From b84d17fd8a63ab6e6ec870d1c75ca41b0ba9a2c4 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 7 Apr 2026 16:56:28 -0400 Subject: [PATCH 001/103] Fix loot Ctrl+Click dressing room and tooltip bleed Use Ctrl+Click (matching default UI) instead of Shift+Click to preview loot items. Fall through to EJ navigation when the item has no visual model. Clear stale tooltip IDs when recycling result buttons between mount/toy/pet/outfit and loot result types. --- UI.lua | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/UI.lua b/UI.lua index 0116666..9c26393 100644 --- a/UI.lua +++ b/UI.lua @@ -4065,6 +4065,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.icon.petID = data.petID resultRow.icon.spellID = data.spellID resultRow.icon.outfitID = data.outfitID + resultRow.icon.lootItemID = nil -- 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) @@ -4134,6 +4135,11 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.icon:SetPoint("RIGHT", resultRow, "RIGHT", -5, 0) resultRow.icon:Show() resultRow.icon.lootItemID = data.itemID + resultRow.icon.mountID = nil + resultRow.icon.toyItemID = nil + resultRow.icon.petID = nil + resultRow.icon.spellID = nil + resultRow.icon.outfitID = nil resultRow.icon:SetVertexColor(1, 1, 1, 1) else SetRowIcon(resultRow, "hidden", nil, theme.iconSize) @@ -5048,12 +5054,13 @@ 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 navigates EJ + -- Loot: Ctrl+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 + if IsControlKeyDown() and lootLink then + if DressUpItemLink(lootLink) then + return + end end -- Sync EJ loot filter so the item is visible when we navigate there From 420681daa50fa4b16adfbf4718c5bf699fce6510 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 9 Apr 2026 01:03:11 -0400 Subject: [PATCH 002/103] Add appearance set search with click-to-dress and filter sync Search results include transmog appearance sets with class, collected, and PvE/PvP filters that sync both directions with the default UI's wardrobe filter. Ctrl+click a set to dress up the full outfit in the dressing room, or click to navigate to it in the wardrobe. Pinned sets and pinned loot now round-trip correctly through SavedVariables and hydrate from the live database when older pins are missing fields. Repopulate the set list when the filter dropdown opens and detects that the default UI's class filter changed since last populate. --- .luacheckrc | 5 +- Core.lua | 10 + Database.lua | 235 +++++++++++++++++++- Highlight.lua | 73 +++++++ UI.lua | 595 ++++++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 896 insertions(+), 22 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 9e45d5e..200b813 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -26,6 +26,7 @@ globals = { -- WoW API globals (read-only) read_globals = { -- WoW Lua extensions + "bit", "debugstack", "strsplit", "strtrim", "wipe", "hooksecurefunc", "format", "time", "date", @@ -68,7 +69,7 @@ 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_TransmogOutfitInfo", "C_VignetteInfo", + "C_ToyBox", "C_TransmogCollection", "C_TransmogOutfitInfo", "C_TransmogSets", "C_VignetteInfo", -- UI utility functions "UIFrameFadeIn", "UIFrameFadeOut", "UIFrameFadeRemoveFrame", @@ -76,7 +77,7 @@ read_globals = { "UnitPopup_ShowMenu", "BattlePetToolTip_ShowLink", "GetUnitSpeed", "GetItemCooldown", "EJ_GetInstanceInfo", "UnitName", "GetItemInfoInstant", "GetItemStats", "GetSpecialization", "GetSpecializationInfo", - "UnitClass", "GetLootSpecialization", "DressUpItemLink", + "UnitClass", "GetLootSpecialization", "DressUpItemLink", "DressUpTransmogSet", "GetNumClasses", "GetClassInfo", "GetNumSpecializationsForClassID", "GetSpecializationInfoForClassID", "RAID_CLASS_COLORS", "EJ_GetCurrentTier", "EJ_SelectTier", "EJ_GetInstanceByIndex", diff --git a/Core.lua b/Core.lua index f27c51f..d8f4697 100644 --- a/Core.lua +++ b/Core.lua @@ -106,6 +106,7 @@ local DB_DEFAULTS = { pets = false, outfits = false, loot = false, + appearanceSets = false, map = false, }, lootSpecs = nil, -- Loot search: nil = current spec only, table of {classID, specID} pairs when customized @@ -113,6 +114,11 @@ local DB_DEFAULTS = { lootSearchStats = true, -- Loot search: match by stat keywords (haste, crit, etc.) lootUpgradesOnly = false, -- Loot search: only show items above equipped ilvl lootDifficulty = "normal", + appearanceSetClass = nil, -- nil = player class, "all" = all, {classID=N} = specific + appearanceSetCollected = true, -- Show collected sets + appearanceSetNotCollected = true, -- Show uncollected sets + appearanceSetPvE = true, -- Show PvE sets (Dungeon/Raid) + appearanceSetPvP = true, -- Show PvP sets uiMapSearchLocal = true, -- Map search in UI bar: true = local zone only, false = global } @@ -391,6 +397,10 @@ local function OnPlayerLogin() ns.Database:PopulateDynamicPets() SafeAfter(0, function() ns.Database:PopulateDynamicOutfits() + SafeAfter(0, function() + ns.Database:SyncTransmogSetFiltersFromUI() + ns.Database:PopulateDynamicTransmogSets() + end) end) end) end) diff --git a/Database.lua b/Database.lua index 677b6bd..e27bf54 100644 --- a/Database.lua +++ b/Database.lua @@ -9,6 +9,7 @@ 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 +local band, lshift = bit.band, bit.lshift -- Word split cache: avoids per-call gmatch + table creation in scoring hot path. -- Key = lowercase string, value = array of words split on [%w']+. @@ -443,6 +444,13 @@ local LOOT_PROTO = { } local LOOT_MT = { __index = LOOT_PROTO } +local TRANSMOG_SET_PROTO = { + category = "Appearance Set", + path = {}, + steps = {}, +} +local TRANSMOG_SET_MT = { __index = TRANSMOG_SET_PROTO } + -- Map equip location strings to user-friendly search keywords local SLOT_KEYWORDS = { INVTYPE_HEAD = {"helm", "helmet", "head"}, @@ -524,7 +532,7 @@ function Database:GetEJDifficultyID(sourceType) 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 + return diffIDs[srcKey] end function Database:SetEJDifficulty(diffID) @@ -533,6 +541,45 @@ function Database:SetEJDifficulty(diffID) if setDiff then setDiff(diffID) end end +-- Sync the EJ's difficulty for both dungeon and raid tabs. +-- EJ_SetDifficulty requires an instance of the matching type to be selected first. +-- Saves and restores the current instance selection to avoid side effects. +function Database:SyncEJDifficulty() + local selectInst = EJ("SelectInstance") + local getInst = EJ("GetInstanceByIndex") + local setDiff = EJ("SetDifficulty") + local getInstInfo = EJ("GetInstanceInfo") + if not selectInst or not getInst or not setDiff then return end + + -- Save current instance so we can restore it after + -- EJ_GetInstanceInfo returns: name, description, bgImage, buttonImage1, ..., mapID, journalInstanceID + -- journalInstanceID is at index 12 + local savedInstID = getInstInfo and select(12, getInstInfo()) + + local dungeonDiffID = self:GetEJDifficultyID("Dungeon") + if dungeonDiffID then + local dInstID = getInst(1, false) + if dInstID then + selectInst(dInstID) + setDiff(dungeonDiffID) + end + end + + local raidDiffID = self:GetEJDifficultyID("Raid") + if raidDiffID then + local rInstID = getInst(1, true) + if rInstID then + selectInst(rInstID) + setDiff(raidDiffID) + end + end + + -- Restore previous instance selection + if savedInstID and savedInstID > 0 then + selectInst(savedInstID) + 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() @@ -626,8 +673,28 @@ 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. +-- Look up a transmog set's ID by exact name. Used to recover the setID for +-- pinned appearance sets that were saved before transmogSetID was persisted. +function Database:GetTransmogSetIDByName(name) + if not name or not C_TransmogSets or not C_TransmogSets.GetAllSets then return nil end + local allSets = C_TransmogSets.GetAllSets() + if not allSets then return nil end + for i = 1, #allSets do + local s = allSets[i] + if s and s.name == name then return s.setID end + end + return nil +end + function Database:GetLootItemLink(entry) local links = entry.lootItemLinks + -- Pinned/serialized entries lose their lootItemLinks table when written + -- to SavedVariables. Fall back to the live loot cache by itemID so + -- existing pins keep working without needing to be re-pinned. + if not links and entry.itemID then + local live = lootItemCache[entry.itemID] + if live then links = live.lootItemLinks end + end if not links then return nil end local selected = EasyFind.db.lootDifficulty or "normal" if links[selected] then return links[selected] end @@ -807,6 +874,172 @@ function Database:PopulateDynamicOutfits() end end +-- Reads the default UI's transmog set filters and updates our saved settings. +-- Called before populate and when the filter dropdown opens. +function Database:SyncTransmogSetFiltersFromUI() + local db = EasyFind and EasyFind.db + if not db then return end + local getFilter = C_TransmogSets and (C_TransmogSets.GetBaseSetsFilter or C_TransmogSets.GetSetsFilter) + if getFilter then + -- Enum: 1=Collected, 2=Not Collected, 3=PvE, 4=PvP + local ok1, v1 = pcall(getFilter, 1) + local ok2, v2 = pcall(getFilter, 2) + local ok3, v3 = pcall(getFilter, 3) + local ok4, v4 = pcall(getFilter, 4) + if ok1 then db.appearanceSetCollected = v1 end + if ok2 then db.appearanceSetNotCollected = v2 end + if ok3 then db.appearanceSetPvE = v3 end + if ok4 then db.appearanceSetPvP = v4 end + end + local getClass = C_TransmogSets and C_TransmogSets.GetTransmogSetsClassFilter + if getClass then + local ok, classID = pcall(getClass) + if ok and classID then + local _, _, playerClassID = UnitClass("player") + if classID == playerClassID then + db.appearanceSetClass = nil + else + db.appearanceSetClass = { classID = classID } + end + end + end +end + +-- Called after PLAYER_LOGIN when C_TransmogSets is available. +-- Scans all transmog appearance sets and injects them into the search database. +-- Respects class, collected, and PvE/PvP filter settings. +function Database:PopulateDynamicTransmogSets() + if not C_TransmogSets or not C_TransmogSets.GetAllSets then return end + + -- Remove previous entries (handles mid-session filter changes) + for i = #uiSearchData, 1, -1 do + if uiSearchData[i].transmogSetID then + tremove(uiSearchData, i) + end + end + + local allSets = C_TransmogSets.GetAllSets() + if not allSets then return end + + local db = EasyFind and EasyFind.db + if not db then return end + local classFilter = db.appearanceSetClass + local showCollected = not db or db.appearanceSetCollected ~= false + local showNotCollected = not db or db.appearanceSetNotCollected ~= false + local showPvE = not db or db.appearanceSetPvE ~= false + local showPvP = not db or db.appearanceSetPvP ~= false + + -- Sync class filter to default UI + if C_TransmogSets.SetTransmogSetsClassFilter then + if not classFilter then + local _, _, cid = UnitClass("player") + if cid then C_TransmogSets.SetTransmogSetsClassFilter(cid) end + elseif classFilter ~= "all" and type(classFilter) == "table" and classFilter.classID then + C_TransmogSets.SetTransmogSetsClassFilter(classFilter.classID) + end + end + + -- Sync collected/PvE/PvP filters to default UI + -- BaseSetsFilter enum: 1=Collected, 2=Not Collected, 3=PvE, 4=PvP + local syncFilter = C_TransmogSets.SetBaseSetsFilter or C_TransmogSets.SetSetsFilter + if syncFilter then + pcall(syncFilter, 1, showCollected) + pcall(syncFilter, 2, showNotCollected) + pcall(syncFilter, 3, showPvE) + pcall(syncFilter, 4, showPvP) + end + + -- Refresh the default UI's sets list and class dropdown if loaded + local wcf = _G["WardrobeCollectionFrame"] + local scf = wcf and wcf.SetsCollectionFrame + if scf and scf:IsShown() then + if scf.SetDataSource then pcall(scf.SetDataSource, scf) end + if scf.UpdateUI then pcall(scf.UpdateUI, scf) end + if scf.Refresh then pcall(scf.Refresh, scf) end + end + -- Refresh class dropdown text + if wcf and wcf.ClassDropdown and wcf.ClassDropdown.Update then + pcall(wcf.ClassDropdown.Update, wcf.ClassDropdown) + end + + -- Determine class mask for filtering + local wantMask + if not classFilter then + local _, _, cid = UnitClass("player") + wantMask = cid and lshift(1, cid - 1) or 0 + elseif classFilter == "all" then + wantMask = nil -- accept all + elseif type(classFilter) == "table" and classFilter.classID then + wantMask = lshift(1, classFilter.classID - 1) + end + + local GetSetPrimaryAppearances = C_TransmogSets.GetSetPrimaryAppearances + local GetSourceIcon = C_TransmogCollection and C_TransmogCollection.GetSourceIcon + + for i = 1, #allSets do + local setInfo = allSets[i] + if setInfo.name and setInfo.name ~= "" and not setInfo.hiddenUntilCollected then + -- Class filter: use classMask for specific class, or accept all + local cm = setInfo.classMask or 0 + local classOk = not wantMask or cm == 0 or cm < 0 or band(cm, wantMask) ~= 0 + + -- PvE/PvP filter (check for known PvP label patterns) + local label = setInfo.label or "" + local labelLower = slower(label) + local isPvP = sfind(labelLower, "pvp") or sfind(labelLower, "season") + or sfind(labelLower, "gladiator") or sfind(labelLower, "aspirant") + or sfind(labelLower, "combatant") + local sourceOk = (isPvP and showPvP) or (not isPvP and showPvE) + + -- Collected filter + local collected = setInfo.collected + local collectedOk = true + if not (showCollected and showNotCollected) then + if collected and not showCollected then collectedOk = false end + if not collected and not showNotCollected then collectedOk = false end + end + + if classOk and sourceOk and collectedOk then + local nameLower = slower(setInfo.name) + local kw = {"set", "transmog", "appearance"} + local kwLen = 3 + if label ~= "" then + kwLen = kwLen + 1 + kw[kwLen] = labelLower + end + if setInfo.description and setInfo.description ~= "" then + local descLower = slower(setInfo.description) + if descLower ~= nameLower then + kwLen = kwLen + 1 + kw[kwLen] = descLower + end + end + + -- Get icon from first appearance source + local icon + if GetSetPrimaryAppearances and GetSourceIcon then + local appearances = GetSetPrimaryAppearances(setInfo.setID) + if appearances and appearances[1] then + local sourceID = appearances[1].appearanceID + if sourceID then + icon = GetSourceIcon(sourceID) + end + end + end + + uiSearchData[#uiSearchData + 1] = setmetatable({ + name = setInfo.name, + nameLower = nameLower, + transmogSetID = setInfo.setID, + icon = icon, + keywords = kw, + keywordsLower = kw, + }, TRANSMOG_SET_MT) + end + end + end +end + function Database:FindEmptyActionSlot() -- Scan from high to low for an empty slot. -- Skip 121-168: bonus/override/vehicle/stance bars that may reject diff --git a/Highlight.lua b/Highlight.lua index 0d54322..b29b968 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -1238,6 +1238,51 @@ function Highlight:UpdateGuide() return end + -- Wardrobe Sets tab: switch to the Sets sub-tab within WardrobeCollectionFrame + if step.wardrobeSetsTab then + local wcf = _G["WardrobeCollectionFrame"] + if wcf and wcf.SetsCollectionFrame and wcf.SetsCollectionFrame:IsShown() then + self:AdvanceStep() + return + end + local setsTab = self:GetTabButton("WardrobeCollectionFrame", 2) + if setsTab then + self:HighlightFrame(setsTab) + end + return + end + + -- Transmog set: scroll to and highlight in the Sets ScrollBox + if step.transmogSetID then + local wcf = _G["WardrobeCollectionFrame"] + local setsFrame = wcf and wcf.SetsCollectionFrame + local scrollBox = setsFrame and setsFrame.ListContainer + and setsFrame.ListContainer.ScrollBox + if not scrollBox then return end + + local targetName = step.transmogSetName and slower(step.transmogSetName) + ScrollBoxScrollTo(scrollBox, function(elementData) + if not elementData then return false end + return elementData.setID and elementData.setID == step.transmogSetID + end) + local setBtn = ScrollBoxFindButton(scrollBox, function(btn) + local edata = btn.GetElementData and btn:GetElementData() + if edata and edata.setID == step.transmogSetID then return true end + if targetName then + local text = GetButtonText(btn) + if text and slower(text) == targetName then return true end + end + return false + end) + if setBtn then + self:HighlightFrame(setBtn) + if canHoverDismiss() and setBtn:IsMouseOver() then + self:Cancel() + end + end + return + end + -- Text-only final step (when we've navigated but can't highlight specific element) if step.text and not step.regionFrames and not step.regionFrame and not step.searchButtonText then self:ShowInstruction(step.text) @@ -2017,6 +2062,34 @@ function Highlight:GetTabButton(frameName, tabIndex) return _G["AchievementFrameTab" .. tabIndex] end + -- WardrobeCollectionFrame tabs (Items / Sets) + if frameName == "WardrobeCollectionFrame" then + -- Try named globals first + local tabBtn = _G["WardrobeCollectionFrameTab" .. tabIndex] + if tabBtn then return tabBtn end + local frame = _G["WardrobeCollectionFrame"] + if frame then + -- Try .Tabs array (modern WoW tab system) + if frame.Tabs and frame.Tabs[tabIndex] then return frame.Tabs[tabIndex] end + -- Try named properties + if tabIndex == 2 and frame.SetsTab then return frame.SetsTab end + if tabIndex == 1 and frame.ItemsTab then return frame.ItemsTab end + -- Search children by name or debugName + local tabKeywords = { {"Items", "Item", "Tab1"}, {"Sets", "Set", "Tab2"} } + local keywords = tabKeywords[tabIndex] + if keywords then + for i = 1, select("#", frame:GetChildren()) do + local child = select(i, frame:GetChildren()) + local name = child:GetName() or "" + local debugName = child.GetDebugName and child:GetDebugName() or "" + for _, kw in ipairs(keywords) do + if sfind(name, kw) or sfind(debugName, kw) then return child end + end + end + end + end + end + -- EncounterJournal tabs (Adventure Guide) if frameName == "EncounterJournal" then local frame = EncounterJournal diff --git a/UI.lua b/UI.lua index 9c26393..d51f952 100644 --- a/UI.lua +++ b/UI.lua @@ -51,8 +51,12 @@ end local CLEAN_SIMPLE_FIELDS = {"name", "nameLower", "category", "buttonFrame", "flashLabel", "icon", "mountID", "spellID", "toyItemID", "petID", "speciesID", "outfitID", "itemID", "encounterID", "instanceID", "lootSlotName", "lootSourceName", "lootInstanceName", "lootSourceType", + "transmogSetID", "factionID", "hasRepBar", "canQueue", "isPvP", "isPvE"} -local CLEAN_TABLE_FIELDS = {"path", "steps", "keywords", "keywordsLower"} +-- Tables to copy. Some are arrays (path, steps, keywords) and some are +-- string-keyed maps (lootItemLinks = {[difficulty] = link}), so iterate +-- with pairs rather than ipairs. +local CLEAN_TABLE_FIELDS = {"path", "steps", "keywords", "keywordsLower", "lootItemLinks"} local function CleanUIForStorage(data) local clean = {} @@ -65,17 +69,17 @@ local function CleanUIForStorage(data) local k = CLEAN_TABLE_FIELDS[fi] local v = data[k] if v then - local arr = {} - for i2, v2 in ipairs(v) do + local copy = {} + for k2, v2 in pairs(v) do if type(v2) == "table" then local sub = {} for sk, sv in pairs(v2) do sub[sk] = sv end - arr[i2] = sub + copy[k2] = sub else - arr[i2] = v2 + copy[k2] = v2 end end - clean[k] = arr + clean[k] = copy end end return clean @@ -172,6 +176,58 @@ local function UnpinUIItem(data) end end +-- Apply a full transmog set to the DressUpFrame using Blizzard's own +-- DressUpTransmogSet. The function's first parameter is misleadingly named +-- "setID" in older docs but actually takes a table of itemModifiedAppearanceIDs +-- (the sources to dress up). Passing a raw setID into it is the mistake that +-- makes Blizzard's code appear broken. +function UI:DressUpAppearanceSet(setID) + if not setID or not C_TransmogSets then return end + + -- Gather source IDs from whichever API returns data. GetAllSourceIDs is + -- the canonical form (a numeric array), preferred when available. + local sources, n = {}, 0 + local seen = {} + local function add(sid) + if sid and sid ~= 0 and not seen[sid] then + seen[sid] = true + n = n + 1 + sources[n] = sid + end + end + + if C_TransmogSets.GetAllSourceIDs then + local ids = C_TransmogSets.GetAllSourceIDs(setID) + if ids then + for i = 1, #ids do add(ids[i]) end + end + end + if n == 0 and C_TransmogSets.GetSetSources then + local set = C_TransmogSets.GetSetSources(setID) + if set then + for sourceID in pairs(set) do add(sourceID) end + end + end + if n == 0 and C_TransmogSets.GetSetPrimaryAppearances then + local appearances = C_TransmogSets.GetSetPrimaryAppearances(setID) + if appearances then + for i = 1, #appearances do + local app = appearances[i] + add(app and app.appearanceID) + end + end + end + + if n == 0 then + EasyFind:Print("could not load sources for this appearance set.") + return + end + + if DressUpTransmogSet then + DressUpTransmogSet(sources) + end +end + -- Sync pinned outfit names/icons with current outfit data. -- Called when TRANSMOG_OUTFITS_CHANGED fires (outfits renamed/deleted). function UI:SyncOutfitPins() @@ -1219,9 +1275,10 @@ local UI_FILTER_OPTIONS = { { key = "map", label = "Map Search", iconAtlas = "Waypoint-MapPin-ChatIcon" }, { 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 = "pets", label = "Pets", iconTex = 631719 }, -- PetJournal paw icon { key = "outfits", label = "Outfits", iconTex = 132649 }, -- INV_Chest_Cloth_17 { key = "loot", label = "Loot", iconTex = 132281 }, -- INV_Sword_04 + { key = "appearanceSets", label = "Appearance Sets", iconTex = "Interface\\Icons\\INV_Helmet_03" }, } function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) @@ -1236,6 +1293,9 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) dropdown:SetFrameLevel(9999) dropdown:Hide() dropdown:EnableMouse(true) + -- Popups that should prevent the dropdown from closing on outside-click. + -- Each sub-filter registers its popups here instead of hardcoding frame names. + local dropdownGuardFrames = {} dropdown:SetClampedToScreen(true) dropdown:SetBackdrop({ @@ -1621,6 +1681,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end) AddPopupKeyboardNav(diffPopup, function() return diffPopupRows end) + dropdownGuardFrames[#dropdownGuardFrames + 1] = diffPopup row.diffBtn = diffBtn row.diffPopup = diffPopup @@ -2089,6 +2150,10 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end) + -- Register loot popups as dropdown guard frames + dropdownGuardFrames[#dropdownGuardFrames + 1] = specPopup + dropdownGuardFrames[#dropdownGuardFrames + 1] = classFlyout + -- Close flyouts when dropdown hides dropdown:HookScript("OnHide", function() classFlyout:Hide() @@ -2146,6 +2211,338 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) kbHighlight:Hide() row.kbHighlight = kbHighlight + -- Appearance Sets sub-options: class selector + Filter button (collected/PvE/PvP) + if opt.key == "appearanceSets" then + local FLYOUT_ROW_H_AS = 20 + local CLASSPOPUP_WIDTH_AS = 160 + local FILTERFLYOUT_WIDTH_AS = 150 + local FILTERFLYOUT_ROW_H = 22 + + -- Separator line + local asSep = dropdown:CreateTexture(nil, "ARTWORK") + asSep:SetHeight(1) + asSep:SetColorTexture(0.5, 0.5, 0.5, 0.4) + asSep:Hide() + row.asSep = asSep + + -- Build class data (same pattern as loot) + local CLASS_COLORS_AS = RAID_CLASS_COLORS + local asClasses = {} + for classIdx = 1, GetNumClasses() do + local className, classFile, classID = GetClassInfo(classIdx) + if className and classFile then + asClasses[#asClasses + 1] = { + classID = classID, className = className, classFile = classFile, + } + end + end + + local function ApplyAsFilterSelection() + if ns.Database and ns.Database.PopulateDynamicTransmogSets then + ns.Database:PopulateDynamicTransmogSets() + end + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end + + ----------------------------------------------------------- + -- Class selector bar (matches loot spec selector exactly) + ----------------------------------------------------------- + local classSelectRow = CreateFrame("Button", nil, dropdown) + classSelectRow:SetSize(120, 27) + local csBg = classSelectRow:CreateTexture(nil, "BACKGROUND") + csBg:SetAtlas("common-dropdown-textholder") + csBg:SetAllPoints() + local csArrow = classSelectRow:CreateTexture(nil, "OVERLAY") + csArrow:SetAtlas("common-dropdown-a-button-hover") + csArrow:SetSize(20, 20) + csArrow:SetPoint("RIGHT", -2, -1) + csArrow:SetVertexColor(0.7, 0.7, 0.7) + local csLabel = classSelectRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + csLabel:SetPoint("LEFT", 8, 0) + csLabel:SetPoint("RIGHT", csArrow, "LEFT", -2, 0) + csLabel:SetJustifyH("LEFT") + csLabel:SetWordWrap(false) + classSelectRow:SetScript("OnEnter", function() csArrow:SetVertexColor(1, 1, 1) end) + classSelectRow:SetScript("OnLeave", function() csArrow:SetVertexColor(0.7, 0.7, 0.7) end) + classSelectRow:Hide() + row.asClassSelectRow = classSelectRow + row.asClassLabel = csLabel + + local function UpdateAsClassLabel() + local cf = EasyFind.db.appearanceSetClass + if not cf then + local _, _, cid = UnitClass("player") + for _, cls in ipairs(asClasses) do + if cls.classID == cid then + local ccl = CLASS_COLORS_AS[cls.classFile] + local cs = ccl and string.format("|cff%02x%02x%02x", ccl.r * 255, ccl.g * 255, ccl.b * 255) or "" + csLabel:SetText(cs .. cls.className .. "|r") + return + end + end + elseif cf == "all" then + csLabel:SetText("All Classes") + elseif type(cf) == "table" and cf.classID then + for _, cls in ipairs(asClasses) do + if cls.classID == cf.classID then + local ccl = CLASS_COLORS_AS[cls.classFile] + local cs = ccl and string.format("|cff%02x%02x%02x", ccl.r * 255, ccl.g * 255, ccl.b * 255) or "" + csLabel:SetText(cs .. cls.className .. "|r") + return + end + end + end + csLabel:SetText("All Classes") + end + UpdateAsClassLabel() + + -- Class popup (opens below the selector bar) + local classPopup = CreateFrame("Frame", "EasyFindAsClassPopup", UIParent, "BackdropTemplate") + classPopup:SetFrameStrata("TOOLTIP") + StylePopup(classPopup) + classPopup:EnableMouse(true) + classPopup:Hide() + classPopup:SetScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end) + classPopup:SetScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + end) + classPopup:SetScript("OnEvent", function(self, event) + if event == "GLOBAL_MOUSE_DOWN" then + if not self:IsMouseOver() and not classSelectRow:IsMouseOver() + and not dropdown:IsMouseOver() then + self:Hide() + end + end + end) + + local classPopupRows = {} + -- "All Classes" row + local allRow = CreateFrame("Button", nil, classPopup) + allRow:SetSize(CLASSPOPUP_WIDTH_AS - 16, FLYOUT_ROW_H_AS) + allRow:SetFrameLevel(classPopup:GetFrameLevel() + 10) + local allRadio, allSetRadio = CreateRadioTexture(allRow) + allRadio:SetPoint("LEFT", 4, 0) + local allLbl = allRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + allLbl:SetPoint("LEFT", allRadio, "RIGHT", 4, 0) + allLbl:SetText("All Classes") + local allHL = allRow:CreateTexture(nil, "HIGHLIGHT") + allHL:SetAllPoints() + allHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + allRow._setRadioChecked = allSetRadio + allRow._classID = nil + allRow:SetScript("OnClick", function() + EasyFind.db.appearanceSetClass = "all" + UpdateAsClassLabel() + classPopup:Hide() + ApplyAsFilterSelection() + end) + classPopupRows[#classPopupRows + 1] = allRow + + for _, cls in ipairs(asClasses) do + local clsRow = CreateFrame("Button", nil, classPopup) + clsRow:SetSize(CLASSPOPUP_WIDTH_AS - 16, FLYOUT_ROW_H_AS) + clsRow:SetFrameLevel(classPopup:GetFrameLevel() + 10) + local cRadio, cSetRadio = CreateRadioTexture(clsRow) + cRadio:SetPoint("LEFT", 4, 0) + local ccl = CLASS_COLORS_AS[cls.classFile] + local csStr = ccl and string.format("|cff%02x%02x%02x", ccl.r * 255, ccl.g * 255, ccl.b * 255) or "" + local cLbl = clsRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + cLbl:SetPoint("LEFT", cRadio, "RIGHT", 4, 0) + cLbl:SetText(csStr .. cls.className .. "|r") + local cHL = clsRow:CreateTexture(nil, "HIGHLIGHT") + cHL:SetAllPoints() + cHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + clsRow._setRadioChecked = cSetRadio + clsRow._classID = cls.classID + clsRow:SetScript("OnClick", function() + EasyFind.db.appearanceSetClass = { classID = cls.classID } + UpdateAsClassLabel() + classPopup:Hide() + ApplyAsFilterSelection() + end) + classPopupRows[#classPopupRows + 1] = clsRow + end + + local function LayoutClassPopup() + local py = -6 + local cf = EasyFind.db.appearanceSetClass + for _, r in ipairs(classPopupRows) do + r:ClearAllPoints() + r:SetPoint("TOPLEFT", classPopup, "TOPLEFT", 8, py) + r:Show() + if r._setRadioChecked then + local match = false + if not r._classID then + match = cf == "all" + else + if type(cf) == "table" and cf.classID == r._classID then + match = true + elseif not cf then + local _, _, cid = UnitClass("player") + match = r._classID == cid + end + end + r._setRadioChecked(match) + end + py = py - FLYOUT_ROW_H_AS + end + classPopup:SetSize(CLASSPOPUP_WIDTH_AS, -py + 6) + end + + classSelectRow:SetScript("OnClick", function(self) + if classPopup:IsShown() then + classPopup:Hide() + return + end + LayoutClassPopup() + classPopup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + classPopup:ClearAllPoints() + classPopup:SetPoint("TOPLEFT", self, "BOTTOMLEFT", 0, 2) + classPopup:Show() + end) + + ----------------------------------------------------------- + -- Filter button (Options.lua multi-select dropdown style) + ----------------------------------------------------------- + local asFilterDefs = { + { dbKey = "appearanceSetCollected", label = "Collected", shortLabel = "Collected" }, + { dbKey = "appearanceSetNotCollected", label = "Not Collected", shortLabel = "Not Collected" }, + { dbKey = "appearanceSetPvE", label = "PvE", shortLabel = "PvE" }, + { dbKey = "appearanceSetPvP", label = "PvP", shortLabel = "PvP" }, + } + + local filterBtn = CreateFrame("Button", nil, dropdown) + filterBtn:SetSize(120, 22) + local filterBg = filterBtn:CreateTexture(nil, "BACKGROUND") + filterBg:SetAllPoints() + filterBg:SetAtlas("common-dropdown-b-button-hover") + local filterArrow = filterBtn:CreateTexture(nil, "OVERLAY") + filterArrow:SetSize(16, 16) + filterArrow:SetPoint("RIGHT", -2, 0) + filterArrow:SetAtlas("common-dropdown-b-arrow-closed") + local filterText = filterBtn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + filterText:SetPoint("LEFT", 4, 0) + filterText:SetPoint("RIGHT", -16, 0) + filterText:SetJustifyH("CENTER") + filterText:SetText("Filter") + filterBtn:Hide() + row.asFilterBtn = filterBtn + + -- Filter flyout panel + local filterFlyout = CreateFrame("Frame", nil, UIParent, "BackdropTemplate") + filterFlyout:SetFrameStrata("TOOLTIP") + StylePopup(filterFlyout) + filterFlyout:EnableMouse(true) + filterFlyout:Hide() + + local asSubRows = {} + local ffy = -4 + for si, def in ipairs(asFilterDefs) do + local cbRow = CreateFrame("CheckButton", nil, filterFlyout) + cbRow:SetSize(FILTERFLYOUT_WIDTH_AS - 8, FILTERFLYOUT_ROW_H) + cbRow:SetPoint("TOPLEFT", filterFlyout, "TOPLEFT", 4, ffy) + + cbRow:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") + cbRow:GetNormalTexture():SetSize(CHECK_SIZE, CHECK_SIZE) + cbRow:GetNormalTexture():ClearAllPoints() + cbRow:GetNormalTexture():SetPoint("LEFT", 4, 0) + + cbRow:SetCheckedTexture("Interface\\Buttons\\UI-CheckBox-Check") + cbRow:GetCheckedTexture():SetSize(CHECK_SIZE, CHECK_SIZE) + cbRow:GetCheckedTexture():ClearAllPoints() + cbRow:GetCheckedTexture():SetPoint("LEFT", 4, 0) + + local cbLabel = cbRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + cbLabel:SetPoint("LEFT", cbRow:GetNormalTexture(), "RIGHT", 4, 0) + cbLabel:SetText(def.label) + + local cbHL = cbRow:CreateTexture(nil, "HIGHLIGHT") + cbHL:SetAllPoints() + cbHL:SetColorTexture(1, 1, 1, 0.1) + + local val = EasyFind.db[def.dbKey] + if val == nil then val = true end + cbRow:SetChecked(val) + cbRow.dbKey = def.dbKey + + cbRow:SetScript("OnClick", function(self) + EasyFind.db[def.dbKey] = self:GetChecked() + ApplyAsFilterSelection() + end) + + asSubRows[si] = cbRow + ffy = ffy - FILTERFLYOUT_ROW_H + -- Separator between "Not Collected" and "PvE" + if si == 2 then + local sep = filterFlyout:CreateTexture(nil, "ARTWORK") + sep:SetHeight(1) + sep:SetPoint("TOPLEFT", filterFlyout, "TOPLEFT", 8, ffy + 2) + sep:SetPoint("TOPRIGHT", filterFlyout, "TOPRIGHT", -8, ffy + 2) + sep:SetColorTexture(0.5, 0.5, 0.5, 0.4) + ffy = ffy - 6 + end + end + filterFlyout:SetSize(FILTERFLYOUT_WIDTH_AS, -ffy + 4) + row.asSubRows = asSubRows + + filterBtn:SetScript("OnClick", function() + local opening = not filterFlyout:IsShown() + filterFlyout:SetShown(opening) + filterArrow:SetAtlas(opening and "common-dropdown-b-arrow-open" or "common-dropdown-b-arrow-closed") + if opening then + filterFlyout:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + filterFlyout:ClearAllPoints() + filterFlyout:SetPoint("TOPLEFT", filterBtn, "BOTTOMLEFT", 0, -2) + end + end) + filterFlyout:SetScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end) + filterFlyout:SetScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + filterArrow:SetAtlas("common-dropdown-b-arrow-closed") + end) + filterFlyout:SetScript("OnEvent", function(self, event) + if event == "GLOBAL_MOUSE_DOWN" then + if not self:IsMouseOver() and not filterBtn:IsMouseOver() + and not dropdown:IsMouseOver() then + self:Hide() + end + end + end) + + -- Register appearance set popups as dropdown guard frames + dropdownGuardFrames[#dropdownGuardFrames + 1] = classPopup + dropdownGuardFrames[#dropdownGuardFrames + 1] = filterFlyout + + -- Close popups when the main dropdown hides + dropdown:HookScript("OnHide", function() + classPopup:Hide() + filterFlyout:Hide() + end) + + row.updateAppearanceSetToggle = function() + local checked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.appearanceSets ~= false + if asSep then asSep:SetShown(checked) end + classSelectRow:SetShown(checked) + filterBtn:SetShown(checked) + for _, sr in ipairs(asSubRows) do + if sr.dbKey and sr.SetChecked then + sr:SetChecked(EasyFind.db[sr.dbKey] ~= false) + end + end + UpdateAsClassLabel() + if not checked then + classPopup:Hide() + filterFlyout:Hide() + end + end + end + row:SetChecked(true) row:SetScript("OnClick", function(self) @@ -2153,6 +2550,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) filters[opt.key] = self:GetChecked() if self.updateMapToggle then self.updateMapToggle() end if self.updateLootToggle then self.updateLootToggle() end + if self.updateAppearanceSetToggle then self.updateAppearanceSetToggle() end LayoutDropdown() if searchEditBox:GetText() ~= "" then UI:OnSearchTextChanged(searchEditBox:GetText()) @@ -2221,6 +2619,42 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end end + -- Appearance Sets sub-rows + if row.asClassSelectRow then + local asChecked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.appearanceSets ~= false + if row.asSep then + if asChecked then + row.asSep:ClearAllPoints() + row.asSep:SetPoint("LEFT", 8 + SUB_INDENT, 0) + row.asSep:SetPoint("RIGHT", dropdown, "RIGHT", -8, 0) + row.asSep:SetPoint("TOP", 0, y - 2) + row.asSep:Show() + y = y - 6 + else + row.asSep:Hide() + end + end + if asChecked then + row.asClassSelectRow:ClearAllPoints() + row.asClassSelectRow:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) + row.asClassSelectRow:Show() + dropdownNavRows[#dropdownNavRows + 1] = row.asClassSelectRow + y = y - 28 + else + row.asClassSelectRow:Hide() + end + if row.asFilterBtn then + if asChecked then + row.asFilterBtn:ClearAllPoints() + row.asFilterBtn:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) + row.asFilterBtn:Show() + dropdownNavRows[#dropdownNavRows + 1] = row.asFilterBtn + y = y - 24 + else + row.asFilterBtn:Hide() + end + end + end -- Loot sub-rows if row.lootSubRows then local lootChecked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.loot ~= false @@ -2365,6 +2799,36 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- Keyboard: enable when opened via Enter on filter button dropdown:HookScript("OnShow", function(self) + -- Sync appearance set filters from default UI on open. If anything + -- changed since we last populated, rebuild uiSearchData so the search + -- actually reflects the new class / collected / PvE / PvP state. + if ns.Database and ns.Database.SyncTransmogSetFiltersFromUI then + local db = EasyFind.db + local beforeClassID = type(db.appearanceSetClass) == "table" + and db.appearanceSetClass.classID or db.appearanceSetClass + local beforeCollected = db.appearanceSetCollected + local beforeNotCollected = db.appearanceSetNotCollected + local beforePvE = db.appearanceSetPvE + local beforePvP = db.appearanceSetPvP + + ns.Database:SyncTransmogSetFiltersFromUI() + + local afterClassID = type(db.appearanceSetClass) == "table" + and db.appearanceSetClass.classID or db.appearanceSetClass + local changed = beforeClassID ~= afterClassID + or beforeCollected ~= db.appearanceSetCollected + or beforeNotCollected ~= db.appearanceSetNotCollected + or beforePvE ~= db.appearanceSetPvE + or beforePvP ~= db.appearanceSetPvP + if changed and ns.Database.PopulateDynamicTransmogSets then + ns.Database:PopulateDynamicTransmogSets() + end + + local asRow = checkRows and checkRows.appearanceSets + if asRow and asRow.updateAppearanceSetToggle then + asRow.updateAppearanceSetToggle() + end + end if searchFrame.filterBtn and searchFrame.filterBtn.keyboardFocused then dropdownKeyboardMode = true Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) @@ -2396,17 +2860,12 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end) - -- Close when clicking outside (but not when interacting with spec/class flyouts) + -- Close when clicking outside (but not when interacting with sub-filter popups) 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 sp = _G["EasyFindSpecPopup"] - local dp = _G["EasyFindDiffPopup"] - if (sf and sf:IsShown() and sf:IsMouseOver()) - or (sp and sp:IsShown() and sp:IsMouseOver()) - or (dp and dp:IsShown() and dp:IsMouseOver()) then - return + for _, guard in ipairs(dropdownGuardFrames) do + if guard:IsShown() and guard:IsMouseOver() then return end end self:Hide() end @@ -2513,7 +2972,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, groupLoot, groupMap = {}, {}, {}, {}, {}, {}, {} +local groupUI, groupMounts, groupToys, groupPets, groupOutfits, groupLoot, groupAppearanceSets, groupMap = {}, {}, {}, {}, {}, {}, {}, {} local uiSectionHeader = { name = "UI", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, @@ -2538,6 +2997,10 @@ local lootSectionHeader = { name = "Loot", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, } +local appearanceSetSectionHeader = { + name = "Appearance Sets", depth = 0, isPathNode = true, + isMatch = false, isSectionHeader = true, +} local mapSectionHeader = { name = "Map Search", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, @@ -3258,13 +3721,14 @@ 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 or filters.loot == false then + if filters.mounts == false or filters.toys == false or filters.pets == false or filters.outfits == false or filters.loot == false or filters.appearanceSets == 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 + if filters.appearanceSets == false then skipCategories["Appearance Set"] = true end end end local results = ns.Database:SearchUI(text, skipCategories) @@ -3274,7 +3738,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.itemID and rd.category == "Loot") 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.transmogSetID or rd.mapSearchResult) then filtered[#filtered + 1] = r end end @@ -3298,6 +3762,7 @@ function UI:OnSearchTextChanged(text) elseif d.petID then cat = "pets" elseif d.outfitID then cat = "outfits" elseif d.itemID and d.category == "Loot" then cat = "loot" + elseif d.transmogSetID then cat = "appearanceSets" else cat = "ui" end if s > (bestCatScore[cat] or 0) then bestCatScore[cat] = s end @@ -3335,6 +3800,7 @@ function UI:OnSearchTextChanged(text) wipe(groupPets) wipe(groupOutfits) wipe(groupLoot) + wipe(groupAppearanceSets) wipe(groupMap) for _, entry in ipairs(hierarchical) do local d = entry.data @@ -3348,6 +3814,8 @@ function UI:OnSearchTextChanged(text) groupOutfits[#groupOutfits + 1] = entry elseif d and d.itemID and d.category == "Loot" then groupLoot[#groupLoot + 1] = entry + elseif d and d.transmogSetID then + groupAppearanceSets[#groupAppearanceSets + 1] = entry else groupUI[#groupUI + 1] = entry end @@ -3372,6 +3840,7 @@ function UI:OnSearchTextChanged(text) 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 #groupAppearanceSets > 0 then catGroups[#catGroups + 1] = { key = "appearanceSets", score = bestCatScore.appearanceSets 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 @@ -3435,6 +3904,9 @@ function UI:OnSearchTextChanged(text) end end end + elseif cat.key == "appearanceSets" then + hierarchical[#hierarchical + 1] = appearanceSetSectionHeader + for _, e in ipairs(groupAppearanceSets) do e.depth = 1; hierarchical[#hierarchical + 1] = e end elseif cat.key == "map" then hierarchical[#hierarchical + 1] = mapSectionHeader for _, e in ipairs(groupMap) do hierarchical[#hierarchical + 1] = e end @@ -4048,7 +4520,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 or data.outfitID) then + elseif not iconSet and data and (data.mountID or data.toyItemID or data.petID or data.outfitID or data.transmogSetID) then local iconFileID = data.icon local rightOffset = -5 @@ -5088,6 +5560,34 @@ function UI:SelectResult(data) return end + -- Appearance Set: Ctrl+click opens dressing room with full set, regular click navigates. + -- For pinned entries saved before transmogSetID was persisted, recover the + -- ID by looking up the set name in the live database. + if data.category == "Appearance Set" and not data.transmogSetID and data.name and ns.Database then + data.transmogSetID = ns.Database:GetTransmogSetIDByName(data.name) + end + if data.transmogSetID then + if IsControlKeyDown() then + self:DressUpAppearanceSet(data.transmogSetID) + return + end + + local guideData = { + steps = { + { buttonFrame = "CollectionsMicroButton" }, + { waitForFrame = "CollectionsJournal", tabIndex = 5 }, + { waitForFrame = "WardrobeCollectionFrame", wardrobeSetsTab = true }, + { waitForFrame = "WardrobeCollectionFrame", transmogSetID = data.transmogSetID, transmogSetName = data.name }, + }, + } + if EasyFind.db.directOpen then + self:DirectOpen(guideData) + else + EasyFind:StartGuide(guideData) + end + return + end + -- Mount: summon/dismiss (secure macro handles cancelform on click) if data.mountID then if C_MountJournal and C_MountJournal.SummonByID then @@ -5221,6 +5721,8 @@ function UI:DirectOpen(data) if step.ejInstance then return true end if step.ejBoss then return true end if step.ejLootTab then return true end + if step.wardrobeSetsTab then return true end + if step.transmogSetID 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 @@ -5412,6 +5914,61 @@ function UI:DirectOpen(data) end end + -- Wardrobe Sets tab: click the Sets tab within WardrobeCollectionFrame + if step.wardrobeSetsTab then + local setsTab = Highlight:GetTabButton("WardrobeCollectionFrame", 2) + if setsTab then + ClickButton(setsTab) + elseif PanelTemplates_SetTab then + local wcf = _G["WardrobeCollectionFrame"] + if wcf then pcall(PanelTemplates_SetTab, wcf, 2) end + end + end + + -- Transmog set: scroll to and highlight in the Sets ScrollBox + if step.transmogSetID and i == executeCount then + local setsFrame = _G["WardrobeCollectionFrame"] + and _G["WardrobeCollectionFrame"].SetsCollectionFrame + local scrollBox = setsFrame and setsFrame.ListContainer + and setsFrame.ListContainer.ScrollBox + if scrollBox then + local targetName = step.transmogSetName and slower(step.transmogSetName) + C_Timer.After(0.1, function() + if targetName then + Utils.ScrollBoxScrollTo(scrollBox, function(elementData) + if not elementData then return false end + local setID = elementData.setID + if setID and setID == step.transmogSetID then return true end + return false + end) + end + C_Timer.After(0.05, function() + local setBtn = Utils.ScrollBoxFindButton(scrollBox, function(btn) + local edata = btn.GetElementData and btn:GetElementData() + if edata and edata.setID == step.transmogSetID then return true end + if targetName then + local text = Utils.GetButtonText(btn) + if text and slower(text) == targetName then return true end + end + return false + end) + if setBtn and Highlight then + Highlight:HighlightFrame(setBtn) + local checkHover + checkHover = function() + if setBtn:IsMouseOver() then + Highlight:HideHighlight() + else + C_Timer.After(0.1, checkHover) + end + end + C_Timer.After(0.3, checkHover) + end + end) + end) + end + end + -- Currency/faction headers pre-expanded via API, nothing to execute if step.currencyID then From 4e58969fc330461170cee6ef468cd42e7f86022a Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 9 Apr 2026 01:34:27 -0400 Subject: [PATCH 003/103] Fix appearance set class filter sync from default wardrobe UI Hook C_TransmogSets.SetTransmogSetsClassFilter so runtime class changes in the default wardrobe propagate to our search data without waiting for the user to open the filter dropdown. Listen for TRANSMOG_COLLECTION_UPDATED so the engine's deferred state restore on login triggers a resync, since the login-time sync can run before the wardrobe state is ready. Guard both paths against recursion when PopulateDynamicTransmogSets calls the setter itself, and invalidate the incremental search cache whenever uiSearchData is repopulated so stale prevCandidates don't hide the newly injected entries. --- Core.lua | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ Database.lua | 19 ++++++++++++++++++- UI.lua | 8 +++++--- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/Core.lua b/Core.lua index d8f4697..974b887 100644 --- a/Core.lua +++ b/Core.lua @@ -400,6 +400,43 @@ local function OnPlayerLogin() SafeAfter(0, function() ns.Database:SyncTransmogSetFiltersFromUI() ns.Database:PopulateDynamicTransmogSets() + + -- Hook Blizzard's class filter setter so any later + -- change (including the engine restoring the saved + -- value after Blizzard_Collections loads) triggers + -- a resync + repopulate. The login sync above can + -- run before the wardrobe state is fully restored, + -- which is why the dropdown-open sync used to be + -- the only thing that picked up the correct class. + if C_TransmogSets and C_TransmogSets.SetTransmogSetsClassFilter + and not EasyFind._tmogClassHooked then + EasyFind._tmogClassHooked = true + hooksecurefunc(C_TransmogSets, "SetTransmogSetsClassFilter", function(classID) + -- Skip when Populate itself called the setter, to avoid + -- infinite recursion through the hook. + if EasyFind._tmogClassHookSuppress then return end + if not classID or not ns.Database then return end + local db = EasyFind.db + if not db then return end + local _, _, playerClassID = UnitClass("player") + local newVal + if classID == playerClassID then + newVal = nil + else + newVal = { classID = classID } + end + -- Compare before vs after: avoid a repopulate if the + -- value didn't actually change. + local oldID = type(db.appearanceSetClass) == "table" + and db.appearanceSetClass.classID or db.appearanceSetClass + local newID = type(newVal) == "table" and newVal.classID or newVal + if oldID == newID then return end + db.appearanceSetClass = newVal + if ns.Database.PopulateDynamicTransmogSets then + ns.Database:PopulateDynamicTransmogSets() + end + end) + end end) end) end) @@ -435,6 +472,7 @@ eventFrame:RegisterEvent("PLAYER_LOGIN") eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD") eventFrame:RegisterEvent("PLAYER_LOGOUT") eventFrame:RegisterEvent("TRANSMOG_OUTFITS_CHANGED") +eventFrame:RegisterEvent("TRANSMOG_COLLECTION_UPDATED") eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2) if event == "ADDON_LOADED" and arg1 == ADDON_NAME then OnInitialize() @@ -462,6 +500,16 @@ eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2) ns.UI:SyncOutfitPins() end end) + elseif event == "TRANSMOG_COLLECTION_UPDATED" then + -- Fires once the transmog collection (including the saved class + -- filter) is fully loaded. Re-sync + repopulate so we pick up the + -- correct class without waiting for the user to open our dropdown. + if ns.Database and ns.Database.SyncTransmogSetFiltersFromUI then + ns.Database:SyncTransmogSetFiltersFromUI() + if ns.Database.PopulateDynamicTransmogSets then + ns.Database:PopulateDynamicTransmogSets() + 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 e27bf54..5937aab 100644 --- a/Database.lua +++ b/Database.lua @@ -917,6 +917,11 @@ function Database:PopulateDynamicTransmogSets() tremove(uiSearchData, i) end end + -- Invalidate incremental search cache. Without this, a query that was + -- typed before the repopulate (e.g. "cauldron" searched with Druid sets + -- active) would still reuse prevCandidates on the next extension and + -- miss the newly injected entries. + if self.ResetSearchCache then self:ResetSearchCache() end local allSets = C_TransmogSets.GetAllSets() if not allSets then return end @@ -929,14 +934,17 @@ function Database:PopulateDynamicTransmogSets() local showPvE = not db or db.appearanceSetPvE ~= false local showPvP = not db or db.appearanceSetPvP ~= false - -- Sync class filter to default UI + -- Sync class filter to default UI. Guard against the hooksecurefunc in + -- Core.lua re-entering Populate from our own call here. if C_TransmogSets.SetTransmogSetsClassFilter then + EasyFind._tmogClassHookSuppress = true if not classFilter then local _, _, cid = UnitClass("player") if cid then C_TransmogSets.SetTransmogSetsClassFilter(cid) end elseif classFilter ~= "all" and type(classFilter) == "table" and classFilter.classID then C_TransmogSets.SetTransmogSetsClassFilter(classFilter.classID) end + EasyFind._tmogClassHookSuppress = false end -- Sync collected/PvE/PvP filters to default UI @@ -2794,6 +2802,15 @@ local prevQuery = "" local prevSkipKey = "" local prevCandidates = {} +-- Clear the incremental search cache. Called after uiSearchData is mutated +-- (e.g. PopulateDynamicTransmogSets) so the next query doesn't reuse a +-- stale candidate list that was built against the old dataset. +function Database:ResetSearchCache() + prevQuery = "" + prevSkipKey = "" + wipe(prevCandidates) +end + function Database:SearchUI(query, skipCategories) if not query or query == "" or #query < 2 then prevQuery = "" diff --git a/UI.lua b/UI.lua index d51f952..aadc58c 100644 --- a/UI.lua +++ b/UI.lua @@ -2799,9 +2799,8 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- Keyboard: enable when opened via Enter on filter button dropdown:HookScript("OnShow", function(self) - -- Sync appearance set filters from default UI on open. If anything - -- changed since we last populated, rebuild uiSearchData so the search - -- actually reflects the new class / collected / PvE / PvP state. + -- Sync appearance set filters from the default UI. Only repopulate + -- if something actually changed, so opening the dropdown is cheap. if ns.Database and ns.Database.SyncTransmogSetFiltersFromUI then local db = EasyFind.db local beforeClassID = type(db.appearanceSetClass) == "table" @@ -2822,6 +2821,9 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) or beforePvP ~= db.appearanceSetPvP if changed and ns.Database.PopulateDynamicTransmogSets then ns.Database:PopulateDynamicTransmogSets() + if searchEditBox and searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end end local asRow = checkRows and checkRows.appearanceSets From c283eb0e32fff73b58de7d19ddedb6cb016d34bc Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 9 Apr 2026 04:05:45 -0400 Subject: [PATCH 004/103] Glow native map pins and default UI search to fast mode --- Core.lua | 3 +- MapSearch.lua | 214 ++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 175 insertions(+), 42 deletions(-) diff --git a/Core.lua b/Core.lua index 974b887..752a79e 100644 --- a/Core.lua +++ b/Core.lua @@ -33,6 +33,7 @@ local DB_DEFAULTS = { enableUISearch = true, enableMapSearch = true, iconScale = 0.8, + nativePinScale = 1.5, -- Multiplier applied to a Blizzard map pin while EasyFind is glowing it uiSearchScale = 1.0, mapSearchScale = 1.0, mapSearchWidth = 0.88, @@ -51,7 +52,7 @@ local DB_DEFAULTS = { globalSearchPositionMax = nil, -- x offset from map right edge (maximized) mapSearchYOffset = 0, -- y offset for search bars relative to map bottom hideSearchBarsMaximized = false, -- Hide search bars when map is full screen - directOpen = false, -- Open panels directly instead of step-by-step + directOpen = true, -- Open panels directly (fast mode) instead of step-by-step (standard mode) localMapDirectOpen = false, -- Zone bar: navigate directly (no zone highlighting) globalMapDirectOpen = false, -- Global bar: navigate directly (no zone highlighting) smartShow = false, -- Hide search bar until mouse hovers nearby diff --git a/MapSearch.lua b/MapSearch.lua index 1879aa3..abc448d 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -8,7 +8,7 @@ local DebugPrint = Utils.DebugPrint local pairs, ipairs, type, select = Utils.pairs, Utils.ipairs, Utils.type, Utils.select local tinsert, tsort, tconcat, tremove = Utils.tinsert, Utils.tsort, Utils.tconcat, Utils.tremove local sfind, slower = Utils.sfind, Utils.slower -local mmin, mmax, mpi = Utils.mmin, Utils.mmax, Utils.mpi +local mmin, mmax, mpi, mfloor = Utils.mmin, Utils.mmax, Utils.mpi, Utils.mfloor local pcall, xpcall, tostring = Utils.pcall, Utils.xpcall, Utils.tostring local ErrorHandler = Utils.ErrorHandler @@ -2731,6 +2731,8 @@ function MapSearch:CreateResultButton(index) if nearest then MapSearch:ShowWaypointAt(nearest.x, nearest.y, nil, nearest.category) end + elseif coords.pin and coords.pin:IsShown() then + MapSearch:HighlightPin(coords.pin, coords.x, coords.y, coords.icon, coords.category) else MapSearch:ShowWaypointAt(coords.x, coords.y, coords.icon, coords.category) end @@ -2830,6 +2832,11 @@ function MapSearch:CreateResultButton(index) MapSearch._previewing = true if coords.instances then MapSearch:ShowMultipleWaypoints(coords.instances) + elseif coords.pin and coords.pin:IsShown() then + -- Glow the live native pin in place. coords.x/y/icon/category + -- are forwarded so HighlightPin can fall back to ShowWaypointAt + -- if the pin disappears mid-hover. + MapSearch:HighlightPin(coords.pin, coords.x, coords.y, coords.icon, coords.category) else MapSearch:ShowWaypointAt(coords.x, coords.y, coords.icon, coords.category) end @@ -5603,14 +5610,88 @@ function MapSearch:ScanMapPOIs() end end - return pois + -- Dedupe: when both the API path and the canvas-children scan report the + -- same POI (same category at the same coordinates), keep the canvas-scan + -- entry because it carries a live pin reference. SelectResult uses that + -- reference to glow the native icon in place instead of stamping an + -- overlay icon on top. + local deduped = {} + local seenByKey = {} + for _, poi in ipairs(pois) do + local key + if poi.x and poi.y and poi.category then + key = poi.category .. "|" .. mfloor(poi.x * 1000) .. "|" .. mfloor(poi.y * 1000) + end + if not key then + tinsert(deduped, poi) + else + local existingIdx = seenByKey[key] + if not existingIdx then + tinsert(deduped, poi) + seenByKey[key] = #deduped + elseif poi.pin and not deduped[existingIdx].pin then + deduped[existingIdx] = poi + end + end + end + return deduped +end + +-- Generic glow/waypoint atlases stacked on every native pin. Skipped when +-- looking for the pin's distinguishing icon. +local PIN_SKIP_ATLAS = { + ["Waypoint-MapPin-Tracked"] = true, + ["Waypoint-MapPin-Untracked"] = true, + ["UI-QuestPoi-OuterGlow"] = true, +} + +-- Native Blizzard pin atlases we recognize by sight. Used as a fallback when +-- the pin's other Lua metadata (areaPoiInfo, vignetteInfo) doesn't categorize +-- it - some data providers expose pins as plain Frames with only an atlas to +-- tell you what they are (e.g., trading post, quartermaster, chromie). +local PIN_ATLAS_TYPES = { + ["ChromieTime-32x32"] = { name = "Chromie", category = "chromie" }, + ["trading-post-minimap-icon"] = { name = "Trading Post", category = "tradingpost" }, + ["Quartermaster"] = { name = "Quartermaster", category = "quartermaster" }, +} + +-- Inspect a pin's textures and return the first known atlas identity, plus +-- the first non-generic ARTWORK atlas as a fallback icon. atlasName / +-- atlasCategory are nil when the pin has no recognized atlas; atlasIcon may +-- still be set (any non-generic ARTWORK atlas). +local function ScanPinAtlasIdentity(pin) + local atlasName, atlasCategory, atlasIcon + for _, region in pairs({pin:GetRegions()}) do + if region.GetAtlas then + local a = region:GetAtlas() + if a and a ~= "" and not PIN_SKIP_ATLAS[a] + and region:GetDrawLayer() == "ARTWORK" then + if not atlasIcon then + atlasIcon = "atlas:" .. a + end + if not atlasName then + local known = PIN_ATLAS_TYPES[a] + if known then + atlasName = known.name + atlasCategory = known.category + end + end + end + end + end + return atlasName, atlasCategory, atlasIcon end function MapSearch:GetPinInfo(pin) if not pin or not pin:IsShown() then return nil end + -- Pre-scan atlases. Used as a fallback for any code path below that + -- would otherwise return nil due to unrecognized areaPoiInfo names or + -- vignette types we don't normally track. + local atlasName, atlasCategory, atlasIcon = ScanPinAtlasIdentity(pin) + local name = nil - local icon = nil + local icon = atlasIcon local pinType = "unknown" local category = nil @@ -5668,18 +5749,31 @@ function MapSearch:GetPinInfo(pin) category = "chromie" pinType = "chromie" icon = "atlas:ChromieTime-32x32" + elseif atlasCategory then + -- API name doesn't match our keyword list, but the pin's atlas + -- tells us what it is (e.g., a vendor NPC name like "Boots + -- Murphy" on a Quartermaster atlas). Keep the API name and + -- adopt the atlas-derived category. + category = atlasCategory + pinType = atlasCategory else - -- Generic area POI - skip it (these are usually landmarks, events, etc.) return nil end end - -- Vignettes: treasures only. Rares handled by ScanVignettes() (provides GUID for super-tracking) + -- Vignettes: treasures get the treasure category. Other vignette types + -- (vendors, services) are still useful if their atlas tells us what + -- they are; otherwise rares are handled by ScanVignettes() (which + -- provides GUID for super-tracking) so we drop them here. if pin.vignetteInfo then if pin.vignetteInfo.vignetteType == 2 then name = pin.vignetteInfo.name pinType = "vignette" category = "treasure" + elseif atlasCategory then + name = name or pin.vignetteInfo.name + category = atlasCategory + pinType = atlasCategory else return nil end @@ -5700,38 +5794,19 @@ function MapSearch:GetPinInfo(pin) return nil end - -- Scan pin regions for atlas-based icons (shows the real map pin icon) - -- Also used to identify unknown pin types by their atlas name - local skipAtlas = { ["Waypoint-MapPin-Tracked"] = true, ["Waypoint-MapPin-Untracked"] = true, ["UI-QuestPoi-OuterGlow"] = true } - -- Atlas names that identify specific known pin types when other data fields are absent - local atlasPinTypes = { - ["ChromieTime-32x32"] = { name = "Chromie", category = "chromie" }, - } - do - for _, region in pairs({pin:GetRegions()}) do - if region.GetAtlas then - local atlas = region:GetAtlas() - if atlas and atlas ~= "" and not skipAtlas[atlas] then - local layer = region:GetDrawLayer() - if layer == "ARTWORK" then - if not icon then - icon = "atlas:" .. atlas - end - if not name then - local known = atlasPinTypes[atlas] - if known then - name = known.name - category = known.category - pinType = known.category - end - end - end - end - end - end + -- No areaPoiInfo / vignetteInfo categorization happened: fall back to + -- whatever the atlas pre-scan identified. This is the path for plain + -- Frame pins that have no Lua metadata at all (e.g., trading post, + -- quartermaster on some maps). + if not category and atlasCategory then + category = atlasCategory + pinType = atlasCategory + end + if not name and atlasName then + name = atlasName end - -- Fallback raw texture scan (only if atlas scan found nothing) + -- Final fallback raw texture scan if we still have no icon at all if not icon then if pin.Texture and pin.Texture.GetTexture then local tex = pin.Texture:GetTexture() @@ -5746,7 +5821,7 @@ function MapSearch:GetPinInfo(pin) end end - if not name or name == "" then + if not name or name == "" or not category then return nil end @@ -6581,6 +6656,8 @@ function MapSearch:UpdateSelectionHighlight(skipRefocus) self._previewing = true if coords.instances then self:ShowMultipleWaypoints(coords.instances) + elseif coords.pin and coords.pin:IsShown() then + self:HighlightPin(coords.pin, coords.x, coords.y, coords.icon, coords.category) else self:ShowWaypointAt(coords.x, coords.y, coords.icon, coords.category) end @@ -6751,6 +6828,11 @@ function MapSearch:SelectResult(data) SetSuperTrackedVignette(single.vignetteGUID) end end + elseif data.pin and data.pin:IsShown() then + -- Native canvas pin available: glow the existing in-game icon + -- directly so the player still sees Blizzard's pin underneath + -- the highlight border, instead of stamping our own overlay. + self:HighlightPin(data.pin, data.x, data.y, data.icon, data.category) elseif data.x and data.y then -- Single POI with coordinates self:ShowWaypointAt(data.x, data.y, data.icon, data.category) @@ -6760,7 +6842,7 @@ function MapSearch:SelectResult(data) SetSuperTrackedVignette(data.vignetteGUID) end elseif data.pin then - -- Dynamic pin with no coords - highlight it directly + -- Pin reference but currently hidden and no coords: clear. self:HighlightPin(data.pin) end end @@ -7091,17 +7173,59 @@ function MapSearch:ShowWaypointAt(x, y, icon, category) end end -function MapSearch:HighlightPin(pin) +-- Tracks the canvas pin currently scaled up by HighlightPin so ClearHighlight +-- can restore the original scale. Module-scoped because the pin reference +-- doesn't survive a /reload. +local highlightedNativePin = nil + +local function RestoreNativePinScale() + local p = highlightedNativePin + if not p then return end + if p._easyfindOriginalScale and p.SetScale then + pcall(p.SetScale, p, p._easyfindOriginalScale) + end + p._easyfindOriginalScale = nil + highlightedNativePin = nil +end + +function MapSearch:HighlightPin(pin, x, y, icon, category) waypointPin:Hide() + -- Restore any previously scaled native pin before highlighting a new one. + RestoreNativePinScale() + if not pin or not pin:IsShown() then + -- Pin gone or hidden: fall back to overlay if we know the coords, + -- otherwise just clear. + if x and y then + self:ShowWaypointAt(x, y, icon, category) + return + end self:ClearHighlight() return end + -- Save state with coords + category so close/reopen can restore via the + -- ShowWaypointAt fallback. The live pin reference will be stale by then. + if x and y then + activePinState = { + mapID = WorldMapFrame:GetMapID(), + x = x, y = y, + icon = icon, category = category, + isLocal = not isGlobalSearch, + } + end - -- Convert UI-unit sizes to canvas units + -- Scale the native pin up so it visually pops while highlighted. The + -- original scale is cached on the pin frame and restored by + -- ClearHighlight (or the next HighlightPin call). local userScale = EasyFind.db.iconScale or 0.8 + local nativePinScale = (EasyFind.db.nativePinScale or 1.5) * userScale + if pin.SetScale and pin.GetScale then + pin._easyfindOriginalScale = pin._easyfindOriginalScale or (pin:GetScale() or 1) + pcall(pin.SetScale, pin, pin._easyfindOriginalScale * nativePinScale) + highlightedNativePin = pin + end local width, height = pin:GetSize() local minPinSize = ns.UIToCanvas(36) * userScale @@ -7131,6 +7255,10 @@ end function MapSearch:ClearHighlight() if not highlightFrame then return end + + -- Restore the native pin's original scale before tearing down highlight visuals. + RestoreNativePinScale() + highlightFrame:Hide() highlightFrame.top:Show() highlightFrame.bottom:Show() @@ -7204,9 +7332,11 @@ function MapSearch:GetPreviewCoords(data) pCat = data.category pMapID = data.entranceMapID end - -- Coords on the current map: use directly + -- Coords on the current map: use directly. Forward the live pin + -- reference (when present) so previews can glow the native icon + -- in place of stamping an overlay. if px and py and (not pMapID or pMapID == currentMapID) then - return { x = px, y = py, icon = pIcon, category = pCat } + return { x = px, y = py, icon = pIcon, category = pCat, pin = data.pin } end -- Coords on a different map: check if entrance is visible here if data.isDungeonEntrance or data.category == "dungeon" @@ -7946,6 +8076,8 @@ function MapSearch:PreviewUIResult(data) self._previewing = true if coords.instances then self:ShowMultipleWaypoints(coords.instances) + elseif coords.pin and coords.pin:IsShown() then + self:HighlightPin(coords.pin, coords.x, coords.y, coords.icon, coords.category) else self:ShowWaypointAt(coords.x, coords.y, coords.icon, coords.category) end From f050c8a3003dac9273aa53471e27f48916da44d3 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 9 Apr 2026 04:05:55 -0400 Subject: [PATCH 005/103] Fix Reset All Settings missing UI search and loot fields --- Options.lua | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/Options.lua b/Options.lua index a6797e6..1885083 100644 --- a/Options.lua +++ b/Options.lua @@ -1686,7 +1686,7 @@ function Options:DoResetAll() EasyFind.db.mapSearchPositionMax = nil EasyFind.db.globalSearchPositionMax = nil EasyFind.db.mapSearchYOffset = 0 - EasyFind.db.directOpen = false + EasyFind.db.directOpen = true EasyFind.db.localMapDirectOpen = false EasyFind.db.globalMapDirectOpen = false EasyFind.db.smartShow = false @@ -1720,10 +1720,19 @@ function Options:DoResetAll() EasyFind.db.enableUISearch = true 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, loot = false, map = false } - EasyFind.db.lootFilter = nil -- nil = current spec + EasyFind.db.localSearchFilters = { instances = true, travel = true, services = true, rares = true } + EasyFind.db.uiSearchFilters = { ui = true, mounts = false, toys = false, pets = false, outfits = false, loot = false, appearanceSets = false, map = false } + EasyFind.db.lootSpecs = nil -- nil = current spec only + EasyFind.db.lootSearchSlots = true + EasyFind.db.lootSearchStats = true + EasyFind.db.lootUpgradesOnly = false EasyFind.db.lootDifficulty = "normal" + EasyFind.db.appearanceSetClass = nil -- nil = player class + EasyFind.db.appearanceSetCollected = true + EasyFind.db.appearanceSetNotCollected = true + EasyFind.db.appearanceSetPvE = true + EasyFind.db.appearanceSetPvP = true + EasyFind.db.nativePinScale = 1.5 EasyFind.db.pinnedUIItems = {} EasyFind.db.pinnedUIItemsPerChar = {} EasyFind.db.pinnedMapItems = {} @@ -1773,7 +1782,7 @@ function Options:DoResetAll() optionsFrame.opacitySlider:SetValue(DEFAULT_OPACITY) optionsFrame.uiFontSlider:SetValue(0.9) optionsFrame.mapFontSlider:SetValue(0.9) - optionsFrame.directOpenCheckbox:SetChecked(false) + optionsFrame.directOpenCheckbox:SetChecked(true) local sf = _G["EasyFindSearchFrame"] if sf and sf.modeBtn and ns.UpdateModeButtonVisual then ns.UpdateModeButtonVisual(sf.modeBtn) @@ -1837,7 +1846,7 @@ function Options:DoResetAll() end function Options:DoResetUI() - EasyFind.db.directOpen = false + EasyFind.db.directOpen = true EasyFind.db.smartShow = false EasyFind.db.staticOpacity = false EasyFind.db.uiResultsAbove = false @@ -1848,10 +1857,20 @@ 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, loot = false, map = false } + EasyFind.db.uiSearchFilters = { ui = true, mounts = false, toys = false, pets = false, outfits = false, loot = false, appearanceSets = false, map = false } + EasyFind.db.lootSpecs = nil + EasyFind.db.lootSearchSlots = true + EasyFind.db.lootSearchStats = true + EasyFind.db.lootUpgradesOnly = false + EasyFind.db.lootDifficulty = "normal" + EasyFind.db.appearanceSetClass = nil + EasyFind.db.appearanceSetCollected = true + EasyFind.db.appearanceSetNotCollected = true + EasyFind.db.appearanceSetPvE = true + EasyFind.db.appearanceSetPvP = true EasyFind.db.uiMapSearchLocal = true - optionsFrame.directOpenCheckbox:SetChecked(false) + optionsFrame.directOpenCheckbox:SetChecked(true) optionsFrame.smartShowCheckbox:SetChecked(false) optionsFrame.staticOpacityCheckbox:SetChecked(false) optionsFrame.uiResultsAboveCheckbox:SetChecked(false) From e8e457f87f411e70de4a15eb163e017e13f1e0fe Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 9 Apr 2026 04:06:00 -0400 Subject: [PATCH 006/103] Stop filter button click from stealing keyboard focus --- UI.lua | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/UI.lua b/UI.lua index aadc58c..015b93b 100644 --- a/UI.lua +++ b/UI.lua @@ -840,13 +840,20 @@ function UI:CreateSearchFrame() editBox:SetPoint("LEFT", modeBtn, "RIGHT", 0, 0) editBox:SetPoint("RIGHT", filterBtn, "LEFT", -4, 0) - -- Click anywhere on the search frame to focus the editbox (enables blinking cursor) + -- Click anywhere on the search frame to focus the editbox (enables blinking cursor). -- Use HookScript to preserve SmartShow OnLeave handlers; - -- skip focus if SmartShow is active and editbox is empty (prevents the bar getting stuck visible) + -- skip focus if SmartShow is active and editbox is empty (prevents the bar getting stuck visible). + -- Skip when the click landed on one of the toolbar buttons - they have their + -- own behavior, and stealing keyboard focus here would yank it away from any + -- other editable frame the player is currently using (chat, mail, /say, etc). searchFrame:HookScript("OnMouseDown", function(self, button) - if button == "LeftButton" and not IsShiftKeyDown() and not self.setupMode then - editBox:SetFocus() + if button ~= "LeftButton" or IsShiftKeyDown() or self.setupMode then return end + if (filterBtn and filterBtn:IsMouseOver()) + or (modeBtn and modeBtn:IsMouseOver()) + or (clearTextBtn and clearTextBtn:IsShown() and clearTextBtn:IsMouseOver()) then + return end + editBox:SetFocus() end) -- Show/hide the clear X based on whether there's text or an active guide From f8fe1b3c14561a0c5718c182932c833e95772295 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 9 Apr 2026 04:06:06 -0400 Subject: [PATCH 007/103] Refresh StaticLocations from harvest, drop duplicates --- StaticLocations.lua | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/StaticLocations.lua b/StaticLocations.lua index 4ae88cb..3438bce 100644 --- a/StaticLocations.lua +++ b/StaticLocations.lua @@ -1,5 +1,5 @@ --- EasyFind Static Locations (auto-generated 2026-03-17 03:17) -local _, ns = ... +-- EasyFind Static Locations (auto-generated 2026-04-09 03:37) +local ADDON_NAME, ns = ... ns.STATIC_LOCATIONS = { [18] = { -- Tirisfal Glades { name = "Portal to Stranglethorn Vale", category = "portal", x = 0.6142756296653066, y = 0.5879643697403164, keywords = {"portal", "teleport", "mage"} }, @@ -71,6 +71,8 @@ ns.STATIC_LOCATIONS = { { name = "Mailbox", category = "mailbox", x = 0.569003059844468, y = 0.7177869582605291, keywords = {"mail", "mailbox"} }, { name = "Mailbox", category = "mailbox", x = 0.4982730493422765, y = 0.8637194336242191, keywords = {"mail", "mailbox"} }, { name = "Mailbox", category = "mailbox", x = 0.7581761591013239, y = 0.6375443493587201, keywords = {"mail", "mailbox"} }, + { name = "Other Continents|Ironforge, Eastern Kingdoms", category = "portal", x = 0.6687707901000977, y = 0.3439830541610718, keywords = {"portal", "teleport", "mage"} }, + { name = "Other Continents|Azsuna, Broken Isles", category = "portal", x = 0.4872930347919464, y = 0.8809855580329895, keywords = {"portal", "teleport", "mage"} }, }, [85] = { -- Orgrimmar { name = "Legacy PvP Vendors", category = "pvpvendor", x = 0.3907181451218901, y = 0.7160247475023476, keywords = {"pvp", "honor", "conquest", "vendor"} }, @@ -321,7 +323,6 @@ ns.STATIC_LOCATIONS = { { name = "Innkeeper", category = "innkeeper", x = 0.80031418800354, y = 0.5956547260284424, keywords = {"inn", "innkeeper", "hearth", "rest", "tavern"} }, { name = "Warlock Trainer", category = "classtrainer_warlock", x = 0.7610905766487122, y = 0.4362403452396393, keywords = {"trainer", "class", "warlock"} }, { name = "Priest Trainer", category = "classtrainer_priest", x = 0.5428659319877625, y = 0.2686304748058319, keywords = {"trainer", "class", "priest"} }, - { name = "Innkeeper", category = "innkeeper", x = 0.80031418800354, y = 0.5956547260284424, keywords = {"inn", "innkeeper", "hearth", "rest", "tavern"} }, { name = "Stable Master", category = "stablemaster", x = 0.8282662630081177, y = 0.3097585141658783, keywords = {"stable", "pet"} }, { name = "Mailbox", category = "mailbox", x = 0.879634439945221, y = 0.5083430409431458, keywords = {"mail", "mailbox"} }, }, @@ -356,6 +357,9 @@ ns.STATIC_LOCATIONS = { { name = "Guild Services", category = "guildservices", x = 0.5892795920372009, y = 0.4632092118263245, keywords = {"guild", "tabard", "registrar"} }, { name = "Innkeeper", category = "innkeeper", x = 0.7443743944168091, y = 0.3264971375465393, keywords = {"inn", "innkeeper", "hearth", "rest", "tavern"} }, }, + [1164] = { -- Dazar'alor + { name = "Decor Specialist", category = "decor", x = 0.3680292523030898, y = 0.6273999392615361, keywords = {"decor", "decoration", "housing", "furniture"} }, + }, [1670] = { -- Oribos { name = "Appearance Agitator", category = "barber", x = 0.6418470740318298, y = 0.6455227732658386, keywords = {"barber", "appearance", "haircut"} }, { name = "Bank", category = "bank", x = 0.6057606935501099, y = 0.2979814112186432, keywords = {"bank", "vault", "storage"} }, @@ -483,7 +487,6 @@ ns.STATIC_LOCATIONS = { { name = "Auction House", category = "auctionhouse", x = 0.5084021687507629, y = 0.7571848034858704, keywords = {"auction", "ah"} }, { name = "Decor Specialist", category = "decor", x = 0.5596986357345336, y = 0.6570628685898381, keywords = {"decor", "decoration", "housing", "furniture"} }, { name = "Crafting Orders", category = "craftingorders", x = 0.4515396058559418, y = 0.5560497045516968, keywords = {"crafting", "orders", "work order"} }, - { name = "Inn", category = "innkeeper", x = 0.5628485083580017, y = 0.7034760117530823, keywords = {"inn", "innkeeper", "hearth", "rest", "tavern"} }, { name = "Sanctum of Light", category = "landmark", x = 0.4544047117233276, y = 0.7033688426017761, keywords = {"sanctum", "light"} }, { name = "Rostrum of Transformation", category = "rostrum", x = 0.5219147801399231, y = 0.7374712824821472, keywords = {"rostrum", "transformation", "dragonriding"} }, { name = "Mailbox", category = "mailbox", x = 0.4985528588294983, y = 0.7411491870880127, keywords = {"mail", "mailbox"} }, @@ -494,6 +497,12 @@ ns.STATIC_LOCATIONS = { { name = "Mailbox", category = "mailbox", x = 0.695517930156462, y = 0.8206761373145117, keywords = {"mail", "mailbox"} }, { name = "Mailbox", category = "mailbox", x = 0.5750499589877152, y = 0.6998717990457972, keywords = {"mail", "mailbox"} }, { name = "Mailbox", category = "mailbox", x = 0.6702745588052893, y = 0.7012820215558285, keywords = {"mail", "mailbox"} }, + { name = "Arcantina Portal", category = "portal", x = 0.562795365381976, y = 0.7056124956620882, keywords = {"portal", "teleport", "mage"} }, + }, + [2437] = { -- Zul'Aman + { name = "Decor Specialist", category = "decor", x = 0.461404994483628, y = 0.6649003781285632, keywords = {"decor", "decoration", "housing", "furniture"} }, + { name = "Innkeeper", category = "innkeeper", x = 0.4540807306251356, y = 0.6545635298785561, keywords = {"inn", "innkeeper", "hearth", "rest", "tavern"} }, + { name = "Mailbox", category = "mailbox", x = 0.4497563021021528, y = 0.6597604414722286, keywords = {"mail", "mailbox"} }, }, } From e3f6f1176c85fe3d55b4217a771ed994243a8d9e Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 12 Apr 2026 05:03:11 -0400 Subject: [PATCH 008/103] Add lorewalker POI category and fix catalyst icon --- .luacheckrc | 2 +- MapSearch.lua | 213 +++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 193 insertions(+), 22 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 200b813..4cff09e 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -36,7 +36,7 @@ read_globals = { "GetAddOnMetadata", "GetAtlasInfo", "GetMinimapShape", "GetBindingKey", "GetCurrentBindingSet", "SaveBindings", "SetBinding", "GetCategoryInfo", "GetPlayerFacing", - "SetPortraitTexture", "ShowUIPanel", "ToggleWorldMap", "ToggleDropDownMenu", + "SetPortraitTexture", "ShowUIPanel", "HideUIPanel", "ToggleWorldMap", "ToggleDropDownMenu", "InterfaceOptions_AddCategory", "InCombatLockdown", "IsShiftKeyDown", "IsMouseButtonDown", "IsAltKeyDown", "IsControlKeyDown", "IsInGroup", "IsInInstance", "IsIndoors", "UnitIsGroupLeader", diff --git a/MapSearch.lua b/MapSearch.lua index abc448d..cc88dfe 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -1183,7 +1183,7 @@ local CATEGORY_ICONS = { transmogrifier = 1598183, rare = { file = 1121272, coords = { 0.7796, 0.8381, 0.1934, 0.2531 } }, treasure = "Interface\\Icons\\INV_Misc_Bag_10", - catalyst = "Interface\\Icons\\INV_10_GearUpgrade_Catalyst_Charged", + catalyst = { file = 1121272, coords = { 0.5097, 0.5390, 0.4078, 0.4363 } }, greatvault = "Interface\\Icons\\INV_Misc_Lockbox_1", upgradevendor = 4025144, guildservices = "Interface\\Icons\\Achievement_GuildPerk_EverybodysFriend", @@ -1191,6 +1191,7 @@ local CATEGORY_ICONS = { tradingpost = "Interface\\Icons\\tradingpostcurrency", decor = { file = 1121272, coords = { 0.4078, 0.4380, 0.8713, 0.9040 } }, chromie = "atlas:ChromieTime-32x32", + lorewalker = { file = 1121272, coords = { 0.2027, 0.2404, 0.5966, 0.6261 } }, craftingorders = { file = 1121272, coords = { 0.8764, 0.9040, 0.5102, 0.5357 } }, rostrum = { file = 1121272, coords = { 0.7738, 0.8033, 0.4066, 0.4394 } }, pettrainer = "atlas:WildBattlePetCapturable", @@ -1260,6 +1261,7 @@ local CATEGORIES = { upgradevendor = { keywords = {"upgrade", "upgrade vendor", "flightstone", "crest"}, parent = "service" }, tradingpost = { keywords = {"trading post", "trader's tender", "tender", "tmog", "xmog"}, parent = "service" }, decor = { keywords = {"decor", "decoration", "decorations", "decorator", "housing", "furniture"}, parent = "service" }, + lorewalker = { keywords = {"lorewalker", "cho", "lore walker", "pandaria lore", "flashback", "replay cinematic"}, parent = "service" }, } -- Categories allowed in global (cross-zone) search results. @@ -1581,9 +1583,9 @@ function MapSearch:CreateSearchFrame() GameTooltip:SetOwner(self, "ANCHOR_TOP") if EasyFind.db.localMapDirectOpen then GameTooltip:SetText("Fast Mode") - GameTooltip:AddLine("Click to switch to Standard mode.", 1, 1, 1, true) + GameTooltip:AddLine("Click to switch to Guide mode.", 1, 1, 1, true) else - GameTooltip:SetText("Standard Mode") + GameTooltip:SetText("Guide Mode") GameTooltip:AddLine("Click to switch to Fast mode.", 1, 1, 1, true) end GameTooltip:AddLine("Hold |cFF00FF00Shift|r and drag to reposition.", 0.7, 0.7, 0.7) @@ -1755,7 +1757,7 @@ function MapSearch:CreateSearchFrame() GameTooltip:SetText("|cFFFFD100Zone Search|r (Fast)") GameTooltip:AddLine("Navigates directly to results without zone highlighting.", 1, 1, 1, true) else - GameTooltip:SetText("|cFFFFD100Zone Search|r (Standard)") + GameTooltip:SetText("|cFFFFD100Zone Search|r (Guide)") GameTooltip:AddLine("Highlights zones on the map and guides navigation step by step.", 1, 1, 1, true) end GameTooltip:AddLine("Hold |cFF00FF00Shift|r and drag to reposition.", 0.7, 0.7, 0.7) @@ -2016,9 +2018,9 @@ function MapSearch:CreateSearchFrame() GameTooltip:SetOwner(self, "ANCHOR_TOP") if EasyFind.db.globalMapDirectOpen then GameTooltip:SetText("Fast Mode") - GameTooltip:AddLine("Click to switch to Standard mode.", 1, 1, 1, true) + GameTooltip:AddLine("Click to switch to Guide mode.", 1, 1, 1, true) else - GameTooltip:SetText("Standard Mode") + GameTooltip:SetText("Guide Mode") GameTooltip:AddLine("Click to switch to Fast mode.", 1, 1, 1, true) end GameTooltip:AddLine("Hold |cFF00FF00Shift|r and drag to reposition.", 0.7, 0.7, 0.7) @@ -2190,7 +2192,7 @@ function MapSearch:CreateSearchFrame() GameTooltip:SetText("|cFF66CCFFGlobal Search|r (Fast)") GameTooltip:AddLine("Navigates directly to clicked zones and dungeon entrances.", 1, 1, 1, true) else - GameTooltip:SetText("|cFF66CCFFGlobal Search|r (Standard)") + GameTooltip:SetText("|cFF66CCFFGlobal Search|r (Guide)") GameTooltip:AddLine("Highlights zones on the map and guides navigation step by step.", 1, 1, 1, true) end GameTooltip:AddLine("Hold |cFF00FF00Shift|r and drag to reposition.", 0.7, 0.7, 0.7) @@ -2993,6 +2995,10 @@ function MapSearch:CreateHighlightFrame() C_SuperTrack.SetSuperTrackedUserWaypoint(true) efPlacedWaypoint = true ShowSuperTrackGlow() + local sf = _G["EasyFindSearchFrame"] + if sf and sf.UpdateClearButtonVisibility then + sf.UpdateClearButtonVisibility() + end end end if button == "RightButton" then @@ -5340,8 +5346,8 @@ end -- Scan dungeon entrances across ALL zone-type maps for global search. -- Results are cached since instance discovery doesn't change mid-session. -function MapSearch:GetStaticLocations() - local mapID = WorldMapFrame:GetMapID() +function MapSearch:GetStaticLocations(mapID) + mapID = mapID or WorldMapFrame:GetMapID() if not mapID then return {} end local results = {} @@ -5389,11 +5395,11 @@ function MapSearch:GetStaticLocations() return results end -function MapSearch:ScanVignettes() +function MapSearch:ScanVignettes(mapID) local rares = {} if not GetVignettes then return rares end - local mapID = WorldMapFrame:GetMapID() + mapID = mapID or WorldMapFrame:GetMapID() if not mapID then return rares end local playerMapID = GetBestMapForUnit("player") @@ -5506,9 +5512,9 @@ function MapSearch:UpdateRareTracking() end end -function MapSearch:ScanMapPOIs() +function MapSearch:ScanMapPOIs(mapID) local pois = {} - local mapID = WorldMapFrame:GetMapID() + mapID = mapID or WorldMapFrame:GetMapID() if not mapID then return pois end local canvas = WorldMapFrame.ScrollContainer and WorldMapFrame.ScrollContainer.Child @@ -6709,6 +6715,54 @@ function MapSearch:FocusLocalSearch() searchFrame.editBox:SetFocus() end +-- Run a local search programmatically, bypassing the OnTextChanged +-- HasFocus gate. Sets the module-level state (isGlobalSearch, +-- activeSearchFrame) and invokes the real search directly. Used by the +-- demo's setupAfter functions to restore the "typed + results showing" +-- state instantly when the user jumps to a later step. +function MapSearch:RunLocalSearch(text) + if not searchFrame or not searchFrame.editBox then return end + -- No SetFocus: the real editbox must NOT hold keyboard focus or + -- the real user could type alongside the demo. We drive the search + -- directly by setting state and calling OnSearchTextChanged, which + -- skips the HasFocus() gate inside the editbox's OnTextChanged. + searchFrame.editBox:ClearFocus() + searchFrame.editBox:SetText(text or "") + if searchFrame.editBox.placeholder then + if text and text ~= "" then + searchFrame.editBox.placeholder:Hide() + else + searchFrame.editBox.placeholder:Show() + end + end + isGlobalSearch = false + activeSearchFrame = searchFrame + -- SelectResult sets _suppressTextChanged = true and relies on the + -- editbox's OnTextChanged to consume it. When we bypass focus, the + -- HasFocus() gate can leave the flag stuck, which would cause the + -- next OnSearchTextChanged to bail out early. Clear it here so the + -- search always runs. + self._suppressTextChanged = nil + self:OnSearchTextChanged(text or "") +end + +function MapSearch:RunGlobalSearch(text) + if not globalSearchFrame or not globalSearchFrame.editBox then return end + globalSearchFrame.editBox:ClearFocus() + globalSearchFrame.editBox:SetText(text or "") + if globalSearchFrame.editBox.placeholder then + if text and text ~= "" then + globalSearchFrame.editBox.placeholder:Hide() + else + globalSearchFrame.editBox.placeholder:Show() + end + end + isGlobalSearch = true + activeSearchFrame = globalSearchFrame + self._suppressTextChanged = nil + self:OnSearchTextChanged(text or "") +end + function MapSearch:FocusGlobalSearch() if not globalSearchFrame or not globalSearchFrame.editBox then return end if EasyFind.db.mapSmartShow then SmartShowFadeIn(globalSearchFrame) end @@ -6845,6 +6899,39 @@ function MapSearch:SelectResult(data) -- Pin reference but currently hidden and no coords: clear. self:HighlightPin(data.pin) end + + -- Fast Mode (local search only): auto-place a SuperTrack waypoint + -- so the minimap glow / guide-circle appears immediately without + -- needing a second click on the pin. Matches HandleUISearchClick's + -- local Fast Mode behavior. + -- Local search POIs generally don't carry their own mapID (the + -- mapID is implicit = currently-viewed map, since the local + -- search is scoped to that map), so we fall back to the viewed + -- map when data.mapID isn't present. + if not isGlobalSearch and directMode then + local autoX, autoY, autoMapID + if data.x and data.y then + autoX, autoY = data.x, data.y + autoMapID = data.mapID + or (data.allInstances and data.allInstances[1] and data.allInstances[1].mapID) + or (WorldMapFrame and WorldMapFrame:GetMapID()) + elseif data.allInstances and #data.allInstances == 1 then + local single = data.allInstances[1] + autoX, autoY = single.x, single.y + autoMapID = single.mapID or (WorldMapFrame and WorldMapFrame:GetMapID()) + end + if autoX and autoY and autoMapID + and autoX >= 0 and autoX <= 1 and autoY >= 0 and autoY <= 1 then + SetUserWaypoint(UiMapPoint.CreateFromCoordinates(autoMapID, autoX, autoY)) + C_SuperTrack.SetSuperTrackedUserWaypoint(true) + efPlacedWaypoint = true + ShowSuperTrackGlow() + local sf = _G["EasyFindSearchFrame"] + if sf and sf.UpdateClearButtonVisibility then + sf.UpdateClearButtonVisibility() + end + end + end end -- Clear search text after pins are placed. Flag persists until @@ -7171,6 +7258,13 @@ function MapSearch:ShowWaypointAt(x, y, icon, category) self.autoTrackNextPin = nil self:TrackActivePin() end + + -- Refresh the UI search bar's clear button so it appears while a + -- pin is visible (active map navigation). + local sf = _G["EasyFindSearchFrame"] + if sf and sf.UpdateClearButtonVisibility then + sf.UpdateClearButtonVisibility() + end end -- Tracks the canvas pin currently scaled up by HighlightPin so ClearHighlight @@ -7269,6 +7363,13 @@ function MapSearch:ClearHighlight() indicatorFrame:SetPoint("BOTTOM", highlightFrame, "TOP", 0, 2) indicatorFrame:Hide() waypointPin:Hide() + + -- Refresh the UI search bar's clear button (pin is gone, but map + -- navigation may still be active via efPlacedWaypoint). + local sf = _G["EasyFindSearchFrame"] + if sf and sf.UpdateClearButtonVisibility then + sf.UpdateClearButtonVisibility() + end waypointPin.waypointX = nil waypointPin.waypointY = nil waypointPin.isLocalSearch = nil @@ -7351,6 +7452,61 @@ end -- Full clear: map visuals + minimap waypoint tracking -- Called by explicit dismiss actions (right-click pin, /ef clear, clear button) +-- Returns true if EasyFind currently has any active map navigation: +-- an active pin, a SuperTrack waypoint we placed, a zone highlight, +-- or a pending waypoint/zone. Used by the UI search bar's clear button +-- to stay visible while navigation is in progress. +function MapSearch:HasActiveNavigation() + if efPlacedWaypoint then return true end + if activePinState then return true end + if self.pendingWaypoint then return true end + if self.pendingZoneHighlight then return true end + if waypointPin and waypointPin:IsShown() then return true end + return false +end + +-- Return the currently-highlighted pin's coordinates and mapID, whether +-- it was shown via ShowWaypointAt (waypointPin) or HighlightPin (native +-- Blizzard pin). Used by the demo's "click to start tracking" step to +-- resolve coordinates regardless of which rendering path was taken. +function MapSearch:GetActivePinCoords() + if activePinState and activePinState.x and activePinState.y and activePinState.mapID then + return activePinState.x, activePinState.y, activePinState.mapID + end + if waypointPin and waypointPin:IsShown() and waypointPin.waypointX and waypointPin.waypointY then + local mapID = WorldMapFrame and WorldMapFrame:GetMapID() + return waypointPin.waypointX, waypointPin.waypointY, mapID + end + return nil, nil, nil +end + +-- Place a SuperTrack user waypoint at the currently highlighted pin's +-- location. Used by the "click the pin to start tracking" step in the +-- Guide Mode demo (since Guide Mode doesn't auto-track on result click). +function MapSearch:TrackActiveLocation() + local x, y, mapID = self:GetActivePinCoords() + if not (x and y and mapID) then return false end + SetUserWaypoint(UiMapPoint.CreateFromCoordinates(mapID, x, y)) + C_SuperTrack.SetSuperTrackedUserWaypoint(true) + efPlacedWaypoint = true + ShowSuperTrackGlow() + local sf = _G["EasyFindSearchFrame"] + if sf and sf.UpdateClearButtonVisibility then + sf.UpdateClearButtonVisibility() + end + return true +end + +-- Return the on-screen frame currently showing the pin highlight. This +-- is either waypointPin (overlay path) or highlightFrame (which is +-- anchored over the native pin in the HighlightPin path). Used by the +-- demo cursor to know where to move for a visual "click" on the pin. +function MapSearch:GetActivePinFrame() + if waypointPin and waypointPin:IsShown() then return waypointPin end + if highlightFrame and highlightFrame:IsShown() then return highlightFrame end + return nil +end + function MapSearch:ClearAll() activePinState = nil self:ClearHighlight() @@ -7363,6 +7519,11 @@ function MapSearch:ClearAll() ClearUserWaypoint() end end + -- Notify the UI search bar to refresh its clear-button state. + local sf = _G["EasyFindSearchFrame"] + if sf and sf.UpdateClearButtonVisibility then + sf.UpdateClearButtonVisibility() + end end function MapSearch:GetNearestInstance(instances, mapID) @@ -7801,20 +7962,26 @@ function MapSearch:SearchForUI(query) if not query or query == "" or #query < 2 then return nil end local isLocal = EasyFind.db.uiMapSearchLocal ~= false - local searchMapID = WorldMapFrame and WorldMapFrame:GetMapID() - or GetBestMapForUnit("player") + -- For UI-bar local search, "local" means the PLAYER's current zone, + -- not whatever the WorldMapFrame is currently viewing. The player + -- might have last opened the map to a different zone, but a local + -- search from the UI bar should always reflect what's around them. + local searchMapID = isLocal + and (GetBestMapForUnit("player") or (WorldMapFrame and WorldMapFrame:GetMapID())) + or (WorldMapFrame and WorldMapFrame:GetMapID()) or GetBestMapForUnit("player") -- Gather POIs using the same sources as OnSearchTextChanged local pois = {} local existingNames = {} if isLocal then - -- Local: same sources as the real local search - local dynamicPOIs = self:ScanMapPOIs() - local staticLocations = self:GetStaticLocations() - local dungeonEntrances = self:ScanDungeonEntrances() - local flightMasters = self:ScanFlightMasters() - local vignetteRares = self:ScanVignettes() + -- Local: same sources as the real local search, scoped to the + -- player's current zone (searchMapID computed above). + local dynamicPOIs = self:ScanMapPOIs(searchMapID) + local staticLocations = self:GetStaticLocations(searchMapID) + local dungeonEntrances = self:ScanDungeonEntrances(searchMapID) + local flightMasters = self:ScanFlightMasters(searchMapID) + local vignetteRares = self:ScanVignettes(searchMapID) for _, entrance in ipairs(dungeonEntrances) do pois[#pois + 1] = entrance @@ -8050,6 +8217,10 @@ function MapSearch:HandleUISearchClick(data) icon = data.icon, category = data.category, isLocal = true, } + local sf = _G["EasyFindSearchFrame"] + if sf and sf.UpdateClearButtonVisibility then + sf.UpdateClearButtonVisibility() + end end end else From cf8f0226a3a6eeab2d8ea44825719226462e9fe1 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 12 Apr 2026 05:03:20 -0400 Subject: [PATCH 009/103] Fix appearance set navigation in Sets tab Filter search to base sets only (variants aren't navigable in the left list). Fix Sets tab scroll using SetScrollPercentage with data provider index instead of ScrollToElementData which silently fails for this ScrollBox type. --- Database.lua | 9 ++++- Highlight.lua | 104 +++++++++++++++++++++++++++++++++++--------------- 2 files changed, 82 insertions(+), 31 deletions(-) diff --git a/Database.lua b/Database.lua index 5937aab..d296908 100644 --- a/Database.lua +++ b/Database.lua @@ -986,7 +986,14 @@ function Database:PopulateDynamicTransmogSets() for i = 1, #allSets do local setInfo = allSets[i] - if setInfo.name and setInfo.name ~= "" and not setInfo.hiddenUntilCollected then + -- Only include base sets; variants share the same visuals and + -- aren't directly navigable in the Sets tab left list + local isBaseSet = true + if C_TransmogSets.GetBaseSetID then + local bid = C_TransmogSets.GetBaseSetID(setInfo.setID) + isBaseSet = not bid or bid == setInfo.setID + end + if isBaseSet and setInfo.name and setInfo.name ~= "" and not setInfo.hiddenUntilCollected then -- Class filter: use classMask for specific class, or accept all local cm = setInfo.classMask or 0 local classOk = not wantMask or cm == 0 or cm < 0 or band(cm, wantMask) ~= 0 diff --git a/Highlight.lua b/Highlight.lua index b29b968..ca65ebc 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -1253,36 +1253,75 @@ function Highlight:UpdateGuide() end -- Transmog set: scroll to and highlight in the Sets ScrollBox + -- Transmog set: scroll to it in the left list, highlight the row. if step.transmogSetID then local wcf = _G["WardrobeCollectionFrame"] - local setsFrame = wcf and wcf.SetsCollectionFrame - local scrollBox = setsFrame and setsFrame.ListContainer - and setsFrame.ListContainer.ScrollBox - if not scrollBox then return end - - local targetName = step.transmogSetName and slower(step.transmogSetName) - ScrollBoxScrollTo(scrollBox, function(elementData) - if not elementData then return false end - return elementData.setID and elementData.setID == step.transmogSetID - end) - local setBtn = ScrollBoxFindButton(scrollBox, function(btn) - local edata = btn.GetElementData and btn:GetElementData() - if edata and edata.setID == step.transmogSetID then return true end - if targetName then - local text = GetButtonText(btn) - if text and slower(text) == targetName then return true end + local scf = wcf and wcf.SetsCollectionFrame + if not scf then return end + + local lc = scf.ListContainer + local scrollBox = lc and lc.ScrollBox + + -- ScrollToElementData silently fails for this ScrollBox. + -- Find the element's index in the data provider and use + -- SetScrollPercentage to scroll to the calculated position. + if scrollBox and scrollBox.SetScrollPercentage then + local dp = scrollBox.GetDataProvider and scrollBox:GetDataProvider() + if dp then + local matchFn = function(ed) + return ed and ed.setID == step.transmogSetID + end + local finder = dp.FindElementDataByPredicate or dp.FindByPredicate + local found = finder and finder(dp, matchFn) + if found then + local idx = dp.FindIndex and dp:FindIndex(found) + local total = dp.GetSize and dp:GetSize() + if idx and total and total > 1 then + scrollBox:SetScrollPercentage((idx - 1) / (total - 1)) + end + end end - return false - end) - if setBtn then - self:HighlightFrame(setBtn) - if canHoverDismiss() and setBtn:IsMouseOver() then - self:Cancel() + end + + -- Highlight the row + if scrollBox then + local setBtn = ScrollBoxFindButton(scrollBox, function(btn) + local edata = btn.GetElementData and btn:GetElementData() + return edata and edata.setID == step.transmogSetID + end) + if setBtn then + self:HighlightFrame(setBtn) + if canHoverDismiss() and setBtn:IsMouseOver() then + self:Cancel() + end end end return end + -- Transmog variant dropdown: highlight it for the user to click + if step.transmogVariantDropdown then + local wcf = _G["WardrobeCollectionFrame"] + local scf = wcf and wcf.SetsCollectionFrame + local dd = scf and scf.DetailsFrame + and scf.DetailsFrame.VariantSetsDropdown + if dd and dd:IsShown() then + self:HighlightFrame(dd) + end + return + end + + -- Transmog variant: use SelectSet to switch to the variant + if step.transmogVariantSetID then + local wcf = _G["WardrobeCollectionFrame"] + local scf = wcf and wcf.SetsCollectionFrame + if scf and scf.SelectSet then + pcall(scf.SelectSet, scf, step.transmogVariantSetID) + end + self:AdvanceStep() + return + end + -- Text-only final step (when we've navigated but can't highlight specific element) if step.text and not step.regionFrames and not step.regionFrame and not step.searchButtonText then self:ShowInstruction(step.text) @@ -2064,26 +2103,31 @@ function Highlight:GetTabButton(frameName, tabIndex) -- WardrobeCollectionFrame tabs (Items / Sets) if frameName == "WardrobeCollectionFrame" then - -- Try named globals first local tabBtn = _G["WardrobeCollectionFrameTab" .. tabIndex] if tabBtn then return tabBtn end local frame = _G["WardrobeCollectionFrame"] if frame then - -- Try .Tabs array (modern WoW tab system) if frame.Tabs and frame.Tabs[tabIndex] then return frame.Tabs[tabIndex] end - -- Try named properties + if frame.TabSystem and frame.TabSystem.tabs then + local tab = frame.TabSystem.tabs[tabIndex] + if tab then return tab end + end if tabIndex == 2 and frame.SetsTab then return frame.SetsTab end if tabIndex == 1 and frame.ItemsTab then return frame.ItemsTab end - -- Search children by name or debugName + -- Scan button-type children only; content frames like + -- SetsCollectionFrame also match "Sets" but aren't tabs local tabKeywords = { {"Items", "Item", "Tab1"}, {"Sets", "Set", "Tab2"} } local keywords = tabKeywords[tabIndex] if keywords then for i = 1, select("#", frame:GetChildren()) do local child = select(i, frame:GetChildren()) - local name = child:GetName() or "" - local debugName = child.GetDebugName and child:GetDebugName() or "" - for _, kw in ipairs(keywords) do - if sfind(name, kw) or sfind(debugName, kw) then return child end + local objType = child.GetObjectType and child:GetObjectType() + if objType == "Button" or objType == "CheckButton" then + local cname = child:GetName() or "" + local debugName = child.GetDebugName and child:GetDebugName() or "" + for _, kw in ipairs(keywords) do + if sfind(cname, kw) or sfind(debugName, kw) then return child end + end end end end From fe134f6cea893300a487502b40dd31f47a041480 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 12 Apr 2026 05:03:27 -0400 Subject: [PATCH 010/103] Add interactive demo system with mode toggle support --- Core.lua | 2 +- Demo.lua | 4358 +++++++++++++++++++++++++++++++++++++++++++++++ EasyFind.toc | 1 + Options.lua | 2 +- UI.lua | 588 +++++-- demo-cog.tga | Bin 0 -> 16402 bytes demo-pause.tga | Bin 0 -> 16402 bytes demo-replay.tga | Bin 0 -> 65554 bytes 8 files changed, 4768 insertions(+), 183 deletions(-) create mode 100644 Demo.lua create mode 100644 demo-cog.tga create mode 100644 demo-pause.tga create mode 100644 demo-replay.tga diff --git a/Core.lua b/Core.lua index 752a79e..ab99a47 100644 --- a/Core.lua +++ b/Core.lua @@ -52,7 +52,7 @@ local DB_DEFAULTS = { globalSearchPositionMax = nil, -- x offset from map right edge (maximized) mapSearchYOffset = 0, -- y offset for search bars relative to map bottom hideSearchBarsMaximized = false, -- Hide search bars when map is full screen - directOpen = true, -- Open panels directly (fast mode) instead of step-by-step (standard mode) + directOpen = true, -- Open panels directly (Fast Mode) instead of step-by-step (Guide Mode) localMapDirectOpen = false, -- Zone bar: navigate directly (no zone highlighting) globalMapDirectOpen = false, -- Global bar: navigate directly (no zone highlighting) smartShow = false, -- Hide search bar until mouse hovers nearby diff --git a/Demo.lua b/Demo.lua new file mode 100644 index 0000000..51da74c --- /dev/null +++ b/Demo.lua @@ -0,0 +1,4358 @@ +local _, ns = ... + +local Demo = {} +ns.Demo = Demo + +local Utils = ns.Utils +local sfind, slower = Utils.sfind, Utils.slower +local select, ipairs, pairs = Utils.select, Utils.ipairs, Utils.pairs +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 +local TOOLTIP_BORDER = ns.TOOLTIP_BORDER +local DEFAULT_OPACITY = ns.DEFAULT_OPACITY +local DARK_PANEL_BG = ns.DARK_PANEL_BG +local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind\\textures\\lightning-bolt" + +local CreateFrame = CreateFrame +local C_Timer = C_Timer +local UIParent = UIParent +local GameTooltip = GameTooltip +local GameTooltip_Hide = GameTooltip_Hide +local IsShiftKeyDown = IsShiftKeyDown +local GetCursorPosition = GetCursorPosition +local InCombatLockdown = InCombatLockdown +local HideUIPanel = HideUIPanel +local wipe = wipe + +-- Find the "Spellbook" tab entry in the search database. The demo +-- bypasses fuzzy search ranking by passing this entry straight to +-- SelectResult, so we know exactly what gets opened regardless of +-- what ranks first for "sp". +local function FindSpellbookEntry() + if not (ns.Database and ns.Database.uiSearchData) then return nil end + for _, entry in ipairs(ns.Database.uiSearchData) do + if entry.name == "Spellbook" and entry.category == "Talents" then + return entry + end + end + return nil +end + +-- Run the EasyFind setup demo. ctx fields: +-- searchFrame, resultsFrame, resultButtons - main UI references +-- finishSetup - callback that closes the setup tutorial UI +function Demo.Start(ctx) + local searchFrame = ctx.searchFrame + local resultsFrame = ctx.resultsFrame + local resultButtons = ctx.resultButtons + local FinishSetup = ctx.finishSetup + local UI = ns.UI + + -- Combat-safe abort: opening Collections and animating around the + -- screen can taint or fail under combat lockdown. + if InCombatLockdown() then + FinishSetup() + return + end + + local spellbookEntry = FindSpellbookEntry() + if not spellbookEntry then + FinishSetup() + return + end + + -- Lock the search bar dragging during the demo + searchFrame:SetScript("OnDragStart", nil) + + -- Force-load Blizzard_PlayerSpells so PlayerSpellsFrame and its + -- tabs exist as targets for the mock cursor and Highlight system. + if C_AddOns and C_AddOns.LoadAddOn then + pcall(C_AddOns.LoadAddOn, "Blizzard_PlayerSpells") + end + + -- Step list: ONLY real (executable) steps. Section headers are + -- display-only entries in DEMO_SECTIONS below and aren't navigable + -- stops in the state machine - the player shouldn't have to click + -- Per-demo Fast/Guide flag for demos that opt in via + -- supportsModeToggle. Defaults to fast (true). Independent of + -- the user's saved EasyFind.db.directOpen / *MapDirectOpen + -- settings, which the demo restores after each run. + local demoModeFast = {} + + -- Next to get past a header that does nothing. + -- Demo registry. Each entry has: + -- title - shown at the top of the demo panel + -- sections - list of { header, section, firstStep, lastStep } + -- stepDefs - list of { text, section } describing each step row + -- run - list of function(done) that animates each step + -- setupAfter - list of function() that snaps the game state to + -- the end of step i without animation (used by + -- Prev/Next/jumpToStep) + -- supportsModeToggle - true to show the Fast/Guide toggle button + -- on the demo panel; the demo's `rebuild` reads + -- demoModeFast[key] to pick which step set to build + -- run and setupAfter for the default UI Search demo are populated + -- further down, after the helper functions they reference exist. + -- Other demos start empty and get filled in as we build them. + local DEMOS = { + uiSearch = { + title = "UI Search", + sections = { + { header = "|TInterface\\AddOns\\EasyFind\\textures\\lightning-bolt:14:14|t Fast Mode (for convenience)", section = 1, firstStep = 1, lastStep = 3 }, + { header = "|A:common-search-magnifyingglass:14:14|a Guide Mode (for learning)", section = 2, firstStep = 4, lastStep = 8 }, + }, + stepDefs = { + { text = "Make sure Fast Mode is enabled", section = 1 }, -- 1 + { text = 'Start typing "Spellbook"', section = 1 }, -- 2 + { text = "Click the Spellbook result", section = 1 }, -- 3 + { text = "Switch to Guide Mode", section = 2 }, -- 4 + { text = 'Start typing "Spellbook"', section = 2 }, -- 5 + { text = "Click the Spellbook result", section = 2 }, -- 6 + { text = "Click Talents & Spellbook button", section = 2 }, -- 7 + { text = "Click the Spellbook tab", section = 2 }, -- 8 + }, + lockFrames = { "PlayerSpellsFrame" }, + run = {}, + setupAfter = {}, + }, + mapSearchZone = { + title = "Zone/Instance Map Search", + -- sections / stepDefs / run / setupAfter are populated by + -- the `rebuild` function based on demoModeFast["mapSearchZone"]. + sections = {}, + stepDefs = {}, + lockFrames = { "WorldMapFrame" }, + run = {}, + setupAfter = {}, + supportsModeToggle = true, + }, + mapSearchCurrent = { + title = "Current Zone Map Search", + -- sections / stepDefs / run / setupAfter are populated by + -- the `rebuild` function based on demoModeFast["mapSearchCurrent"]. + sections = {}, + stepDefs = {}, + lockFrames = { "WorldMapFrame" }, + run = {}, + setupAfter = {}, + supportsModeToggle = true, + }, + mapSearchUI = { + title = "Map search through UI bar", + sections = { + { header = "Local Map POI search: Flight Master", section = 1, firstStep = 1, lastStep = 6 }, + { header = "Global zone search: Eastern Plaguelands", section = 2, firstStep = 7, lastStep = 10 }, + }, + stepDefs = { + { text = "Make sure Fast Mode is enabled", section = 1 }, -- 1 + { text = "Open the filter menu", section = 1 }, -- 2 + { text = "Enable Map Search filter", section = 1 }, -- 3 + { text = 'Confirm "Local" is selected', section = 1 }, -- 4 + { text = 'Start typing "Flight Master"', section = 1 }, -- 5 + { text = "Click the Flight Master result", section = 1 }, -- 6 + { text = "Open the filter menu", section = 2 }, -- 7 + { text = 'Switch to "Global"', section = 2 }, -- 8 + { text = 'Start typing "Eastern Plaguelands"', section = 2 }, -- 9 + { text = "Click the Eastern Plaguelands result", section = 2 }, -- 10 + }, + lockFrames = { "WorldMapFrame" }, + run = {}, + setupAfter = {}, + supportsModeToggle = true, + }, + outfits = { title = "Outfits", sections = {}, stepDefs = {}, lockFrames = {}, run = {}, setupAfter = {} }, + appearanceSets = { title = "Appearance Sets", sections = {}, stepDefs = {}, lockFrames = {}, run = {}, setupAfter = {} }, + loot = { title = "Loot", sections = {}, stepDefs = {}, lockFrames = {}, run = {}, setupAfter = {} }, + mounts = { title = "Mounts", sections = {}, stepDefs = {}, lockFrames = {}, run = {}, setupAfter = {} }, + } + -- Working refs reassigned by loadDemo. Existing state-machine code + -- captures these as upvalues, so swapping them here propagates. + local currentDemo = DEMOS.uiSearch + local currentDemoKey = "uiSearch" + local DEMO_STEPS = currentDemo.stepDefs + local DEMO_SECTIONS = currentDemo.sections + local demoSteps -- assigned after the run table is populated + local setupAfterStep -- assigned after the setupAfter table is populated + local loadDemo -- forward decl; defined after setupAfter is populated + local refreshDemoMenuActive -- forward decl; defined where the menu items are built + local active -- forward decl; assigned later as the demo "is open" flag + + -- Playback speed multiplier set by the speed flyout. Declared + -- early so the scroll animation OnUpdate (defined a few lines + -- below with the demo frame) can capture it as an upvalue. + -- tickFrame's OnUpdate and cursor handlers also close over + -- this so the whole demo runs at the chosen rate. + local playbackSpeed = 1.0 + + demoFrame = CreateFrame("Frame", nil, UIParent, "BackdropTemplate") + -- Fixed height: the step list scrolls inside a ScrollFrame + -- anchored below the title separator. Demos that are longer + -- than the visible area scroll the active step into view; the + -- rest of the list stays clipped above/below like a crawl. + demoFrame:SetSize(280, 290) + -- Shift down from center so the panel doesn't cover the minimap. + -- User can still drag it anywhere they want. + demoFrame:SetPoint("RIGHT", UIParent, "RIGHT", -32, -120) + demoFrame:SetFrameStrata("FULLSCREEN_DIALOG") + demoFrame:SetIgnoreParentAlpha(true) + -- Make the panel draggable so the user can move it out of the + -- way when it covers the minimap or the demo target window. + demoFrame:SetMovable(true) + demoFrame:EnableMouse(true) + demoFrame:RegisterForDrag("LeftButton") + demoFrame:SetScript("OnDragStart", demoFrame.StartMoving) + demoFrame:SetScript("OnDragStop", demoFrame.StopMovingOrSizing) + demoFrame:SetBackdrop({ + edgeFile = TOOLTIP_BORDER, + edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 }, + }) + demoFrame:SetBackdropBorderColor(0.50, 0.48, 0.45, 1.0) + + local demoBgTex = demoFrame:CreateTexture(nil, "BACKGROUND", nil, -1) + demoBgTex:SetPoint("TOPLEFT", 4, -4) + demoBgTex:SetPoint("BOTTOMRIGHT", -4, 4) + demoBgTex:SetAtlas("QuestLog-main-background", false) + demoBgTex:SetAlpha(1.0) + + local title = demoFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge") + title:SetPoint("TOP", 0, -12) + title:SetJustifyH("CENTER") + title:SetSpacing(2) + title:SetText("|cffFFD100" .. currentDemo.title .. "|r") + + local titleSep = demoFrame:CreateTexture(nil, "ARTWORK") + titleSep:SetHeight(1) + titleSep:SetPoint("TOPLEFT", 16, -60) + titleSep:SetPoint("TOPRIGHT", -16, -60) + titleSep:SetColorTexture(0.4, 0.4, 0.4, 0.7) + + -- Fast/Guide toggle row. Only shown for demos that opt in via + -- def.supportsModeToggle. The "Mode:" prefix sits outside the + -- button so the button itself just displays the icon + the + -- current mode name. All sub-elements live on the modeUI table + -- so they only consume one local in this large function. + local modeUI = {} + modeUI.row = CreateFrame("Frame", nil, demoFrame) + modeUI.row:SetSize(108, 16) + modeUI.row:SetPoint("TOP", title, "BOTTOM", 0, -4) + modeUI.row:Hide() + + modeUI.prefix = modeUI.row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + modeUI.prefix:SetPoint("LEFT", modeUI.row, "LEFT", 0, 0) + modeUI.prefix:SetText("Mode:") + modeUI.prefix:SetTextColor(0.85, 0.85, 0.85) + + modeUI.btn = CreateFrame("Button", nil, modeUI.row) + modeUI.btn:SetSize(64, 16) + modeUI.btn:SetPoint("LEFT", modeUI.prefix, "RIGHT", 4, 0) + local _modeTex = modeUI.btn:CreateTexture(nil, "BACKGROUND") + _modeTex:SetAllPoints() + _modeTex:SetColorTexture(0.4, 0.4, 0.4, 0.6) + _modeTex = modeUI.btn:CreateTexture(nil, "ARTWORK") + _modeTex:SetPoint("TOPLEFT", 1, -1) + _modeTex:SetPoint("BOTTOMRIGHT", -1, 1) + _modeTex:SetColorTexture(0.12, 0.12, 0.12, 0.95) + modeUI.icon = modeUI.btn:CreateTexture(nil, "OVERLAY") + modeUI.icon:SetSize(11, 11) + modeUI.icon:SetPoint("LEFT", modeUI.btn, "LEFT", 4, 0) + modeUI.label = modeUI.btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + modeUI.label:SetPoint("LEFT", modeUI.icon, "RIGHT", 3, 0) + modeUI.label:SetPoint("RIGHT", modeUI.btn, "RIGHT", -3, 0) + modeUI.label:SetJustifyH("LEFT") + modeUI.btn:SetHighlightTexture(130757, "ADD") + + local function refreshModeToggle() + local def = DEMOS[currentDemoKey] + if not def or not def.supportsModeToggle then + modeUI.row:Hide() + return + end + local isFast = demoModeFast[currentDemoKey] ~= false + if isFast then + modeUI.icon:SetAtlas(nil) + modeUI.icon:SetTexture(LIGHTNING_BOLT_TEX) + modeUI.label:SetText("Fast") + modeUI.label:SetTextColor(1.0, 0.82, 0.0) + else + modeUI.icon:SetTexture(nil) + modeUI.icon:SetAtlas("common-search-magnifyingglass") + modeUI.label:SetText("Guide") + modeUI.label:SetTextColor(0.6, 0.85, 1.0) + end + modeUI.row:Show() + end + + modeUI.btn:SetScript("OnClick", function() + if not active or not currentDemoKey then return end + demoModeFast[currentDemoKey] = (demoModeFast[currentDemoKey] == false) + if loadDemo then loadDemo(currentDemoKey) end + end) + + modeUI.btn:SetScript("OnEnter", function(self) + GameTooltip:SetOwner(self, "ANCHOR_BOTTOM") + if demoModeFast[currentDemoKey] ~= false then + GameTooltip:SetText("Fast Mode") + GameTooltip:AddLine("Click to switch this demo to Guide Mode.", 1, 1, 1, true) + else + GameTooltip:SetText("Guide Mode") + GameTooltip:AddLine("Click to switch this demo to Fast Mode.", 1, 1, 1, true) + end + GameTooltip:Show() + end) + modeUI.btn:SetScript("OnLeave", GameTooltip_Hide) + + -- Scroll container for the step list. Sits directly below the + -- title separator and above the transport-button row. Rows are + -- anchored to stepScrollChild, so when the active step changes + -- we simply SetVerticalScroll() on stepScrollFrame and the rows + -- slide up "under" the title line (clipping is automatic). + -- Mouse wheel lets the user peek back at earlier steps. + local stepScrollFrame = CreateFrame("ScrollFrame", nil, demoFrame) + stepScrollFrame:SetPoint("TOPLEFT", demoFrame, "TOPLEFT", 8, -64) + stepScrollFrame:SetPoint("TOPRIGHT", demoFrame, "TOPRIGHT", -8, -64) + stepScrollFrame:SetPoint("BOTTOM", demoFrame, "BOTTOM", 0, 88) + stepScrollFrame:EnableMouseWheel(true) + local stepScrollChild = CreateFrame("Frame", nil, stepScrollFrame) + stepScrollChild:SetSize(1, 1) -- width follows scrollFrame, height grows + stepScrollFrame:SetScrollChild(stepScrollChild) + -- ScrollFrame + ScrollChild default width behavior: child width + -- can be less than parent. Force the child to match the + -- scroll frame's width so anchoring to TOPLEFT/TOPRIGHT works. + stepScrollFrame:HookScript("OnSizeChanged", function(self, w) + stepScrollChild:SetWidth(w) + end) + stepScrollChild:SetWidth(stepScrollFrame:GetWidth()) + + -- Smooth scroll animation: callers set scrollTarget, an + -- OnUpdate tween eases scrollCurrent toward it. Avoids the + -- jumpy SetVerticalScroll-every-step feel. + local stepScrollMax = 0 + local scrollCurrent = 0 + local scrollTarget = 0 + local scrollAnimFrame = CreateFrame("Frame") + scrollAnimFrame:Hide() + scrollAnimFrame:SetScript("OnUpdate", function(self, dt) + local diff = scrollTarget - scrollCurrent + if math.abs(diff) < 0.3 then + scrollCurrent = scrollTarget + stepScrollFrame:SetVerticalScroll(scrollCurrent) + self:Hide() + return + end + -- Exponential ease scaled by playbackSpeed so the scroll + -- tween finishes faster at higher speeds (matching cursor + -- moves and safeAfter-based pauses). + local step = diff * mmin(dt * 8 * playbackSpeed, 1) + scrollCurrent = scrollCurrent + step + stepScrollFrame:SetVerticalScroll(scrollCurrent) + end) + + local function setScrollTarget(target) + if target < 0 then target = 0 end + if target > stepScrollMax then target = stepScrollMax end + scrollTarget = target + scrollAnimFrame:Show() + end + + stepScrollFrame:SetScript("OnMouseWheel", function(_, delta) + setScrollTarget(scrollTarget - delta * 20) + end) + + -- Step row builder. Pools row buttons and section header buttons + -- so loadDemo can swap demos without leaking frames. Each step row + -- is a Button whose OnClick jumps to that step. Each section + -- header is also a Button whose OnClick jumps to the state right + -- BEFORE that section's first step (so the next Play / Next plays + -- the section from the very beginning, not the end of step 1). + -- jumpToStep / jumpToBeforeStep are forward-declared here and + -- assigned later, after setupAfterStep exists. + local jumpToStep + local jumpToBeforeStep + local highlightOverride -- forward decl; set by step-row clicks + local pendingSectionHighlight -- forward decl; set by step funcs to pre-highlight a section header during transition text + -- Cursor position snapshots: cursorEndPos[i] = { x, y } is the + -- cursor location at the moment step i's done() fired. Used + -- when the user clicks a step row to jump to that step, so + -- the cursor appears exactly where it would be mid-playback + -- instead of resetting to the default starting spot. + local cursorEndPos = {} + local refreshStepList -- forward decl; defined after rebuildStepRows + local stepFS = {} -- indexed by DEMO_STEPS index, used by refreshStepList + local headerFS = {} -- indexed by section number, used by refreshStepList + local stepRowPool = {} -- pool of { btn, fs } reused across demos + local headerPool = {} -- pool of { btn, fs } reused across demos + + local emptyMsg = demoFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + emptyMsg:SetPoint("TOP", 0, -100) + emptyMsg:SetTextColor(0.6, 0.6, 0.6, 1) + emptyMsg:SetText("Coming soon...") + emptyMsg:Hide() + + -- Tracks per-step Y offset inside the scroll child so + -- scrollToStep can slide the active step into view. + local stepYOffset = {} -- [stepIdx] = positive pixels from top of scrollChild + local headerYOffset = {} -- [sectionNum] = positive pixels from top of scrollChild + + local function rebuildStepRows() + for _, hd in ipairs(headerPool) do hd.btn:Hide() end + for _, row in ipairs(stepRowPool) do row.btn:Hide() end + wipe(headerFS) + wipe(stepFS) + wipe(stepYOffset) + wipe(headerYOffset) + if not DEMO_SECTIONS or #DEMO_SECTIONS == 0 then + emptyMsg:Show() + stepScrollChild:SetHeight(1) + stepScrollMax = 0 + scrollCurrent = 0 + scrollTarget = 0 + stepScrollFrame:SetVerticalScroll(0) + return + end + emptyMsg:Hide() + local headerIdx, rowIdx = 0, 0 + local y = 4 -- positive offset from top of scrollChild + for sIdx, sect in ipairs(DEMO_SECTIONS) do + -- Visual gap between sections (skip before the first one) + if sIdx > 1 then y = y + 12 end + local hasHeader = sect.header and sect.header ~= "" + if hasHeader then + headerIdx = headerIdx + 1 + local hd = headerPool[headerIdx] + if not hd then + local hbtn = CreateFrame("Button", nil, stepScrollChild) + hbtn:SetHighlightTexture("Interface\\Buttons\\WHITE8x8", "ADD") + local hhl = hbtn:GetHighlightTexture() + hhl:SetVertexColor(1, 0.82, 0, 0.12) + hhl:SetPoint("TOPLEFT", hbtn, "TOPLEFT", -2, 0) + hhl:SetPoint("BOTTOMRIGHT", hbtn, "BOTTOMRIGHT", 2, 0) + local hfs = hbtn:CreateFontString(nil, "OVERLAY", "GameFontNormal") + hfs:SetPoint("LEFT", hbtn, "LEFT", 0, 0) + hd = { btn = hbtn, fs = hfs } + headerPool[headerIdx] = hd + else + hd.btn:SetParent(stepScrollChild) + end + hd.btn:ClearAllPoints() + hd.btn:SetPoint("TOPLEFT", stepScrollChild, "TOPLEFT", 8, -y) + hd.btn:SetPoint("TOPRIGHT", stepScrollChild, "TOPRIGHT", -4, -y) + hd.btn:SetHeight(20) + hd.btn:Show() + hd.fs:SetText(sect.header) + local firstStep = sect.firstStep + hd.btn:SetScript("OnClick", function() + if jumpToBeforeStep then jumpToBeforeStep(firstStep) end + end) + headerFS[sect.section] = hd.fs + headerYOffset[sect.section] = y + y = y + 24 + end + for i = sect.firstStep, sect.lastStep do + local step = DEMO_STEPS[i] + rowIdx = rowIdx + 1 + local row = stepRowPool[rowIdx] + if not row then + local btn = CreateFrame("Button", nil, stepScrollChild) + btn:SetHighlightTexture("Interface\\Buttons\\WHITE8x8", "ADD") + local hl = btn:GetHighlightTexture() + hl:SetVertexColor(1, 0.82, 0, 0.12) + hl:SetPoint("TOPLEFT", btn, "TOPLEFT", -2, 0) + hl:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 2, 0) + local fs = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal") + fs:SetPoint("LEFT", btn, "LEFT", 4, 0) + row = { btn = btn, fs = fs } + stepRowPool[rowIdx] = row + else + row.btn:SetParent(stepScrollChild) + end + row.btn:ClearAllPoints() + row.btn:SetPoint("TOPLEFT", stepScrollChild, "TOPLEFT", 20, -(y + 2)) + row.btn:SetPoint("TOPRIGHT", stepScrollChild, "TOPRIGHT", -4, -(y + 2)) + row.btn:SetHeight(18) + row.btn:Show() + row.fs:SetText(step.text) + stepYOffset[i] = y + local stepIdx = i + row.btn:SetScript("OnClick", function() + if jumpToBeforeStep then + jumpToBeforeStep(stepIdx) + highlightOverride = stepIdx + refreshStepList() + end + end) + stepFS[i] = row.fs + y = y + 20 + end + end + local contentHeight = y + 4 + stepScrollChild:SetHeight(contentHeight) + -- Only allow scrolling when the content actually + -- overflows the viewport. Short demos whose whole step + -- list fits inside stepScrollFrame get stepScrollMax = 0, + -- so setScrollTarget clamps every request back to 0 and + -- the list sits still — no pointless crawl when there's + -- nothing hidden. + local viewportHeight = stepScrollFrame:GetHeight() or 0 + stepScrollMax = contentHeight - viewportHeight + if stepScrollMax < 0 then stepScrollMax = 0 end + scrollCurrent = 0 + scrollTarget = 0 + stepScrollFrame:SetVerticalScroll(0) + end + rebuildStepRows() + + -- Transport controls: [◀ Prev] [Play/Pause] [Next ▶] + -- The arrow buttons use the existing flyout-arrow.tga rotated, + -- and play/pause uses custom TGAs (demo-play.tga, demo-pause.tga) + -- so the glyphs render reliably regardless of font support. + local playBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") + playBtn:SetSize(35, 27) + playBtn:SetPoint("BOTTOM", demoFrame, "BOTTOM", 0, 44) + playBtn:SetText("") + local playIcon = playBtn:CreateTexture(nil, "OVERLAY") + playIcon:SetSize(11, 11) + playIcon:SetPoint("CENTER") + playIcon:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") + playBtn.icon = playIcon + + local prevBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") + prevBtn:SetSize(27, 27) + prevBtn:SetPoint("RIGHT", playBtn, "LEFT", -3, 0) + prevBtn:SetText("") + local prevIcon = prevBtn:CreateTexture(nil, "OVERLAY") + prevIcon:SetSize(10, 10) + prevIcon:SetPoint("CENTER") + prevIcon:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") + prevIcon:SetTexCoord(1, 0, 0, 1) -- horizontal flip for left-pointing arrow + + local nextBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") + nextBtn:SetSize(27, 27) + nextBtn:SetPoint("LEFT", playBtn, "RIGHT", 3, 0) + nextBtn:SetText("") + local nextIcon = nextBtn:CreateTexture(nil, "OVERLAY") + nextIcon:SetSize(10, 10) + nextIcon:SetPoint("CENTER") + nextIcon:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") + + -- Section skip buttons (<<, >>) sit on the far ends of the + -- transport row. Each renders the flyout-arrow texture twice so + -- the icon reads as a "double arrow". Click jumps to the previous + -- / next section header (or very start / end of the demo). + local sectPrevBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") + sectPrevBtn:SetSize(27, 27) + sectPrevBtn:SetPoint("RIGHT", prevBtn, "LEFT", -3, 0) + sectPrevBtn:SetText("") + local sectPrevIcon1 = sectPrevBtn:CreateTexture(nil, "OVERLAY") + sectPrevIcon1:SetSize(8, 8) + sectPrevIcon1:SetPoint("CENTER", -4, 0) + sectPrevIcon1:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") + sectPrevIcon1:SetTexCoord(1, 0, 0, 1) + local sectPrevIcon2 = sectPrevBtn:CreateTexture(nil, "OVERLAY") + sectPrevIcon2:SetSize(8, 8) + sectPrevIcon2:SetPoint("CENTER", 4, 0) + sectPrevIcon2:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") + sectPrevIcon2:SetTexCoord(1, 0, 0, 1) + + local sectNextBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") + sectNextBtn:SetSize(27, 27) + sectNextBtn:SetPoint("LEFT", nextBtn, "RIGHT", 3, 0) + sectNextBtn:SetText("") + local sectNextIcon1 = sectNextBtn:CreateTexture(nil, "OVERLAY") + sectNextIcon1:SetSize(8, 8) + sectNextIcon1:SetPoint("CENTER", -4, 0) + sectNextIcon1:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") + local sectNextIcon2 = sectNextBtn:CreateTexture(nil, "OVERLAY") + sectNextIcon2:SetSize(8, 8) + sectNextIcon2:SetPoint("CENTER", 4, 0) + sectNextIcon2:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") + + -- Tooltip hints so the arrow-only buttons are still discoverable + local function attachTooltip(btn, text) + btn:SetScript("OnEnter", function(self) + GameTooltip:SetOwner(self, "ANCHOR_TOP") + GameTooltip:SetText(text) + GameTooltip:Show() + end) + btn:SetScript("OnLeave", GameTooltip_Hide) + end + -- Replay button: appears when the demo reaches its final step, + -- replacing the Play button. Clicking it resets the demo to its + -- initial state so the player can choose Play or Next again. + local replayBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") + replayBtn:SetSize(35, 27) + replayBtn:SetPoint("CENTER", playBtn, "CENTER", 0, 0) + replayBtn:SetText("") + local replayIcon = replayBtn:CreateTexture(nil, "OVERLAY") + replayIcon:SetSize(14, 14) + replayIcon:SetPoint("CENTER") + replayIcon:SetTexture("Interface\\AddOns\\EasyFind\\demo-replay") + replayBtn:Hide() + + -- Stop button: hard-resets the demo to its initial state, as if + -- the demo window had just been opened. Sits at the very far + -- left of the transport row. + local stopBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") + stopBtn:SetSize(27, 27) + stopBtn:SetPoint("RIGHT", sectPrevBtn, "LEFT", -3, 0) + stopBtn:SetText("") + local stopIcon = stopBtn:CreateTexture(nil, "OVERLAY") + stopIcon:SetSize(9, 9) + stopIcon:SetPoint("CENTER") + stopIcon:SetTexture("Interface\\Buttons\\WHITE8x8") + stopIcon:SetVertexColor(0.95, 0.85, 0.2, 1) + + -- Speed button: opens a flyout with playback-speed multipliers. + -- Sits at the very far right of the transport row. Uses the + -- gold cogwheel TGA generated by gen_cog_icon.py. + local speedBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") + speedBtn:SetSize(27, 27) + speedBtn:SetPoint("LEFT", sectNextBtn, "RIGHT", 3, 0) + speedBtn:SetText("") + local speedIcon = speedBtn:CreateTexture(nil, "OVERLAY") + speedIcon:SetSize(16, 16) + speedIcon:SetPoint("CENTER") + speedIcon:SetTexture("Interface\\AddOns\\EasyFind\\demo-cog") + + attachTooltip(stopBtn, "Stop / Reset Demo") + attachTooltip(sectPrevBtn, "Previous Section") + attachTooltip(prevBtn, "Previous Step") + attachTooltip(nextBtn, "Next Step") + attachTooltip(sectNextBtn, "Next Section") + attachTooltip(speedBtn, "Playback Speed") + attachTooltip(playBtn, "Play / Pause") + attachTooltip(replayBtn, "Replay Demo") + + -- Top-right close button (matches the Options panel). Replaces + -- the old "Got it" bottom button so the bottom of the panel can + -- pull tighter against the dropdown row. + local closeDemoBtn = CreateFrame("Button", nil, demoFrame, "UIPanelCloseButton") + closeDemoBtn:SetPoint("TOPRIGHT", demoFrame, "TOPRIGHT", -5, -5) + + -- "See more demos" dropdown. Same visual style as the multi-select + -- dropdowns in the EasyFind options panel (Search Bars, EF Map + -- Icons, Minimap, Map Pins): WoW common-dropdown atlas background + -- with a chevron arrow on the right. Sits between the transport + -- row and the Got it button. Opens upward over the step list so + -- it doesn't spill below the demo panel. + local DEMO_LIST = { + { name = "UI Search", key = "uiSearch" }, + { name = "Zone/instance map search", key = "mapSearchZone" }, + { name = "Current zone map search", key = "mapSearchCurrent" }, + { name = "Map search through UI bar",key = "mapSearchUI" }, + { name = "Outfits", key = "outfits" }, + { name = "Appearance sets", key = "appearanceSets" }, + { name = "Loot", key = "loot" }, + { name = "Mounts", key = "mounts" }, + } + + local moreBtn = CreateFrame("Button", nil, demoFrame) + moreBtn:SetSize(200, 26) + moreBtn:SetPoint("BOTTOM", demoFrame, "BOTTOM", 0, 12) + local moreBg = moreBtn:CreateTexture(nil, "BACKGROUND") + moreBg:SetAllPoints() + moreBg:SetAtlas("common-dropdown-b-button-hover") + local moreBtnText = moreBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal") + moreBtnText:SetPoint("LEFT", 4, 0) + moreBtnText:SetPoint("RIGHT", -18, 0) + moreBtnText:SetJustifyH("CENTER") + moreBtnText:SetText("See more demos") + local moreArrow = moreBtn:CreateTexture(nil, "OVERLAY") + moreArrow:SetSize(18, 18) + moreArrow:SetPoint("RIGHT", -2, 0) + moreArrow:SetAtlas("common-dropdown-b-arrow-closed") + + local MORE_ROW_H = 24 + local moreFlyout = CreateFrame("Frame", nil, UIParent, "BackdropTemplate") + moreFlyout:SetSize(200, #DEMO_LIST * MORE_ROW_H + 8) + moreFlyout:SetFrameStrata("FULLSCREEN_DIALOG") + moreFlyout:SetFrameLevel(900) + moreFlyout:SetBackdrop({ + bgFile = "Interface\\BUTTONS\\WHITE8X8", + edgeFile = TOOLTIP_BORDER, + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 2, right = 2, top = 2, bottom = 2 }, + }) + moreFlyout:SetBackdropColor(0.08, 0.08, 0.08, 1) + moreFlyout:Hide() + + moreBtn:SetScript("OnClick", function() + moreFlyout:ClearAllPoints() + -- Open upward so the menu hangs over the step list instead + -- of spilling below the demo panel. + moreFlyout:SetPoint("BOTTOM", moreBtn, "TOP", 0, 2) + local opening = not moreFlyout:IsShown() + moreFlyout:SetShown(opening) + moreArrow:SetAtlas(opening and "common-dropdown-b-arrow-open" or "common-dropdown-b-arrow-closed") + end) + moreFlyout:SetScript("OnShow", function(self) + self:SetScript("OnUpdate", function(s) + if not s:IsMouseOver() and not moreBtn:IsMouseOver() then + if IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton") then + s:Hide() + end + end + end) + end) + moreFlyout:SetScript("OnHide", function(self) + self:SetScript("OnUpdate", nil) + moreArrow:SetAtlas("common-dropdown-b-arrow-closed") + end) + + local moreItems = {} + for i, entry in ipairs(DEMO_LIST) do + local item = CreateFrame("Button", nil, moreFlyout) + item:SetSize(192, 22) + item:SetPoint("TOPLEFT", moreFlyout, "TOPLEFT", 4, -4 - (i - 1) * MORE_ROW_H) + item:SetNormalFontObject("GameFontNormal") + item:SetHighlightFontObject("GameFontHighlight") + item:SetText(entry.name) + local itemHL = item:CreateTexture(nil, "BACKGROUND") + itemHL:SetAllPoints() + itemHL:SetColorTexture(1, 0.82, 0, 0.12) + itemHL:Hide() + item:SetScript("OnEnter", function() itemHL:Show() end) + item:SetScript("OnLeave", function() itemHL:Hide() end) + local entryKey = entry.key + item:SetScript("OnClick", function() + moreFlyout:Hide() + if loadDemo then loadDemo(entryKey) end + end) + moreItems[i] = { btn = item, key = entry.key, name = entry.name } + end + + -- Color the active demo's row gold so the player can see which + -- demo is currently loaded. Inactive rows render in white. + refreshDemoMenuActive = function() + for _, mi in ipairs(moreItems) do + if mi.key == currentDemoKey then + mi.btn:SetText("|cffFFD100" .. mi.name .. "|r") + else + mi.btn:SetText("|cffFFFFFF" .. mi.name .. "|r") + end + end + end + refreshDemoMenuActive() + + -- Playback-speed flyout. Opens above the speed cog button at + -- the far-right of the transport row. Active speed is gold. + local SPEED_OPTIONS = { 0.5, 0.75, 1.0, 1.25, 1.5 } + local SPEED_ROW_H = 22 + local speedFlyout = CreateFrame("Frame", nil, UIParent, "BackdropTemplate") + speedFlyout:SetSize(80, #SPEED_OPTIONS * SPEED_ROW_H + 8) + speedFlyout:SetFrameStrata("FULLSCREEN_DIALOG") + speedFlyout:SetFrameLevel(900) + speedFlyout:SetBackdrop({ + bgFile = "Interface\\BUTTONS\\WHITE8X8", + edgeFile = TOOLTIP_BORDER, + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 2, right = 2, top = 2, bottom = 2 }, + }) + speedFlyout:SetBackdropColor(0.08, 0.08, 0.08, 1) + speedFlyout:Hide() + + speedBtn:SetScript("OnClick", function() + speedFlyout:ClearAllPoints() + speedFlyout:SetPoint("BOTTOM", speedBtn, "TOP", 0, 4) + speedFlyout:SetShown(not speedFlyout:IsShown()) + end) + speedFlyout:SetScript("OnShow", function(self) + self:SetScript("OnUpdate", function(s) + if not s:IsMouseOver() and not speedBtn:IsMouseOver() then + if IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton") then + s:Hide() + end + end + end) + end) + speedFlyout:SetScript("OnHide", function(self) + self:SetScript("OnUpdate", nil) + end) + + local speedItems = {} + local function refreshSpeedMenu() + for _, mi in ipairs(speedItems) do + if mi.value == playbackSpeed then + mi.btn:SetText("|cffFFD100" .. mi.label .. "|r") + else + mi.btn:SetText("|cffFFFFFF" .. mi.label .. "|r") + end + end + end + for i, mult in ipairs(SPEED_OPTIONS) do + local item = CreateFrame("Button", nil, speedFlyout) + item:SetSize(72, 20) + item:SetPoint("TOPLEFT", speedFlyout, "TOPLEFT", 4, -4 - (i - 1) * SPEED_ROW_H) + item:SetNormalFontObject("GameFontNormal") + item:SetHighlightFontObject("GameFontHighlight") + local label = (mult == math.floor(mult)) and (tostring(math.floor(mult)) .. "x") or (tostring(mult) .. "x") + item:SetText(label) + local itemHL = item:CreateTexture(nil, "BACKGROUND") + itemHL:SetAllPoints() + itemHL:SetColorTexture(1, 0.82, 0, 0.12) + itemHL:Hide() + item:SetScript("OnEnter", function() itemHL:Show() end) + item:SetScript("OnLeave", function() itemHL:Hide() end) + local mySpeed = mult + item:SetScript("OnClick", function() + playbackSpeed = mySpeed + speedFlyout:Hide() + refreshSpeedMenu() + end) + speedItems[i] = { btn = item, value = mult, label = label } + end + refreshSpeedMenu() + + -- State vars used by both setActiveStep() and the state machine. + -- completedUpTo: highest step index that's already run (0 = none) + -- animatingIdx: step currently running (0 = none) + local completedUpTo = 0 + local animatingIdx = 0 + + -- Re-render the step list. Only ONE element is bright gold at a + -- time: a step when animating or mid-section, the section HEADER + -- when idle at a section boundary (e.g., demo just opened or + -- just finished the previous section). The active section's + -- header is white; all non-active headers and non-active steps + -- are gray. + -- Scroll the active step into view inside stepScrollFrame. The + -- target Y offset is chosen so the step sits a little below the + -- top edge (so it looks like it just "crawled up" past the + -- title separator). Routes through setScrollTarget so the + -- scroll animates smoothly instead of snapping. + local function scrollToStep(stepIdx) + if not stepIdx or not stepYOffset[stepIdx] then return end + setScrollTarget(stepYOffset[stepIdx] - 6) + end + + local function scrollToHeader(sectionNum) + if not sectionNum or not headerYOffset[sectionNum] then return end + setScrollTarget(headerYOffset[sectionNum] - 6) + end + + -- Called by a step function to pre-highlight a later section's + -- header while the step is still technically running (e.g. + -- while the "Now let's..." transition text is on screen). The + -- header lights up and scrolls into view immediately. Stays + -- highlighted until the next runStep clears the flag or a + -- cancelInFlight wipes demo state. + local function beginSectionTransition(sectionNum) + pendingSectionHighlight = sectionNum + refreshStepList() + end + + refreshStepList = function() + if not DEMO_STEPS or #DEMO_STEPS == 0 then return end + local activeStep, activeHeader + if pendingSectionHighlight then + -- A step's action has explicitly requested the next + -- section's header be highlighted (e.g. during the + -- "Now let's see what that looks like..." transition + -- text). The header stays gold, no step is active. + activeHeader = pendingSectionHighlight + elseif highlightOverride then + activeStep = highlightOverride + elseif animatingIdx > 0 then + activeStep = animatingIdx + else + local nextIdx = completedUpTo + 1 + if nextIdx <= #DEMO_STEPS then + -- At a section boundary? Highlight the header instead + -- of the first step (the step lights up once Play is + -- hit, not before). + local atBoundary = false + for _, sect in ipairs(DEMO_SECTIONS) do + if nextIdx == sect.firstStep then + atBoundary = true + activeHeader = sect.section + break + end + end + if not atBoundary then + activeStep = nextIdx + end + end + end + local focusSection + if activeStep then + focusSection = DEMO_STEPS[activeStep] and DEMO_STEPS[activeStep].section + elseif activeHeader then + focusSection = activeHeader + end + + for _, sect in ipairs(DEMO_SECTIONS) do + local hfs = headerFS[sect.section] + if hfs then + if sect.section == activeHeader then + hfs:SetTextColor(1.0, 0.82, 0.0, 1.0) -- gold (highlighted) + elseif sect.section == focusSection then + hfs:SetTextColor(1.0, 1.0, 1.0, 1.0) -- white (active section) + else + hfs:SetTextColor(0.5, 0.5, 0.5, 0.85) -- gray + end + end + end + + for j, fs in ipairs(stepFS) do + if j == activeStep then + fs:SetTextColor(1.0, 0.82, 0.0, 1.0) -- gold (current step) + else + fs:SetTextColor(0.5, 0.5, 0.5, 0.85) -- gray + end + end + + -- Slide the active step (or active header at a section + -- boundary) into the visible window. Earlier rows scroll + -- "under" the title separator (clipped by the scroll + -- frame), later rows remain below it until their turn. + if activeStep then + scrollToStep(activeStep) + elseif activeHeader then + scrollToHeader(activeHeader) + end + end + refreshStepList() + + -- Everything below lives inside _runDemo so its locals don't + -- count toward startDemo's MAXVARS=200 budget AND so they + -- aren't upvalues for _runDemo's inner closures (avoids + -- MAXUPVAL=60). _runDemo runs immediately at the bottom of + -- startDemo. The forward-declared `active` upvalue is still + -- shared with the modeUI button handlers above. + local function _runDemo() + + -- Save originals so the lock toggling system can restore them + -- when the demo isn't actively running. The search bar drag + -- handler and the editbox keyboard state are both temporarily + -- replaced/disabled while a step is animating, then restored + -- whenever the demo is paused, idle, or stopped. + local savedDragStart = searchFrame:GetScript("OnDragStart") + local savedDirectOpen = EasyFind.db.directOpen + local savedGlobalMapDirectOpen = EasyFind.db.globalMapDirectOpen + local savedLocalMapDirectOpen = EasyFind.db.localMapDirectOpen + -- Also snapshot the UI bar's Map Search filter toggle and + -- local/global sub-option. The map-search-through-UI-bar demo + -- flips both and must restore the user's actual pre-demo + -- preference on stop/close, otherwise the bar would behave + -- differently after running the demo. + local savedUiMapFilter + if EasyFind.db.uiSearchFilters then + savedUiMapFilter = EasyFind.db.uiSearchFilters.map + end + local savedUiMapSearchLocal = EasyFind.db.uiMapSearchLocal + + -- The UI filter dropdown has an OnUpdate handler that auto-hides + -- when the user's mouse button is down outside the dropdown. The + -- demo's click blockers eat real clicks but the button-down + -- state still reads true, which causes spurious auto-close + -- during demo playback. Swap in a no-op OnUpdate for the demo's + -- lifetime so the dropdown stays open when the demo wants it + -- open. Restored on endDemo. + local savedFilterDropdownOnUpdate + if searchFrame.filterDropdown then + savedFilterDropdownOnUpdate = searchFrame.filterDropdown:GetScript("OnUpdate") + searchFrame.filterDropdown:SetScript("OnUpdate", nil) + end + + -- Temporarily force-disable movement fade (staticOpacity = true) + -- so the search bar stays at full alpha even if the player walks + -- around mid-demo. Restored on endDemo. + local savedStaticOpacity = EasyFind.db.staticOpacity + EasyFind.db.staticOpacity = true + searchFrame:SetAlpha(1.0) + + -- Mock cursor that animates between targets. Parented to UIParent + -- so we can position it in any coordinate space via raw pixel + -- offsets and so it floats above the rest of the UI. + local cursor = CreateFrame("Frame", nil, UIParent) + cursor:SetSize(36, 36) + cursor:SetFrameStrata("TOOLTIP") + cursor:EnableMouse(false) + local cursorTex = cursor:CreateTexture(nil, "OVERLAY") + cursorTex:SetAllPoints() + -- HD Gauntlet texture in its natural orientation. + cursorTex:SetTexture(4489300) + cursorTex:SetTexCoord(0.0000, 0.2315, 0.0000, 0.4104) + cursor:Hide() + + active = true + -- stepGen is bumped whenever an in-flight animation is cancelled so + -- Prev/Next can interrupt at any time. Every async helper captures + -- the gen at call time and aborts if it no longer matches. + local stepGen = 0 + -- True-pause support: virtualTime only advances when not paused, and + -- safeAfter timers fire based on virtualTime via tickFrame's + -- OnUpdate. Cursor OnUpdate handlers also early-return when paused + -- so cursor moves and click animations freeze in place. + local paused = false + local virtualTime = 0 + local pendingTimers = {} + local stopBlinkCursor -- forward decl; defined below next to typeText + local updateLockState -- forward decl; defined where the locks are built + local setHoveredRow -- forward decl; defined with result-row hover + local clearButtonHover -- forward decl; defined before moveCursorTo + local resetMapSearchState -- forward decl; defined with map search demo + local closeWorldMap -- forward decl; defined with map search demo + + local tickFrame = CreateFrame("Frame") + tickFrame:SetScript("OnUpdate", function(_, dt) + if not active or paused then return end + virtualTime = virtualTime + dt * playbackSpeed + local i = 1 + while i <= #pendingTimers do + local t = pendingTimers[i] + if t.fireAt <= virtualTime then + tremove(pendingTimers, i) + if t.gen == stepGen then + pcall(t.fn) + -- t.fn may have queued more timers (or wiped the + -- queue via cancelInFlight); restart the scan to + -- handle index shifts safely. + i = 1 + end + else + i = i + 1 + end + end + end) + + local function safeAfter(delay, fn) + if not active then return end + tinsert(pendingTimers, { + fireAt = virtualTime + delay, + fn = fn, + gen = stepGen, + }) + end + + -- Restore the user's pre-demo settings AND refresh the mode + -- button visual (the icon doesn't auto-update when directOpen + -- changes, so without this call it stays stuck on whatever the + -- last demo step left it as). + local function restoreUserSettings() + -- staticOpacity stays forced true while the demo is open; + -- it's restored in endDemo, not here, so loadDemo doesn't + -- re-enable movement fade between demo switches. + EasyFind.db.directOpen = savedDirectOpen + EasyFind.db.globalMapDirectOpen = savedGlobalMapDirectOpen + EasyFind.db.localMapDirectOpen = savedLocalMapDirectOpen + EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} + EasyFind.db.uiSearchFilters.map = savedUiMapFilter + EasyFind.db.uiMapSearchLocal = savedUiMapSearchLocal + if ns.UpdateModeButtonVisual then + pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) + end + if ns.MapSearch and ns.MapSearch.UpdateMapModeBtns then + pcall(ns.MapSearch.UpdateMapModeBtns, ns.MapSearch) + end + -- Refresh the filter dropdown's visual state to match the + -- restored saved values (map checkbox + local/global sub). + local dd = searchFrame.filterDropdown + if dd and dd.checkRows and dd.checkRows.map then + local mr = dd.checkRows.map + if mr.SetChecked then mr:SetChecked(savedUiMapFilter ~= false) end + if mr.updateMapToggle then mr.updateMapToggle() end + end + end + + -- Transition banner shown at the end of Fast Mode, hidden when + -- Guide Mode begins. Anchored near the top-center of the screen + -- on the TOOLTIP strata so it floats above every menu and frame. + -- The backdrop is flat-dark with a gold tooltip border (matching + -- the demo panel's aesthetic) plus a soft radial glow behind the + -- text so it reads clearly without looking like a stock dialog. + -- A fade-in/out animation group softens the transition. + local transitionFrame = CreateFrame("Frame", nil, UIParent) + transitionFrame:SetFrameStrata("TOOLTIP") + transitionFrame:SetFrameLevel(1000) + transitionFrame:SetPoint("TOP", UIParent, "TOP", 0, -160) + transitionFrame:SetSize(400, 72) + transitionFrame:SetIgnoreParentAlpha(true) + + local transitionFS = transitionFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge") + transitionFS:SetPoint("CENTER", transitionFrame, "CENTER", 0, 0) + transitionFS:SetWidth(370) + transitionFS:SetJustifyH("CENTER") + transitionFS:SetTextColor(1.0, 0.82, 0.0, 1.0) + transitionFS:SetShadowColor(0, 0, 0, 1) + transitionFS:SetShadowOffset(2, -2) + transitionFS:SetText("Now let's take a look at what that process looks like in Guide Mode.") + + transitionFrame:Hide() + + -- Shim so existing Show/Hide callsites targeting transitionText + -- still work even though the actual visible object is the frame. + local transitionText = {} + function transitionText:Show() transitionFrame:Show() end + function transitionText:Hide() transitionFrame:Hide() end + + -- Minimap callout: floating gold text anchored to the minimap, + -- used to draw attention to minimap changes (e.g. "your target + -- is now tracked"). Same visual style as transitionText. + local minimapCalloutFrame = CreateFrame("Frame", nil, UIParent) + minimapCalloutFrame:SetFrameStrata("TOOLTIP") + minimapCalloutFrame:SetFrameLevel(1000) + minimapCalloutFrame:SetSize(300, 60) + minimapCalloutFrame:SetIgnoreParentAlpha(true) + -- Anchor just below the minimap so the callout points at it. + -- Falls back to top-right of the screen if Minimap isn't present. + if _G["Minimap"] then + minimapCalloutFrame:SetPoint("RIGHT", _G["Minimap"], "LEFT", -20, 40) + else + minimapCalloutFrame:SetPoint("TOPRIGHT", UIParent, "TOPRIGHT", -240, -80) + end + + local minimapCalloutFS = minimapCalloutFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + minimapCalloutFS:SetPoint("CENTER", minimapCalloutFrame, "CENTER", 0, 0) + minimapCalloutFS:SetWidth(280) + minimapCalloutFS:SetJustifyH("CENTER") + minimapCalloutFS:SetTextColor(1.0, 0.82, 0.0, 1.0) + minimapCalloutFS:SetShadowColor(0, 0, 0, 1) + minimapCalloutFS:SetShadowOffset(2, -2) + minimapCalloutFS:SetText("Note how your target is now tracked!") + minimapCalloutFrame:Hide() + + local minimapCallout = {} + function minimapCallout:SetText(text) minimapCalloutFS:SetText(text or "") end + function minimapCallout:Show() minimapCalloutFrame:Show() end + function minimapCallout:Hide() minimapCalloutFrame:Hide() end + + -- Floating narration anchored next to the local map search frame. + -- Used by mapSearchCurrent's "browse what's around" step. Frame + -- and font string live as fields on the table so this consumes + -- only one local in the parent function. + local mapSearchCallout = {} + mapSearchCallout.frame = CreateFrame("Frame", nil, UIParent) + mapSearchCallout.frame:SetFrameStrata("TOOLTIP") + mapSearchCallout.frame:SetFrameLevel(1000) + mapSearchCallout.frame:SetSize(280, 60) + mapSearchCallout.frame:SetIgnoreParentAlpha(true) + mapSearchCallout.frame:Hide() + mapSearchCallout.fs = mapSearchCallout.frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + mapSearchCallout.fs:SetPoint("CENTER", mapSearchCallout.frame, "CENTER", 0, 0) + mapSearchCallout.fs:SetWidth(260) + mapSearchCallout.fs:SetJustifyH("CENTER") + mapSearchCallout.fs:SetTextColor(1.0, 0.82, 0.0, 1.0) + mapSearchCallout.fs:SetShadowColor(0, 0, 0, 1) + mapSearchCallout.fs:SetShadowOffset(2, -2) + function mapSearchCallout:SetText(text) self.fs:SetText(text or "") end + function mapSearchCallout:Show() + local lsf = _G["EasyFindMapSearchFrame"] + if lsf then + self.frame:ClearAllPoints() + self.frame:SetPoint("LEFT", lsf, "RIGHT", 24, 0) + end + self.frame:Show() + end + function mapSearchCallout:Hide() self.frame:Hide() end + + -- Forward decl so endDemo / cancelInFlight / resetDemoGameState + -- can call minimapArrow:Hide() via upvalue. The methods are + -- attached later, once the arrow frame is built alongside the + -- map search demo helpers. + local minimapArrow = {} + function minimapArrow:Show() end + function minimapArrow:Hide() end + + local function endDemo() + active = false + paused = false + wipe(pendingTimers) + tickFrame:SetScript("OnUpdate", nil) + cursor:SetScript("OnUpdate", nil) + cursor:Hide() + stopBlinkCursor() + setHoveredRow(nil) + clearButtonHover() + transitionText:Hide() + minimapCallout:Hide() + minimapArrow:Hide() + -- Wipe any demo-modified search bar state so the bar returns + -- to its clean default (placeholder visible, no stale text). + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + searchFrame.editBox:ClearFocus() + if searchFrame.editBox.placeholder then + searchFrame.editBox.placeholder:Show() + end + if searchFrame.filterDropdown then + if searchFrame.filterDropdown:IsShown() then + searchFrame.filterDropdown:Hide() + end + -- Restore the original OnUpdate handler (auto-close). + searchFrame.filterDropdown:SetScript("OnUpdate", savedFilterDropdownOnUpdate) + end + if ns.Highlight and ns.Highlight.ClearAll then + pcall(ns.Highlight.ClearAll, ns.Highlight) + end + -- Clean up map search state if a map demo was running. + if resetMapSearchState then pcall(resetMapSearchState) end + if closeWorldMap then pcall(closeWorldMap) end + -- Release every blocker, restore drag handler and editbox. + if updateLockState then updateLockState() end + demoFrame:Hide() + restoreUserSettings() + EasyFind.db.staticOpacity = savedStaticOpacity + if not InCombatLockdown() then + local psf = _G["PlayerSpellsFrame"] + if psf and psf.IsShown and psf:IsShown() then + pcall(HideUIPanel, psf) + end + end + FinishSetup() + end + + closeDemoBtn:SetScript("OnClick", endDemo) + + -- Escape closes the demo, but only if the editbox doesn't have + -- focus. If the editbox is focused, the first Esc unfocuses it + -- (WoW's default behavior); the second Esc closes the demo. + demoFrame:EnableKeyboard(true) + demoFrame:SetPropagateKeyboardInput(true) + demoFrame:SetScript("OnKeyDown", function(self, key) + if key == "ESCAPE" then + if searchFrame.editBox:HasFocus() then + searchFrame.editBox:ClearFocus() + self:SetPropagateKeyboardInput(false) + else + self:SetPropagateKeyboardInput(false) + endDemo() + end + else + self:SetPropagateKeyboardInput(true) + end + end) + + -- Compute a frame's center in UIParent's coordinate space, taking + -- effective scale into account so the cursor lines up with the + -- target regardless of where the target lives in the frame tree. + local function centerInUIParent(frame) + if not frame or not frame.GetCenter or not frame:IsShown() then return nil end + local cx, cy = frame:GetCenter() + if not cx then return nil end + local fs = frame:GetEffectiveScale() + local us = UIParent:GetEffectiveScale() + return cx * fs / us, cy * fs / us + end + + -- Simulate hover highlights on result rows as the cursor passes + -- over them. A single texture is re-anchored to the hovered row + -- each frame, bypassing the WoW highlight system (which won't + -- fire because click blockers eat real mouse events). + local rowHoverTex = resultsFrame.scrollChild:CreateTexture(nil, "OVERLAY") + rowHoverTex:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") + rowHoverTex:SetBlendMode("ADD") + rowHoverTex:SetVertexColor(1, 1, 1, 0.6) + rowHoverTex:Hide() + local hoveredResultRow + + setHoveredRow = function(row) + if row == hoveredResultRow then return end + -- Unlock previous row's highlight (for map result buttons + -- which use LockHighlight instead of the overlay texture). + if hoveredResultRow then + if hoveredResultRow.UnlockHighlight then + hoveredResultRow:UnlockHighlight() + end + end + hoveredResultRow = row + if row then + -- Map result buttons (EasyFindMapResultButton*) use + -- SetHighlightTexture natively, so LockHighlight works. + -- UI search buttons use the rowHoverTex overlay instead. + local isMapBtn = row:GetName() and sfind(row:GetName(), "MapResult") + if isMapBtn then + if row.LockHighlight then row:LockHighlight() end + else + rowHoverTex:ClearAllPoints() + rowHoverTex:SetAllPoints(row) + rowHoverTex:Show() + end + else + rowHoverTex:Hide() + end + end + + -- Check if cursor is over any result row (UI search OR map search). + local function updateResultRowHover() + if not cursor:IsShown() then + setHoveredRow(nil) + return + end + local cl = cursor:GetLeft() + local ct = cursor:GetTop() + if not cl then setHoveredRow(nil); return end + local cx, cy = cl + 4, ct - 4 + -- UI search results + if resultsFrame:IsShown() then + for i = 1, #resultButtons do + local row = resultButtons[i] + if row and row:IsShown() and row.data then + local rl, rr, rt, rb = row:GetLeft(), row:GetRight(), row:GetTop(), row:GetBottom() + if rl and cx >= rl and cx <= rr and cy <= rt and cy >= rb then + setHoveredRow(row) + return + end + end + end + end + -- Map search results + local mapResults = _G["EasyFindMapResultsFrame"] + if mapResults and mapResults:IsShown() then + for i = 1, 20 do + local row = _G["EasyFindMapResultButton" .. i] + if row and row:IsShown() and row.data then + local rl, rr, rt, rb = row:GetLeft(), row:GetRight(), row:GetTop(), row:GetBottom() + if rl and cx >= rl and cx <= rr and cy <= rt and cy >= rb then + setHoveredRow(row) + return + end + end + end + end + setHoveredRow(nil) + end + + local function placeCursorAt(x, y) + cursor:ClearAllPoints() + cursor:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", x - 4, y + 4) + updateResultRowHover() + end + + clearButtonHover = function() + GameTooltip_Hide() + -- UI search mode button + local mb = searchFrame.modeBtn + if mb then + if mb.btnBg then mb.btnBg:Hide() end + if mb.UnlockHighlight then mb:UnlockHighlight() end + end + -- Map search mode buttons + local gsf = _G["EasyFindMapGlobalSearchFrame"] + if gsf and gsf.modeBtn then + if gsf.modeBtn.btnBg then gsf.modeBtn.btnBg:Hide() end + if gsf.modeBtn.UnlockHighlight then gsf.modeBtn:UnlockHighlight() end + end + local lsf = _G["EasyFindMapSearchFrame"] + if lsf and lsf.modeBtn then + if lsf.modeBtn.btnBg then lsf.modeBtn.btnBg:Hide() end + if lsf.modeBtn.UnlockHighlight then lsf.modeBtn:UnlockHighlight() end + end + end + + local function moveCursorTo(targetFrame, duration, onArrive, offsetX, offsetY) + -- Clear any lingering hover state from the previous target + -- (tooltip, button outline, highlight) so it doesn't persist + -- while the cursor is in transit to a new frame. + clearButtonHover() + local tx, ty = centerInUIParent(targetFrame) + if not tx then + if onArrive then onArrive() end + return + end + if offsetX then tx = tx + offsetX end + if offsetY then ty = ty + offsetY end + -- If the cursor isn't visible yet, start it at the middle + -- right of the screen so the first move comes in from a + -- natural resting position. + if not cursor:IsShown() then + local sx = UIParent:GetWidth() * 0.72 + local sy = UIParent:GetHeight() * 0.5 + placeCursorAt(sx, sy) + cursor:Show() + end + local sx = cursor:GetLeft() + 4 + local sy = cursor:GetTop() - 4 + local elapsed = 0 + local myGen = stepGen + cursor:SetScript("OnUpdate", function(self, dt) + if not active or myGen ~= stepGen then + self:SetScript("OnUpdate", nil) + return + end + if paused then return end + elapsed = elapsed + dt * playbackSpeed + local t = elapsed / duration + if t >= 1 then t = 1 end + local eased = t * t * (3 - 2 * t) -- smoothstep + local nx = sx + (tx - sx) * eased + local ny = sy + (ty - sy) * eased + placeCursorAt(nx, ny) + if t >= 1 then + self:SetScript("OnUpdate", nil) + if onArrive then pcall(onArrive) end + end + end) + end + + local function clickAnim(onComplete) + -- Quick scale pulse: shrink ~30% then back. ~0.18s total. + local startSize = cursor:GetWidth() or 36 + local minSize = startSize * 0.7 + local elapsed = 0 + local myGen = stepGen + cursor:SetScript("OnUpdate", function(self, dt) + if not active or myGen ~= stepGen then + self:SetScript("OnUpdate", nil) + self:SetSize(36, 36) + return + end + if paused then return end + elapsed = elapsed + dt * playbackSpeed + local total = 0.18 + local half = total / 2 + local sz + if elapsed < half then + sz = startSize + (minSize - startSize) * (elapsed / half) + else + sz = minSize + (startSize - minSize) * ((elapsed - half) / half) + end + self:SetSize(sz, sz) + if elapsed >= total then + self:SetSize(startSize, startSize) + self:SetScript("OnUpdate", nil) + if onComplete then pcall(onComplete) end + end + end) + end + + -- Fake blinking cursor: mimics the "just focused the editbox" + -- visual (placeholder hidden + blinking vertical bar) without + -- actually giving the real editbox keyboard focus. + local blinkCursor = searchFrame.editBox:CreateTexture(nil, "OVERLAY") + blinkCursor:SetTexture("Interface\\Buttons\\WHITE8x8") + blinkCursor:SetVertexColor(1, 1, 1, 1) + blinkCursor:SetWidth(1) + blinkCursor:SetPoint("LEFT", searchFrame.editBox, "LEFT", 2, 0) + blinkCursor:SetPoint("TOP", searchFrame.editBox, "TOP", 0, -3) + blinkCursor:SetPoint("BOTTOM", searchFrame.editBox, "BOTTOM", 0, 3) + blinkCursor:Hide() + local blinkTicker + local function startBlinkCursor() + searchFrame.editBox.placeholder:Hide() + blinkCursor:Show() + blinkCursor:SetAlpha(1) + local visible = true + if blinkTicker then blinkTicker:Cancel() end + blinkTicker = C_Timer.NewTicker(0.5, function() + visible = not visible + blinkCursor:SetAlpha(visible and 1 or 0) + end) + end + -- Assign (don't 'local') so the forward-declared stopBlinkCursor + -- captured by endDemo() resolves to this function. + stopBlinkCursor = function() + if blinkTicker then blinkTicker:Cancel(); blinkTicker = nil end + blinkCursor:Hide() + end + + local function typeText(text, charDelay, onComplete) + -- Set text programmatically via SetText (no SetFocus call) so + -- the editbox visually shows the typed characters and the + -- search runs via OnTextChanged, but the player can't actually + -- type into it mid-demo. + stopBlinkCursor() -- typed text replaces the blink cursor + local editBox = searchFrame.editBox + editBox:SetText("") + local i = 0 + local myGen = stepGen + local function nextChar() + if not active or myGen ~= stepGen then return end + i = i + 1 + if i > #text then + if onComplete then onComplete() end + return + end + editBox:SetText(text:sub(1, i)) + UI:OnSearchTextChanged(editBox:GetText()) + safeAfter(charDelay, nextChar) + end + nextChar() + end + + -- Find a specific result row by entry name (e.g. "Spellbook") so + -- the demo clicks the right one instead of whatever fuzzy search + -- put at the top. + local function findResultRowByName(name) + for i = 1, #resultButtons do + local row = resultButtons[i] + if row and row:IsShown() and row.data and row.data.name == name then + return row + end + end + return nil + end + + local function findFirstResultRow() + for i = 1, #resultButtons do + local row = resultButtons[i] + if row and row:IsShown() and row.data + and not row.data.isPinHeader and not row.data.isHeader then + return row + end + end + return nil + end + + -- Find the first visible result that's a map-search result + -- (mapSearchResult = true). Used by the Map-search-through-UI-bar + -- demo where the target is a flight master POI whose name is + -- " (Flight Master)" so findResultRowByName can't match + -- exactly. + local function findFirstMapSearchResult() + for i = 1, #resultButtons do + local row = resultButtons[i] + if row and row:IsShown() and row.data and row.data.mapSearchResult + and not row.data.isPinHeader and not row.data.isHeader then + return row + end + end + return nil + end + + -------------------------------------------------------------------- + -- Step array: each step is a function(done) that runs its + -- animation and calls done() when finished. The state machine + -- below chains them either automatically (Play All) or waits for + -- the player to click Next Step between each one. + -------------------------------------------------------------------- + local CURSOR_MOVE = 0.8 -- cursor travel time per move + local TYPE_DELAY = 0.3 -- per character (human-paced) + local STEP_PAUSE = 0.2 -- tiny pause at the end of "filler" steps + local SETTLE_PAUSE = 0.8 -- post-click "see what happened" pause for action steps + -- Gaps framing every autoplay step. + -- PRE_ACT_GAP: after a step highlights (and scrolls into + -- view), wait this long before firing the action. This is + -- the "reading time" so the user can absorb the step label + -- before the cursor starts moving. + -- POST_ACT_GAP: after a step's action finishes, keep the + -- step highlighted this long so the result sinks in + -- before the highlight advances. + -- Section-boundary header beats also use POST_ACT_GAP. + local PRE_ACT_GAP = 0.7 + local POST_ACT_GAP = 0.4 + + -- Defensive precondition helper. Each step calls ensureMode at + -- the start with the directOpen value it expects, so if the user + -- toggled the mode while paused or between steps it gets snapped + -- back to the default the step assumes. + local function ensureMode(wantFast) + if EasyFind.db.directOpen ~= wantFast then + EasyFind.db.directOpen = wantFast + if ns.UpdateModeButtonVisual then + pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) + end + end + end + + -- Reset PlayerSpellsFrame to its default tab (Specialization) so + -- the guide mode demo shows the full navigation steps instead + -- of opening directly on the tab we left it on during fast mode. + local function resetPlayerSpellsFrame() + local frame = _G["PlayerSpellsFrame"] + if not frame then return end + if frame.TabSystem and frame.TabSystem.tabs and frame.TabSystem.tabs[1] then + local tab = frame.TabSystem.tabs[1] + if tab.Click then pcall(tab.Click, tab) end + else + local fallback = _G["PlayerSpellsFrameTab1"] + if fallback and fallback.Click then pcall(fallback.Click, fallback) end + end + if not InCombatLockdown() and frame.IsShown and frame:IsShown() then + pcall(HideUIPanel, frame) + end + end + + -- Hover the mode button to show what it does. If the player has + -- it set to Guide Mode, this also performs an animated click + -- to flip it back to Fast Mode (since the demo assumes Fast). + -- The toggle happens INSIDE the click animation so the visual + -- and the state change land at the same moment. + -- Offset the cursor toward the bottom-right of the toggle button + -- so the icon (lightning bolt / magnifying glass) stays visible + -- during the hover and click animations. + local MODE_BTN_OFFSET_X = 9 + local MODE_BTN_OFFSET_Y = -9 + + local function hoverModeButton(done) + local mb = searchFrame.modeBtn + moveCursorTo(mb, CURSOR_MOVE, function() + if mb.btnBg then mb.btnBg:Show() end + if mb.LockHighlight then mb:LockHighlight() end + GameTooltip:SetOwner(mb, "ANCHOR_BOTTOM") + local function showFastTooltip() + GameTooltip:SetText("Fast Mode") + GameTooltip:AddLine("Click to switch to step-by-step Guide Mode.", 1, 1, 1, true) + GameTooltip:Show() + end + if not EasyFind.db.directOpen then + GameTooltip:SetText("Guide Mode") + GameTooltip:AddLine("Click to enable Fast Mode (opens panels directly).", 1, 1, 1, true) + GameTooltip:Show() + safeAfter(1.0, function() + clickAnim(function() + EasyFind.db.directOpen = true + if ns.UpdateModeButtonVisual then + pcall(ns.UpdateModeButtonVisual, mb) + end + showFastTooltip() + safeAfter(1.0, done) + end) + end) + else + showFastTooltip() + safeAfter(1.2, done) + end + end, MODE_BTN_OFFSET_X, MODE_BTN_OFFSET_Y) + end + + -- Mirror of hoverModeButton that switches to Guide Mode instead. + -- Used by mapSearchUI's guide variant in step 1. + local function hoverModeButtonToGuide(done) + local mb = searchFrame.modeBtn + moveCursorTo(mb, CURSOR_MOVE, function() + if mb.btnBg then mb.btnBg:Show() end + if mb.LockHighlight then mb:LockHighlight() end + GameTooltip:SetOwner(mb, "ANCHOR_BOTTOM") + local function showGuideTooltip() + GameTooltip:SetText("Guide Mode") + GameTooltip:AddLine("Click to enable Fast Mode (opens panels directly).", 1, 1, 1, true) + GameTooltip:Show() + end + if EasyFind.db.directOpen then + GameTooltip:SetText("Fast Mode") + GameTooltip:AddLine("Click to switch to step-by-step Guide Mode.", 1, 1, 1, true) + GameTooltip:Show() + safeAfter(1.0, function() + clickAnim(function() + EasyFind.db.directOpen = false + if ns.UpdateModeButtonVisual then + pcall(ns.UpdateModeButtonVisual, mb) + end + showGuideTooltip() + safeAfter(1.0, done) + end) + end) + else + showGuideTooltip() + safeAfter(1.2, done) + end + end, MODE_BTN_OFFSET_X, MODE_BTN_OFFSET_Y) + end + + -- Focuses the UI search bar with cursor animation + blink + -- cursor, then types the query. Owns the full "search for X" + -- action so there's no standalone focus step. `wantFast` + -- defaults to true; pass false for the Guide Mode sections. + local function uis_stepFocusAndType(query, wantFast, done) + ensureMode(wantFast) + moveCursorTo(searchFrame.editBox, CURSOR_MOVE, function() + clickAnim(function() + startBlinkCursor() + safeAfter(STEP_PAUSE, function() + typeText(query, TYPE_DELAY, function() + safeAfter(STEP_PAUSE, done) + end) + end) + end) + end) + end + + DEMOS.uiSearch.run = { + -- 1: Make sure Fast Mode is enabled. If the player is in + -- Standard Mode, hoverModeButton click-animates the toggle + -- and flips directOpen during the click visualization. + function(done) + hoverModeButton(done) + end, + -- 2: Start typing "sp" (cursor flies to the bar, focuses + -- it, then types — all in this single step). + function(done) + uis_stepFocusAndType("sp", true, done) + end, + -- 3: Click the Spellbook result (not just the first one). + -- Final step of Fast Mode. After the result fires and the + -- user sees the panel open, sequence: + -- 1. Settle (see the Spellbook tab open) + -- 2. Clean up: hide cursor, close opened windows, clear + -- search text so the screen is clean + -- 3. Show transition banner + -- 4. Pause so the user can read it + -- 5. Done → step 5 picks up + function(done) + ensureMode(true) + local target = findResultRowByName("Spellbook") or findFirstResultRow() or searchFrame.editBox + moveCursorTo(target, CURSOR_MOVE, function() + clickAnim(function() + UI:SelectResult(spellbookEntry) + safeAfter(1.5, function() + -- Move cursor to the close button on PlayerSpellsFrame + -- and fake-click it, then close the window. + local psf = _G["PlayerSpellsFrame"] + local closeBtn = psf and (psf.ClosePanelButton or psf.CloseButton) + if closeBtn and closeBtn:IsShown() then + moveCursorTo(closeBtn, CURSOR_MOVE, function() + clickAnim(function() + setHoveredRow(nil) + stopBlinkCursor() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if ns.Highlight and ns.Highlight.ClearAll then + pcall(ns.Highlight.ClearAll, ns.Highlight) + end + resetPlayerSpellsFrame() + safeAfter(1.0, function() + transitionText:Show() + beginSectionTransition(2) + safeAfter(3.5, done) + end) + end) + end) + else + setHoveredRow(nil) + stopBlinkCursor() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if ns.Highlight and ns.Highlight.ClearAll then + pcall(ns.Highlight.ClearAll, ns.Highlight) + end + resetPlayerSpellsFrame() + safeAfter(1.0, function() + transitionText:Show() + beginSectionTransition(2) + safeAfter(2.0, done) + end) + end + end) + end) + end) + end, + -- 4: Switch to Guide Mode (hover with tooltip + highlight, + -- click, show updated tooltip so the user sees the change). + -- Tooltip/highlight persist until the cursor moves to the + -- next target (clearButtonHover runs at the start of + -- moveCursorTo). + function(done) + transitionText:Hide() + ensureMode(true) + local mb = searchFrame.modeBtn + moveCursorTo(mb, CURSOR_MOVE, function() + if mb.btnBg then mb.btnBg:Show() end + if mb.LockHighlight then mb:LockHighlight() end + GameTooltip:SetOwner(mb, "ANCHOR_BOTTOM") + GameTooltip:SetText("Fast Mode") + GameTooltip:AddLine("Click to switch to step-by-step Guide Mode.", 1, 1, 1, true) + GameTooltip:Show() + safeAfter(1.0, function() + clickAnim(function() + EasyFind.db.directOpen = false + if ns.UpdateModeButtonVisual then + pcall(ns.UpdateModeButtonVisual, mb) + end + GameTooltip:SetText("Guide Mode") + GameTooltip:AddLine("Click to enable Fast Mode (opens panels directly).", 1, 1, 1, true) + GameTooltip:Show() + safeAfter(0.8, done) + end) + end) + end, MODE_BTN_OFFSET_X, MODE_BTN_OFFSET_Y) + end, + -- 5: Start typing "sp" in Guide Mode + function(done) + uis_stepFocusAndType("sp", false, done) + end, + -- 6: Click the Spellbook result (fires real Highlight) + function(done) + ensureMode(false) + local target = findResultRowByName("Spellbook") or findFirstResultRow() or searchFrame.editBox + moveCursorTo(target, CURSOR_MOVE, function() + clickAnim(function() + UI:SelectResult(spellbookEntry) + safeAfter(SETTLE_PAUSE, done) + end) + end) + end, + -- 7: Click the Player Spells micro button (real Highlight arrow showing) + function(done) + ensureMode(false) + local microBtn = _G["PlayerSpellsMicroButton"] + if not microBtn then done(); return end + moveCursorTo(microBtn, CURSOR_MOVE, function() + clickAnim(function() + if not InCombatLockdown() then + pcall(ShowUIPanel, _G["PlayerSpellsFrame"]) + end + safeAfter(SETTLE_PAUSE, done) + end) + end) + end, + -- 8: Click the Spellbook tab (tab 3), then after a settle + -- move the cursor to the close button, click it, close the + -- window, and hide the cursor to end the demo cleanly. + function(done) + ensureMode(false) + local tab + local psf = _G["PlayerSpellsFrame"] + if psf and psf.TabSystem and psf.TabSystem.tabs then + tab = psf.TabSystem.tabs[3] + end + if not tab then tab = _G["PlayerSpellsFrameTab3"] end + if not tab then done(); return end + moveCursorTo(tab, CURSOR_MOVE, function() + clickAnim(function() + if tab.Click then pcall(tab.Click, tab) end + safeAfter(SETTLE_PAUSE + 1.0, function() + local closeBtn = psf and (psf.ClosePanelButton or psf.CloseButton) + if closeBtn and closeBtn:IsShown() then + moveCursorTo(closeBtn, CURSOR_MOVE, function() + clickAnim(function() + setHoveredRow(nil) + stopBlinkCursor() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if ns.Highlight and ns.Highlight.ClearAll then + pcall(ns.Highlight.ClearAll, ns.Highlight) + end + resetPlayerSpellsFrame() + safeAfter(0.5, function() + cursor:Hide() + done() + end) + end) + end) + else + setHoveredRow(nil) + stopBlinkCursor() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if ns.Highlight and ns.Highlight.ClearAll then + pcall(ns.Highlight.ClearAll, ns.Highlight) + end + resetPlayerSpellsFrame() + safeAfter(0.5, function() + cursor:Hide() + done() + end) + end + end) + end) + end) + end, + } + demoSteps = DEMOS.uiSearch.run + + -------------------------------------------------------------------- + -- Zone/Instance Map Search demo + -------------------------------------------------------------------- + + -- Type text into an arbitrary editbox (map search bars need real + -- focus for OnTextChanged to fire and trigger the search). The + -- editbox is given programmatic focus, which bypasses click + -- blockers. Characters are typed via SetText one at a time. + local function typeMapText(editBox, text, charDelay, onComplete) + -- NEVER focus the real editbox: if it had keyboard focus, + -- the real user could type into it alongside the demo. + -- Instead, drive the MapSearch engine directly so results + -- appear and update as if typed. Detect which bar the + -- caller passed so we route to the right scope. + editBox:ClearFocus() + if editBox.placeholder then editBox.placeholder:Hide() end + local gsf = _G["EasyFindMapGlobalSearchFrame"] + local isGlobal = gsf and gsf.editBox == editBox + local runFn + if isGlobal and ns.MapSearch and ns.MapSearch.RunGlobalSearch then + runFn = function(t) ns.MapSearch:RunGlobalSearch(t) end + elseif ns.MapSearch and ns.MapSearch.RunLocalSearch then + runFn = function(t) ns.MapSearch:RunLocalSearch(t) end + else + runFn = function(t) editBox:SetText(t) end + end + runFn("") + local i = 0 + local myGen = stepGen + local function nextChar() + if not active or myGen ~= stepGen then return end + i = i + 1 + if i > #text then + if onComplete then onComplete() end + return + end + runFn(text:sub(1, i)) + safeAfter(charDelay, nextChar) + end + nextChar() + end + + -- Find a map search result button by partial name match. + local function findMapResultByName(pattern) + for i = 1, 20 do + local btn = _G["EasyFindMapResultButton" .. i] + if btn and btn:IsShown() and btn.data and btn.data.name then + if sfind(slower(btn.data.name), slower(pattern)) then + return btn + end + end + end + return nil + end + + -- Find the first visible map result (skipping any header rows). + local function findFirstVisibleMapResult() + for i = 1, 20 do + local btn = _G["EasyFindMapResultButton" .. i] + if btn and btn:IsShown() and btn.data and btn.data.name + and not btn.data.isHeader then + return btn + end + end + return nil + end + + -- Open the world map, loading the addon if needed. + local function openWorldMap() + if C_AddOns and C_AddOns.LoadAddOn then + pcall(C_AddOns.LoadAddOn, "Blizzard_WorldMap") + end + if not WorldMapFrame or not WorldMapFrame:IsShown() then + pcall(ToggleWorldMap) + end + end + + closeWorldMap = function() + if not InCombatLockdown() and WorldMapFrame and WorldMapFrame:IsShown() then + pcall(HideUIPanel, WorldMapFrame) + end + end + + local function getMapCloseBtn() + local wmf = WorldMapFrame + if not wmf then return nil end + return wmf.CloseButton + or (wmf.BorderFrame and wmf.BorderFrame.CloseButton) + or nil + end + + -- Helper: ensure the global map search mode toggle is in the + -- expected state and update its visual. + local function ensureGlobalMapMode(wantFast) + if EasyFind.db.globalMapDirectOpen ~= wantFast then + EasyFind.db.globalMapDirectOpen = wantFast + if ns.MapSearch and ns.MapSearch.UpdateMapModeBtns then + pcall(ns.MapSearch.UpdateMapModeBtns, ns.MapSearch) + end + end + end + + -- Helper: clean up map search state (clear editboxes, focus, + -- results, pins, zone highlight, activePinState, waypoint). + -- Both local and global search bars so nothing bleeds across + -- demos or rewinds. Uses MapSearch:ClearAll which nulls out + -- activePinState (otherwise WorldMapFrame's OnShow hook would + -- restore a stale pin the next time the map opens). + resetMapSearchState = function() + local lsf = _G["EasyFindMapSearchFrame"] + if lsf and lsf.editBox then + lsf.editBox:SetText("") + lsf.editBox:ClearFocus() + if lsf.editBox.placeholder then lsf.editBox.placeholder:Show() end + end + local gsf = _G["EasyFindMapGlobalSearchFrame"] + if gsf and gsf.editBox then + gsf.editBox:SetText("") + gsf.editBox:ClearFocus() + if gsf.editBox.placeholder then gsf.editBox.placeholder:Show() end + end + if ns.MapSearch then + if ns.MapSearch.HideResults then pcall(ns.MapSearch.HideResults, ns.MapSearch) end + if ns.MapSearch.ClearAll then pcall(ns.MapSearch.ClearAll, ns.MapSearch) end + if ns.MapSearch.ClearZoneHighlight then pcall(ns.MapSearch.ClearZoneHighlight, ns.MapSearch) end + end + end + + -- Recursive navigation clicker for Guide Mode: checks if the + -- waypoint pin is visible (meaning we arrived at the final + -- destination). If not, looks for either a breadcrumb button in + -- the nav bar OR a zone indicator on the map canvas, clicks it, + -- and recurses. Caps at 10 iterations to prevent infinite loops. + local function clickBreadcrumbsUntilArrived(done, depth) + depth = (depth or 0) + 1 + if depth > 10 then done(); return end + safeAfter(0.6, function() + -- Have we arrived? Waypoint pin visible = destination. + local pin = _G["EasyFindLocationPin"] + if pin and pin:IsShown() then + done() + return + end + -- Priority 1: breadcrumb highlight in the nav bar. + local bcHL = _G["EasyFindBreadcrumbHighlight"] + if bcHL and bcHL:IsShown() then + moveCursorTo(bcHL, CURSOR_MOVE, function() + clickAnim(function() + local _, relTo = bcHL:GetPoint(1) + if relTo and relTo.Click then + pcall(relTo.Click, relTo) + end + clickBreadcrumbsUntilArrived(done, depth) + end) + end) + return + end + -- Priority 2: zone indicator arrow on the map canvas. + -- This appears when the target zone is highlighted on the + -- current map and the user needs to click it to zoom in. + -- The arrow is anchored next to the zone in one of four + -- directions (down/up/right/left) — read indicatorDirection + -- to push the cursor PAST the arrow into the zone itself, + -- not onto the arrow's tip. + local zoneInd = _G["EasyFindZoneIndicator"] + if zoneInd and zoneInd:IsShown() then + local dx, dy = 0, -75 -- default: arrow above zone, cursor goes down + local dir = zoneInd.indicatorDirection + if dir == "up" then + dx, dy = 0, 75 + elseif dir == "right" then + dx, dy = 75, 0 + elseif dir == "left" then + dx, dy = -75, 0 + end + moveCursorTo(zoneInd, CURSOR_MOVE, function() + clickAnim(function() + if ns.MapSearch and ns.MapSearch.pendingZoneHighlight then + pcall(WorldMapFrame.SetMapID, WorldMapFrame, + ns.MapSearch.pendingZoneHighlight) + end + clickBreadcrumbsUntilArrived(done, depth) + end) + end, dx, dy) + return + end + -- Neither visible yet, wait and retry. + clickBreadcrumbsUntilArrived(done, depth) + end) + end + + -- Hover over the waypoint pin to show "found it". When the + -- cursor arrives, call ClearHighlight (same as real OnEnter for + -- global search pins) which hides the pin, glow, and indicator + -- arrow — exactly matching what happens when a real user hovers. + local function hoverWaypointPin(afterHover) + local pin = _G["EasyFindLocationPin"] + if pin and pin:IsShown() then + moveCursorTo(pin, CURSOR_MOVE, function() + if ns.MapSearch and ns.MapSearch.ClearHighlight then + pcall(ns.MapSearch.ClearHighlight, ns.MapSearch) + end + safeAfter(SETTLE_PAUSE + 1.0, afterHover) + end) + else + safeAfter(SETTLE_PAUSE, afterHover) + end + end + + -- Click-animates the cursor onto the global map search bar, + -- then types the query via typeMapText. Owns the full "start + -- typing" action so there's no standalone focus step. + -- All Zone/Instance map search step helpers live on a single + -- table so they only consume one local in this huge function. + local msz = {} + + function msz.stepFocusAndType(query, done) + local gsf = _G["EasyFindMapGlobalSearchFrame"] + local editBox = gsf and gsf.editBox + if not editBox then done(); return end + moveCursorTo(editBox, CURSOR_MOVE, function() + clickAnim(function() + safeAfter(STEP_PAUSE, function() + typeMapText(editBox, query, TYPE_DELAY, function() + safeAfter(SETTLE_PAUSE, done) + end) + end) + end) + end) + end + + function msz.openMap(fast, done) + if InCombatLockdown() then done(); return end + ensureGlobalMapMode(fast) + openWorldMap() + safeAfter(0.8, done) + end + + function msz.clickNexus(fast, done) + ensureGlobalMapMode(fast) + local target = findMapResultByName("The Nexus") + or _G["EasyFindMapResultButton1"] + if not target or not target:IsShown() then done(); return end + moveCursorTo(target, CURSOR_MOVE, function() + clickAnim(function() + if target.data and ns.MapSearch then + pcall(ns.MapSearch.SelectResult, ns.MapSearch, target.data) + end + safeAfter(SETTLE_PAUSE, done) + end) + end) + end + + function msz.finish(done) + hoverWaypointPin(function() + local closeBtn = getMapCloseBtn() + if closeBtn and closeBtn:IsShown() then + moveCursorTo(closeBtn, CURSOR_MOVE, function() + clickAnim(function() + resetMapSearchState() + closeWorldMap() + safeAfter(0.5, function() + cursor:Hide() + done() + end) + end) + end) + else + resetMapSearchState() + closeWorldMap() + safeAfter(0.5, function() + cursor:Hide() + done() + end) + end + end) + end + + function msz.switchToGuide(done) + ensureGlobalMapMode(true) + local gsf = _G["EasyFindMapGlobalSearchFrame"] + local mb = gsf and gsf.modeBtn + if not mb then done(); return end + moveCursorTo(mb, CURSOR_MOVE, function() + if mb.btnBg then mb.btnBg:Show() end + if mb.LockHighlight then mb:LockHighlight() end + GameTooltip:SetOwner(mb, "ANCHOR_TOP") + GameTooltip:SetText("Fast Mode") + GameTooltip:AddLine("Click to switch to Guide mode.", 1, 1, 1, true) + GameTooltip:AddLine("Hold |cFF00FF00Shift|r and drag to reposition.", 0.7, 0.7, 0.7) + GameTooltip:Show() + safeAfter(1.0, function() + clickAnim(function() + EasyFind.db.globalMapDirectOpen = false + if ns.MapSearch and ns.MapSearch.UpdateMapModeBtns then + pcall(ns.MapSearch.UpdateMapModeBtns, ns.MapSearch) + end + GameTooltip:SetText("Guide Mode") + GameTooltip:AddLine("Click to switch to Fast mode.", 1, 1, 1, true) + GameTooltip:AddLine("Hold |cFF00FF00Shift|r and drag to reposition.", 0.7, 0.7, 0.7) + GameTooltip:Show() + safeAfter(0.8, done) + end) + end) + end, MODE_BTN_OFFSET_X, MODE_BTN_OFFSET_Y) + end + + DEMOS.mapSearchZone.rebuild = function(def) + -- Capture the user's saved global mode so restoreUserSettings + -- reverts any flips the run/setupAfter make to the saved value. + savedGlobalMapDirectOpen = EasyFind.db.globalMapDirectOpen + local isFast = demoModeFast["mapSearchZone"] ~= false + if isFast then + def.stepDefs = { + { text = "Open the world map", section = 1 }, + { text = 'Start typing "The Nexus"', section = 1 }, + { text = "Click The Nexus result", section = 1 }, + { text = "Hover over to clear highlight", section = 1 }, + } + def.sections = { + { header = "", section = 1, firstStep = 1, lastStep = 4 }, + } + def.run = { + function(done) msz.openMap(true, done) end, + function(done) ensureGlobalMapMode(true); msz.stepFocusAndType("nex", done) end, + function(done) msz.clickNexus(true, done) end, + msz.finish, + } + def.setupAfter = { + function() ensureGlobalMapMode(true); openWorldMap() end, + function() + ensureGlobalMapMode(true); openWorldMap() + if ns.MapSearch and ns.MapSearch.RunGlobalSearch then + pcall(ns.MapSearch.RunGlobalSearch, ns.MapSearch, "nex") + end + end, + function() ensureGlobalMapMode(true); openWorldMap() end, + function() resetMapSearchState(); closeWorldMap() end, + } + else + def.stepDefs = { + { text = "Open the world map", section = 1 }, + { text = "Switch to Guide Mode", section = 1 }, + { text = 'Start typing "The Nexus"', section = 1 }, + { text = "Click The Nexus result", section = 1 }, + { text = "Follow the breadcrumbs", section = 1 }, + { text = "Hover over to clear highlight", section = 1 }, + } + def.sections = { + { header = "", section = 1, firstStep = 1, lastStep = 6 }, + } + def.run = { + function(done) msz.openMap(true, done) end, + msz.switchToGuide, + function(done) ensureGlobalMapMode(false); msz.stepFocusAndType("nex", done) end, + function(done) msz.clickNexus(false, done) end, + function(done) clickBreadcrumbsUntilArrived(done) end, + msz.finish, + } + def.setupAfter = { + function() ensureGlobalMapMode(true); openWorldMap() end, + function() ensureGlobalMapMode(false); openWorldMap() end, + function() + ensureGlobalMapMode(false); openWorldMap() + if ns.MapSearch and ns.MapSearch.RunGlobalSearch then + pcall(ns.MapSearch.RunGlobalSearch, ns.MapSearch, "nex") + end + end, + function() ensureGlobalMapMode(false); openWorldMap() end, + function() ensureGlobalMapMode(false); openWorldMap() end, + function() resetMapSearchState(); closeWorldMap() end, + } + end + end + + -------------------------------------------------------------------- + -- Current Zone Map Search demo + -------------------------------------------------------------------- + + -- Open the map and set it to the player's current zone. If the + -- player's zone is unsuitable (cosmic map, dungeon, raid, nil), + -- returns false so the caller can bail out gracefully. + local function openWorldMapToPlayerZone() + openWorldMap() + local getBest = _G["GetBestMapForUnit"] + local playerMapID = getBest and getBest("player") + if not playerMapID then return false end + if WorldMapFrame then + pcall(WorldMapFrame.SetMapID, WorldMapFrame, playerMapID) + end + return true + end + + local function ensureLocalMapMode(wantFast) + if EasyFind.db.localMapDirectOpen ~= wantFast then + EasyFind.db.localMapDirectOpen = wantFast + if ns.MapSearch and ns.MapSearch.UpdateMapModeBtns then + pcall(ns.MapSearch.UpdateMapModeBtns, ns.MapSearch) + end + end + end + + -- Click the waypoint pin to place a SuperTrack user waypoint. + -- This triggers the real minimap glow / guide-circle behavior. + -- Uses MapSearch helpers that resolve the pin location whether + -- it was rendered via the overlay (ShowWaypointAt) or by scaling + -- up a native Blizzard pin (HighlightPin). + local function clickPinAndTrack(afterTrack) + local targetFrame = ns.MapSearch and ns.MapSearch.GetActivePinFrame + and ns.MapSearch:GetActivePinFrame() + if not targetFrame then + safeAfter(SETTLE_PAUSE, afterTrack) + return + end + moveCursorTo(targetFrame, CURSOR_MOVE, function() + clickAnim(function() + if ns.MapSearch and ns.MapSearch.TrackActiveLocation then + pcall(ns.MapSearch.TrackActiveLocation, ns.MapSearch) + end + -- Short settle so the glow renders before the + -- caller's minimap-hint cursor walk kicks in. + safeAfter(SETTLE_PAUSE, afterTrack) + end) + end) + end + + -- Clear any waypoint the demo placed, so it doesn't linger. + local function clearDemoWaypoint() + if C_Map and C_Map.ClearUserWaypoint then + pcall(C_Map.ClearUserWaypoint) + end + if C_SuperTrack and C_SuperTrack.SetSuperTrackedUserWaypoint then + pcall(C_SuperTrack.SetSuperTrackedUserWaypoint, false) + end + end + + -- Bouncing tutorial-style arrow anchored under the callout text, + -- pointing up at the text so the user notices it. Uses the same + -- indicator texture as the tutorial Highlight engine, following + -- the user's selected style and color. + local minimapArrowFrame = CreateFrame("Frame", nil, UIParent) + minimapArrowFrame:SetFrameStrata("TOOLTIP") + minimapArrowFrame:SetFrameLevel(1001) + local ARROW_SIZE = ns.ICON_SIZE or 48 + minimapArrowFrame:SetSize(ARROW_SIZE, ARROW_SIZE) + minimapArrowFrame:SetIgnoreParentAlpha(true) + -- Sits directly below the callout text. + -- Sit directly against the minimap's left edge, just below the + -- callout text vertically, so the arrow is visibly next to what + -- it's pointing at. Falls back to the callout anchor if the + -- minimap isn't available. + if _G["Minimap"] then + minimapArrowFrame:SetPoint("RIGHT", _G["Minimap"], "LEFT", -4, -20) + else + minimapArrowFrame:SetPoint("TOP", minimapCalloutFrame, "BOTTOM", 0, -4) + end + + local minimapArrowTex = minimapArrowFrame:CreateTexture(nil, "ARTWORK") + minimapArrowTex:SetAllPoints() + + -- Apply current indicator style/color on show, and rotate so + -- the arrow points RIGHT toward the minimap (the minimap is to + -- the right of the callout text, and this arrow sits under the + -- text). CreateIndicatorTextures' `style.rotation` field (or + -- the preRotated vs not-preRotated default) is the rotation + -- that makes the texture point DOWN in tutorial mode; adding + -- pi/2 rotates CCW 90 degrees, taking "down" to "right". + local function refreshMinimapArrowStyle() + local style = ns.GetIndicatorTexture and ns.GetIndicatorTexture() + or { texture = "Interface\\AddOns\\EasyFind\\Images\\arrow-hq", preRotated = true } + local color = ns.GetIndicatorColor and ns.GetIndicatorColor() + or { 1.0, 0.82, 0.0 } + minimapArrowTex:SetTexture(style.texture) + if style.texCoord then + minimapArrowTex:SetTexCoord(unpack(style.texCoord)) + else + minimapArrowTex:SetTexCoord(0, 1, 0, 1) + end + minimapArrowTex:SetVertexColor(color[1], color[2], color[3], 1) + local pointsDown + if style.rotation then + pointsDown = style.rotation + elseif style.preRotated then + pointsDown = 0 + else + pointsDown = math.pi + end + minimapArrowTex:SetRotation(pointsDown + math.pi / 2) + end + minimapArrowFrame:HookScript("OnShow", refreshMinimapArrowStyle) + minimapArrowFrame:Hide() + + -- Bounce horizontally toward the minimap. Same BOUNCE pattern as + -- Highlight.lua's tutorial indicator, just sideways so the + -- motion reinforces the pointing direction. + local minimapArrowAG = minimapArrowFrame:CreateAnimationGroup() + minimapArrowAG:SetLooping("BOUNCE") + local arrowBounce = minimapArrowAG:CreateAnimation("Translation") + arrowBounce:SetOffset(10, 0) + arrowBounce:SetDuration(0.4) + + -- Attach real methods to the forward-declared minimapArrow table + -- so upvalues in endDemo / cancelInFlight / resetDemoGameState + -- see the live implementation. + function minimapArrow:Show() + minimapArrowFrame:Show() + minimapArrowAG:Play() + end + function minimapArrow:Hide() + minimapArrowAG:Stop() + minimapArrowFrame:Hide() + end + + -- Show the minimap callout text and a bouncing arrow pointing + -- at the minimap. No cursor movement: the arrow does the + -- pointing so the user knows where to look. + local function showMinimapHint(onDone) + minimapCallout:Show() + minimapArrow:Show() + safeAfter(3.0, function() + if onDone then onDone() end + end) + end + + ---------------------------------------------------------------- + -- Current Zone Map Search: rebuilds based on demoModeFast so + -- the toggle button on the demo panel can flip between Fast + -- and Guide variants without touching the user's saved + -- localMapDirectOpen setting. + ---------------------------------------------------------------- + -- All current-zone-map-search step functions live on a single + -- table so this huge parent function only consumes one local + -- for the whole bundle. `wantFast` params are the demo's + -- desired mode (from demoModeFast), NOT the user's saved + -- setting; each helper flips ensureLocalMapMode before its + -- action so SelectResult / auto-track behave correctly. + local msc = {} + + function msc.openMap(wantFast, done) + if InCombatLockdown() then done(); return end + ensureLocalMapMode(wantFast) + local ok = openWorldMapToPlayerZone() + if not ok then + safeAfter(0.5, done) + return + end + safeAfter(0.8, done) + end + + function msc.typeFlight(done) + local lsf = _G["EasyFindMapSearchFrame"] + local editBox = lsf and lsf.editBox + if not editBox then done(); return end + moveCursorTo(editBox, CURSOR_MOVE, function() + clickAnim(function() + editBox:SetText("") + safeAfter(STEP_PAUSE, function() + typeMapText(editBox, "fli", TYPE_DELAY, function() + safeAfter(SETTLE_PAUSE, done) + end) + end) + end) + end) + end + + function msc.finishHintAndClose(done) + showMinimapHint(function() + minimapCallout:Hide() + minimapArrow:Hide() + local closeBtn = getMapCloseBtn() + if closeBtn and closeBtn:IsShown() then + moveCursorTo(closeBtn, CURSOR_MOVE, function() + clickAnim(function() + clearDemoWaypoint() + resetMapSearchState() + closeWorldMap() + safeAfter(0.5, function() + cursor:Hide() + done() + end) + end) + end) + else + clearDemoWaypoint() + resetMapSearchState() + closeWorldMap() + safeAfter(0.5, function() + cursor:Hide() + done() + end) + end + end) + end + + function msc.clickResult(wantFast, done) + ensureLocalMapMode(wantFast) + local target = findMapResultByName("Flight") + or findFirstVisibleMapResult() + if not target then + safeAfter(0.5, done) + return + end + moveCursorTo(target, CURSOR_MOVE, function() + clickAnim(function() + if target.data and ns.MapSearch then + pcall(ns.MapSearch.SelectResult, ns.MapSearch, target.data) + end + if wantFast then + minimapCallout:SetText("Notice how your target is now automatically tracked!") + safeAfter(SETTLE_PAUSE, function() + msc.finishHintAndClose(done) + end) + else + safeAfter(SETTLE_PAUSE, done) + end + end) + end) + end + + function msc.clickPinTrackContinue(done) + clickPinAndTrack(function() + minimapCallout:SetText("Tracked. There's an even easier way...") + safeAfter(2.0, function() + minimapCallout:Hide() + clearDemoWaypoint() + resetMapSearchState() + local lsf = _G["EasyFindMapSearchFrame"] + local editBox = lsf and lsf.editBox + if editBox then editBox:SetText("") end + safeAfter(0.4, done) + end) + end) + end + + function msc.clickNavBtnAutoTrack(done) + local row = findMapResultByName("Flight") or findFirstVisibleMapResult() + if not row or not row.navBtn or not row.navBtn:IsShown() then + safeAfter(0.5, done) + return + end + moveCursorTo(row.navBtn, CURSOR_MOVE, function() + clickAnim(function() + if row.data and ns.MapSearch then + ns.MapSearch.autoTrackNextPin = true + pcall(ns.MapSearch.SelectResult, ns.MapSearch, row.data) + end + safeAfter(SETTLE_PAUSE, done) + end) + end) + end + + function msc.minimapHintAndClose(done) + minimapCallout:SetText("And just like that, your target is tracked on the minimap!") + msc.finishHintAndClose(done) + end + + function msc.pickDiversePOIs() + if not (ns.MapSearch and ns.MapSearch.GetStaticLocations) then return {} end + local pois = ns.MapSearch:GetStaticLocations() + if not pois then return {} end + local picks, seenCat = {}, {} + for _, poi in ipairs(pois) do + local cat = poi.category or "unknown" + if cat ~= "flightmaster" and not seenCat[cat] and poi.name and poi.name ~= "" then + seenCat[cat] = true + picks[#picks + 1] = poi + if #picks >= 3 then break end + end + end + return picks + end + + function msc.hoverSearchPreview(query, displayName, narration, done) + local lsf = _G["EasyFindMapSearchFrame"] + local editBox = lsf and lsf.editBox + if not editBox then done(); return end + moveCursorTo(editBox, CURSOR_MOVE, function() + clickAnim(function() + editBox:SetText("") + safeAfter(STEP_PAUSE, function() + typeMapText(editBox, query, TYPE_DELAY, function() + safeAfter(SETTLE_PAUSE, function() + local row = findMapResultByName(displayName) or findFirstVisibleMapResult() + if not row then done(); return end + moveCursorTo(row, CURSOR_MOVE, function() + mapSearchCallout:SetText(narration) + mapSearchCallout:Show() + local onEnter = row:GetScript("OnEnter") + if onEnter then pcall(onEnter, row) end + safeAfter(2.2, function() + local onLeave = row:GetScript("OnLeave") + if onLeave then pcall(onLeave, row) end + done() + end) + end) + end) + end) + end) + end) + end) + end + + function msc.browseWhatsAround(done) + local picks = msc.pickDiversePOIs() + if #picks == 0 then done(); return end + local idx = 0 + local function nextOne() + idx = idx + 1 + if idx > #picks then + mapSearchCallout:SetText("Now let's see what happens when we click a flight master.") + safeAfter(2.5, function() + mapSearchCallout:Hide() + local lsf = _G["EasyFindMapSearchFrame"] + local editBox = lsf and lsf.editBox + if editBox then editBox:SetText("") end + if ns.MapSearch and ns.MapSearch.ClearHighlight then + pcall(ns.MapSearch.ClearHighlight, ns.MapSearch) + end + done() + end) + return + end + local poi = picks[idx] + local query = slower(poi.name):sub(1, 3) + msc.hoverSearchPreview(query, poi.name, "Hovering over a result shows it on the map.", nextOne) + end + nextOne() + end + + DEMOS.mapSearchCurrent.rebuild = function(def) + -- Capture the user's CURRENT mode as the snapshot so + -- restoreUserSettings (which runs after rebuild) reverts + -- any flips the demo's run/setupAfter make to the saved + -- value. Demo's own fast/guide selection comes from + -- demoModeFast, NOT the user's saved setting. + savedLocalMapDirectOpen = EasyFind.db.localMapDirectOpen + local isFast = demoModeFast["mapSearchCurrent"] ~= false + if isFast then + def.stepDefs = { + { text = "Open the world map", section = 1 }, + { text = "Browse what's around", section = 1 }, + { text = 'Start typing "Flight Master"', section = 1 }, + { text = "Click the Flight Master result", section = 1 }, + } + def.sections = { + { header = "", section = 1, firstStep = 1, lastStep = 4 }, + } + def.run = { + function(done) msc.openMap(true, done) end, + msc.browseWhatsAround, + msc.typeFlight, + function(done) msc.clickResult(true, done) end, + } + def.setupAfter = { + function() ensureLocalMapMode(true); openWorldMapToPlayerZone() end, + function() ensureLocalMapMode(true); openWorldMapToPlayerZone() end, + function() + ensureLocalMapMode(true); openWorldMapToPlayerZone() + if ns.MapSearch and ns.MapSearch.RunLocalSearch then + pcall(ns.MapSearch.RunLocalSearch, ns.MapSearch, "fli") + end + end, + function() clearDemoWaypoint(); resetMapSearchState(); closeWorldMap() end, + } + else + def.stepDefs = { + { text = "Open the world map", section = 1 }, + { text = "Browse what's around", section = 1 }, + { text = 'Start typing "Flight Master"', section = 1 }, + { text = "Click the Flight Master result", section = 1 }, + { text = "Click the pin to start tracking", section = 1 }, + { text = 'Now type "Flight Master" again', section = 1 }, + { text = "Click the nav pin to auto-track instead", section = 1 }, + { text = "Now you're tracking it on the minimap!", section = 1 }, + } + def.sections = { + { header = "", section = 1, firstStep = 1, lastStep = 8 }, + } + def.run = { + function(done) msc.openMap(false, done) end, + msc.browseWhatsAround, + msc.typeFlight, + function(done) msc.clickResult(false, done) end, + msc.clickPinTrackContinue, + msc.typeFlight, + msc.clickNavBtnAutoTrack, + msc.minimapHintAndClose, + } + local snapMapSearch = function() + ensureLocalMapMode(false); openWorldMapToPlayerZone() + if ns.MapSearch and ns.MapSearch.RunLocalSearch then + pcall(ns.MapSearch.RunLocalSearch, ns.MapSearch, "fli") + end + end + def.setupAfter = { + function() ensureLocalMapMode(false); openWorldMapToPlayerZone() end, + function() ensureLocalMapMode(false); openWorldMapToPlayerZone() end, + snapMapSearch, + snapMapSearch, + function() ensureLocalMapMode(false); openWorldMapToPlayerZone() end, + snapMapSearch, + snapMapSearch, + function() clearDemoWaypoint(); resetMapSearchState(); closeWorldMap() end, + } + end + end + + ---------------------------------------------------------------- + -- Map search through UI bar: demonstrates searching for map + -- POIs from the main UI search bar and how Fast/Guide mode + -- affect the result click. Fast Mode places a waypoint + -- directly without opening the map. Guide Mode starts a guide + -- to open the map, then shows a pin the user must click. + ---------------------------------------------------------------- + -- Helpers for the Map-search-through-UI-bar demo. All msui.* + -- helpers live on a single table so they only consume one local + -- in this huge function. The demo verifies Fast Mode + the UI + -- search bar's Map Search filter (Local sub-option), then + -- drives the actual search and click. + local msui = {} + + function msui.openFilterDropdown() + local dd = searchFrame.filterDropdown + if dd and not dd:IsShown() then + local anchorFrame = searchFrame + local barScale = (EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0) + dd:SetScale(barScale) + local scale = anchorFrame:GetEffectiveScale() / (UIParent:GetEffectiveScale() * barScale) + local right = anchorFrame:GetRight() * scale + local bottom = anchorFrame:GetBottom() * scale + dd:ClearAllPoints() + dd:SetPoint("TOPRIGHT", UIParent, "BOTTOMLEFT", right, bottom) + dd:Show() + end + end + + function msui.closeFilterDropdown() + local dd = searchFrame.filterDropdown + if dd and dd:IsShown() then dd:Hide() end + end + + -- Fire the full OnClick handler for a filter-dropdown row. The + -- row's OnClick runs the LayoutDropdown closure which isn't + -- exposed externally — but calling the script directly triggers + -- it and correctly repositions sub-rows. + function msui.fireCheckRowClick(row) + if not row then return end + local handler = row:GetScript("OnClick") + if handler then pcall(handler, row) end + end + + function msui.ensureMapFilterEnabled() + EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} + local dd = searchFrame.filterDropdown + if not (dd and dd.checkRows and dd.checkRows.map) then + EasyFind.db.uiSearchFilters.map = true + return + end + local row = dd.checkRows.map + -- If the row isn't checked, flip the checkbox and fire the + -- real OnClick so LayoutDropdown runs and the Local/Global + -- sub-rows become visible in the dropdown. + if not row:GetChecked() then + row:SetChecked(true) + msui.fireCheckRowClick(row) + end + EasyFind.db.uiSearchFilters.map = true + if row.updateMapToggle then row.updateMapToggle() end + end + + -- Returns the map row's Local or Global sub-row. isLocal = true + -- for Local, false for Global. + function msui.getMapSubRow(isLocal) + local dd = searchFrame.filterDropdown + local mr = dd and dd.checkRows and dd.checkRows.map + if not mr or not mr.mapSubRows then return nil end + for _, sr in ipairs(mr.mapSubRows) do + if sr.isLocalKey == isLocal then return sr end + end + return nil + end + + function msui.setMapSubLocal(isLocal) + EasyFind.db.uiMapSearchLocal = isLocal + local dd = searchFrame.filterDropdown + local mr = dd and dd.checkRows and dd.checkRows.map + if mr and mr.mapSubRows then + for _, sr in ipairs(mr.mapSubRows) do + sr:SetChecked(sr.isLocalKey == isLocal) + end + end + end + + -- Reusable helpers for the mapSearchUI run steps. + function msui.stepFilterBtnClick(done) + local fb = searchFrame.filterBtn + if not fb then done(); return end + moveCursorTo(fb, CURSOR_MOVE, function() + clickAnim(function() safeAfter(STEP_PAUSE, done) end) + end) + end + + -- Merged "focus the search bar + start typing" step. Closes the + -- filter dropdown (simulating auto-close on focus), moves the + -- cursor to the search bar, clicks, shows the blink cursor, + -- then types the given text. Used by both Local and Global + -- search sections so focus + typing live in a single user- + -- meaningful step instead of three awkward ones. + function msui.stepFocusAndType(text, done) + msui.closeFilterDropdown() + moveCursorTo(searchFrame.editBox, CURSOR_MOVE, function() + clickAnim(function() + if searchFrame.editBox.placeholder then + searchFrame.editBox.placeholder:Hide() + end + startBlinkCursor() + safeAfter(STEP_PAUSE, function() + typeText(text, TYPE_DELAY, function() + safeAfter(SETTLE_PAUSE, done) + end) + end) + end) + end) + end + + -- Combined filter steps: open filter → enable Map Search → confirm + -- Local, all chained as a single demo step. + function msui.enableLocalFilter(done) + msui.stepFilterBtnClick(function() + msui.openFilterDropdown() + safeAfter(STEP_PAUSE, function() + msui.ensureMapFilterEnabled() + local localRow = msui.getMapSubRow(true) + if localRow then + moveCursorTo(localRow, CURSOR_MOVE, function() + if EasyFind.db.uiMapSearchLocal == false then + clickAnim(function() + msui.setMapSubLocal(true) + safeAfter(STEP_PAUSE, done) + end) + else + safeAfter(0.6, done) + end + end) + else + msui.setMapSubLocal(true) + safeAfter(0.5, done) + end + end) + end) + end + + -- Combined filter steps: open filter → switch to Global. + function msui.switchToGlobalFilter(done) + msui.stepFilterBtnClick(function() + msui.openFilterDropdown() + safeAfter(STEP_PAUSE, function() + msui.ensureMapFilterEnabled() + local globalRow = msui.getMapSubRow(false) + if globalRow then + moveCursorTo(globalRow, CURSOR_MOVE, function() + clickAnim(function() + msui.setMapSubLocal(false) + safeAfter(STEP_PAUSE, done) + end) + end) + else + msui.setMapSubLocal(false) + safeAfter(0.5, done) + end + end) + end) + end + + -- Find a visible UI search result whose name contains the given + -- substring (case-insensitive). Falls back to the first visible + -- non-header row if no match (so the demo still has something + -- to click in zones missing the expected POI type). + function msui.findResultRowByPartialName(substring) + local lowSub = substring and substring ~= "" and slower(substring) or nil + local first + for i = 1, #resultButtons do + local row = resultButtons[i] + if row and row:IsShown() and row.data and row.data.name + and not row.data.isPinHeader and not row.data.isHeader then + if lowSub and sfind(slower(row.data.name), lowSub) then + return row + end + if not first then first = row end + end + end + return first + end + + -- After HandleUISearchClick has started a guide in standard mode, + -- the EasyFind highlighter draws on QuestLogMicroButton. Wait + -- briefly for the highlight to appear, then click the button to + -- open the world map. The pending navigation registered by + -- HandleUISearchClick fires when WorldMapFrame becomes visible. + function msui.clickQuestLogMicroBtn(done) + local btn = _G["QuestLogMicroButton"] + if not btn then done(); return end + safeAfter(0.6, function() + moveCursorTo(btn, CURSOR_MOVE, function() + clickAnim(function() + if btn.Click then pcall(btn.Click, btn) end + -- Wait for map fade-in + pending navigation + + -- pin placement. + safeAfter(1.6, done) + end) + end) + end) + end + + -- Click the EasyFindLocationPin to set a SuperTrack waypoint + -- on it (the pin's OnMouseUp handler does this for left-click + -- when the player is on the same map). Also fires the OnMouseUp + -- script directly so the click registers even though the demo's + -- click blocker eats real mouse events. + function msui.clickLocalPin(done) + local pin = _G["EasyFindLocationPin"] + if not pin or not pin:IsShown() then + safeAfter(0.5, done) + return + end + moveCursorTo(pin, CURSOR_MOVE, function() + clickAnim(function() + local handler = pin:GetScript("OnMouseUp") + if handler then pcall(handler, pin, "LeftButton") end + safeAfter(SETTLE_PAUSE, done) + end) + end) + end + + local mapSearchUI_fastRun = { + ---------------------------------------------------------------- + -- SECTION 1: Local Map POI search (flight master) + ---------------------------------------------------------------- + -- 1: Make sure Fast Mode is enabled + function(done) hoverModeButton(done) end, + -- 2: Enable Local Map Search filter + function(done) msui.enableLocalFilter(done) end, + -- 3: Type "fli" + function(done) msui.stepFocusAndType("fli", done) end, + -- 6: Click the Flight Master result. Fast Mode places the + -- waypoint directly (no map opens). Minimap hint, then + -- transition to Global section. + function(done) + local target = msui.findResultRowByPartialName("flight master") + if not target then + safeAfter(0.5, done) + return + end + moveCursorTo(target, CURSOR_MOVE, function() + clickAnim(function() + stopBlinkCursor() + if target.data and ns.MapSearch and ns.MapSearch.HandleUISearchClick then + pcall(ns.MapSearch.HandleUISearchClick, ns.MapSearch, target.data) + end + minimapCallout:SetText("Notice how your target is now automatically tracked!") + safeAfter(SETTLE_PAUSE, function() + showMinimapHint(function() + minimapCallout:Hide() + minimapArrow:Hide() + clearDemoWaypoint() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if searchFrame.editBox.placeholder then + searchFrame.editBox.placeholder:Show() + end + safeAfter(1.0, function() + transitionFS:SetText("Now let's try a global zone search to find Eastern Plaguelands.") + transitionText:Show() + beginSectionTransition(2) + safeAfter(3.5, done) + end) + end) + end) + end) + end) + end, + ---------------------------------------------------------------- + -- SECTION 2: Global zone search (Eastern Plaguelands) + ---------------------------------------------------------------- + -- 5: Enable Global Map Search filter + function(done) + transitionText:Hide() + msui.switchToGlobalFilter(done) + end, + -- 6: Type "eas" + function(done) msui.stepFocusAndType("eas", done) end, + -- 10: Click the Eastern Plaguelands result. Strict name + -- match: no fallback to first result. Fast Mode with a + -- global zone result opens the map and navigates to the + -- zone. + function(done) + local target = msui.findResultRowByPartialName("eastern plaguelands") + if not target then + safeAfter(0.5, done) + return + end + moveCursorTo(target, CURSOR_MOVE, function() + clickAnim(function() + stopBlinkCursor() + if target.data and ns.MapSearch and ns.MapSearch.HandleUISearchClick then + pcall(ns.MapSearch.HandleUISearchClick, ns.MapSearch, target.data) + end + safeAfter(SETTLE_PAUSE + 1.5, function() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if searchFrame.editBox.placeholder then + searchFrame.editBox.placeholder:Show() + end + local closeBtn = getMapCloseBtn() + if closeBtn and closeBtn:IsShown() then + moveCursorTo(closeBtn, CURSOR_MOVE, function() + clickAnim(function() + resetMapSearchState() + closeWorldMap() + safeAfter(0.5, function() + cursor:Hide() + done() + end) + end) + end) + else + resetMapSearchState() + closeWorldMap() + safeAfter(0.5, function() + cursor:Hide() + done() + end) + end + end) + end) + end) + end, + } + + -- Common "directOpen + map filter enabled, local/global" baseline + -- every mapSearchUI setupAfter builds on. Reads demoModeFast so + -- the same baseline reuses across both fast and guide variants. + function msui.baseline(isLocal) + EasyFind.db.directOpen = (demoModeFast["mapSearchUI"] ~= false) + if ns.UpdateModeButtonVisual then + pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) + end + msui.ensureMapFilterEnabled() + msui.setMapSubLocal(isLocal) + end + + local mapSearchUI_fastSetupAfter = { + -- 1: Fast mode enabled + function() msui.baseline(true); msui.closeFilterDropdown() end, + -- 2: Local filter enabled + function() msui.baseline(true); msui.closeFilterDropdown() end, + -- 3: "fli" typed + function() + msui.baseline(true); msui.closeFilterDropdown() + searchFrame.editBox:SetText("fli") + if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Hide() end + UI:OnSearchTextChanged("fli") + startBlinkCursor() + end, + -- 4: Flight master clicked, waypoint placed + function() + msui.baseline(true); msui.closeFilterDropdown() + clearDemoWaypoint() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end + end, + -- 5: Global filter enabled + function() msui.baseline(false); msui.closeFilterDropdown() end, + -- 6: "eas" typed + function() + msui.baseline(false); msui.closeFilterDropdown() + searchFrame.editBox:SetText("eas") + if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Hide() end + UI:OnSearchTextChanged("eas") + startBlinkCursor() + end, + -- 7: Eastern Plaguelands clicked, map closed + function() + msui.baseline(false); msui.closeFilterDropdown() + clearDemoWaypoint() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end + resetMapSearchState(); closeWorldMap() + end, + } + + local mapSearchUI_fastStepDefs = { + { text = "Make sure Fast Mode is enabled", section = 1 }, -- 1 + { text = "Enable Local Map Search filter", section = 1 }, -- 2 + { text = 'Start typing "Flight Master"', section = 1 }, -- 3 + { text = "Click the Flight Master result", section = 1 }, -- 4 + { text = "Enable Global Map Search filter", section = 2 }, -- 5 + { text = 'Start typing "Eastern Plaguelands"', section = 2 }, -- 6 + { text = "Click the Eastern Plaguelands result", section = 2 }, -- 7 + } + local mapSearchUI_fastSections = { + { header = "Local Map POI search: Flight Master", section = 1, firstStep = 1, lastStep = 4 }, + { header = "Global zone search: Eastern Plaguelands", section = 2, firstStep = 5, lastStep = 7 }, + } + + -- Guide-mode variant: clicking a result triggers HandleUISearchClick's + -- guide branch (StartGuide + SetPendingNavigation), so the demo + -- has additional steps to walk through the guide: click the + -- highlighted QuestLogMicroButton, follow any breadcrumbs, then + -- click the destination pin to track. + local mapSearchUI_guideStepDefs = { + { text = "Make sure Guide Mode is enabled", section = 1 }, -- 1 + { text = "Enable Local Map Search filter", section = 1 }, -- 2 + { text = 'Start typing "Flight Master"', section = 1 }, -- 3 + { text = "Click the Flight Master result", section = 1 }, -- 4 + { text = "Click the highlighted Quest Log button", section = 1 }, -- 5 + { text = "Click the flight master pin to track", section = 1 }, -- 6 + { text = "Enable Global Map Search filter", section = 2 }, -- 7 + { text = 'Start typing "Eastern Plaguelands"', section = 2 }, -- 8 + { text = "Click the Eastern Plaguelands result", section = 2 }, -- 9 + { text = "Click the highlighted Quest Log button", section = 2 }, -- 10 + { text = "Follow the breadcrumbs to the zone", section = 2 }, -- 11 + } + local mapSearchUI_guideSections = { + { header = "Local Map POI search: Flight Master", section = 1, firstStep = 1, lastStep = 6 }, + { header = "Global zone search: Eastern Plaguelands", section = 2, firstStep = 7, lastStep = 11 }, + } + + local mapSearchUI_guideRun = { + -- 1: Switch to Guide Mode + function(done) hoverModeButtonToGuide(done) end, + -- 2: Enable Local Map Search filter + function(done) msui.enableLocalFilter(done) end, + -- 3: Type "fli" + function(done) msui.stepFocusAndType("fli", done) end, + -- 6: Click result (HandleUISearchClick → guide starts) + function(done) + local target = msui.findResultRowByPartialName("flight master") + if not target then + safeAfter(0.5, done) + return + end + moveCursorTo(target, CURSOR_MOVE, function() + clickAnim(function() + stopBlinkCursor() + if target.data and ns.MapSearch and ns.MapSearch.HandleUISearchClick then + pcall(ns.MapSearch.HandleUISearchClick, ns.MapSearch, target.data) + end + safeAfter(SETTLE_PAUSE, done) + end) + end) + end, + -- 7: Click highlighted Quest Log button → opens map + function(done) msui.clickQuestLogMicroBtn(done) end, + -- 8: Click pin to track → minimap hint → transition + function(done) + msui.clickLocalPin(function() + minimapCallout:SetText("Check your minimap: your target is now tracked!") + showMinimapHint(function() + minimapCallout:Hide() + minimapArrow:Hide() + clearDemoWaypoint() + local closeBtn = getMapCloseBtn() + local function finish() + resetMapSearchState() + closeWorldMap() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if searchFrame.editBox.placeholder then + searchFrame.editBox.placeholder:Show() + end + safeAfter(0.8, function() + transitionFS:SetText("Now let's try a global zone search to find Eastern Plaguelands.") + transitionText:Show() + beginSectionTransition(2) + safeAfter(3.5, done) + end) + end + if closeBtn and closeBtn:IsShown() then + moveCursorTo(closeBtn, CURSOR_MOVE, function() + clickAnim(finish) + end) + else + finish() + end + end) + end) + end, + -- 7: Enable Global Map Search filter + function(done) + transitionText:Hide() + msui.switchToGlobalFilter(done) + end, + -- 8: Type "eas" + function(done) msui.stepFocusAndType("eas", done) end, + -- 12: Click result (guide starts) + function(done) + local target = msui.findResultRowByPartialName("eastern plaguelands") + if not target then + safeAfter(0.5, done) + return + end + moveCursorTo(target, CURSOR_MOVE, function() + clickAnim(function() + stopBlinkCursor() + if target.data and ns.MapSearch and ns.MapSearch.HandleUISearchClick then + pcall(ns.MapSearch.HandleUISearchClick, ns.MapSearch, target.data) + end + safeAfter(SETTLE_PAUSE, done) + end) + end) + end, + -- 13: Click highlighted Quest Log button → opens map + function(done) msui.clickQuestLogMicroBtn(done) end, + -- 14: Follow breadcrumbs to the zone, then close the map + function(done) + clickBreadcrumbsUntilArrived(function() + safeAfter(1.5, function() + local closeBtn = getMapCloseBtn() + local function finish() + resetMapSearchState() + closeWorldMap() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if searchFrame.editBox.placeholder then + searchFrame.editBox.placeholder:Show() + end + safeAfter(0.5, function() + cursor:Hide() + done() + end) + end + if closeBtn and closeBtn:IsShown() then + moveCursorTo(closeBtn, CURSOR_MOVE, function() + clickAnim(finish) + end) + else + finish() + end + end) + end) + end, + } + + local mapSearchUI_guideSetupAfter = { + -- 1: Guide mode enabled + function() msui.baseline(true); msui.closeFilterDropdown() end, + -- 2: Local filter enabled + function() msui.baseline(true); msui.closeFilterDropdown() end, + -- 3: "fli" typed + function() + msui.baseline(true); msui.closeFilterDropdown() + searchFrame.editBox:SetText("fli") + if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Hide() end + UI:OnSearchTextChanged("fli") + startBlinkCursor() + end, + -- 4: Result clicked, guide started + function() + msui.baseline(true); msui.closeFilterDropdown() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if searchFrame.editBox.placeholder then + searchFrame.editBox.placeholder:Show() + end + end, + -- 5: QuestLogMicroButton clicked, map open + function() msui.baseline(true); msui.closeFilterDropdown() end, + -- 6: Pin clicked, tracked, map closed (section 1 end) + function() + msui.baseline(true); msui.closeFilterDropdown() + clearDemoWaypoint(); resetMapSearchState(); closeWorldMap() + end, + -- 7: Global filter enabled + function() msui.baseline(false); msui.closeFilterDropdown() end, + -- 8: "eas" typed + function() + msui.baseline(false); msui.closeFilterDropdown() + searchFrame.editBox:SetText("eas") + if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Hide() end + UI:OnSearchTextChanged("eas") + startBlinkCursor() + end, + -- 9: Result clicked, guide started + function() + msui.baseline(false); msui.closeFilterDropdown() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end + end, + -- 10: QuestLogMicroButton clicked, map open + function() msui.baseline(false); msui.closeFilterDropdown() end, + -- 11: Breadcrumbs followed, map closed (final) + function() + msui.baseline(false); msui.closeFilterDropdown() + resetMapSearchState(); closeWorldMap() + end, + } + + DEMOS.mapSearchUI.rebuild = function(def) + savedDirectOpen = EasyFind.db.directOpen + local isFast = demoModeFast["mapSearchUI"] ~= false + if isFast then + def.stepDefs = mapSearchUI_fastStepDefs + def.sections = mapSearchUI_fastSections + def.run = mapSearchUI_fastRun + def.setupAfter = mapSearchUI_fastSetupAfter + else + def.stepDefs = mapSearchUI_guideStepDefs + def.sections = mapSearchUI_guideSections + def.run = mapSearchUI_guideRun + def.setupAfter = mapSearchUI_guideSetupAfter + end + end + + -------------------------------------------------------------------- + -- Appearance Sets demo + -------------------------------------------------------------------- + local asd = {} + + local CLASS_ARMOR = { + [1]=4,[2]=4,[6]=4, -- Plate + [3]=3,[7]=3,[13]=3, -- Mail + [4]=2,[10]=2,[11]=2,[12]=2, -- Leather + [5]=1,[8]=1,[9]=1, -- Cloth + } + + function asd.findSetEntry() + if not ns.Database or not ns.Database.uiSearchData then return nil end + local GetBaseSetID = C_TransmogSets and C_TransmogSets.GetBaseSetID + local GetSetInfo = C_TransmogSets and C_TransmogSets.GetSetInfo + for _, e in ipairs(ns.Database.uiSearchData) do + if e.transmogSetID then + -- Only pick base PvE sets; variants can't be scrolled + -- to, and PvP sets have faction/navigation issues + if not GetBaseSetID or GetBaseSetID(e.transmogSetID) == e.transmogSetID then + local info = GetSetInfo and GetSetInfo(e.transmogSetID) + local label = info and info.label and slower(info.label) or "" + local isPvP = sfind(label, "pvp") or sfind(label, "season") + or sfind(label, "gladiator") or sfind(label, "aspirant") + or sfind(label, "combatant") + if not isPvP then + return e + end + end + end + end + return nil + end + + -- Cached entries so every step in the same section clicks + -- the exact same set instead of re-querying the database + -- (which may return a different entry after filter changes). + asd.playerSetEntry = nil + asd.altSetEntry = nil + + -- "+ Ctrl" label that floats to the right of the cursor during + -- Ctrl+Click steps. Same gold font as the floating callout text + -- so it reads as part of the demo narration, not a UI element. + local ctrlBadge = CreateFrame("Frame", nil, UIParent) + ctrlBadge:SetSize(80, 20) + ctrlBadge:SetFrameStrata("TOOLTIP") + ctrlBadge:SetFrameLevel(10000) + local ctrlBadgeFS = ctrlBadge:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + ctrlBadgeFS:SetPoint("LEFT") + ctrlBadgeFS:SetText("+ Ctrl") + ctrlBadgeFS:SetTextColor(1.0, 0.82, 0.0) + ctrlBadgeFS:SetShadowColor(0, 0, 0, 1) + ctrlBadgeFS:SetShadowOffset(2, -2) + ctrlBadge:Hide() + + function asd.showCtrlBadge() + ctrlBadge:ClearAllPoints() + ctrlBadge:SetPoint("LEFT", cursor, "RIGHT", 4, 0) + ctrlBadge:Show() + end + + function asd.enableFilter(done) + local dd = searchFrame.filterDropdown + local asRow = dd and dd.checkRows and dd.checkRows.appearanceSets + msui.stepFilterBtnClick(function() + msui.openFilterDropdown() + safeAfter(STEP_PAUSE, function() + if not asRow then + EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} + EasyFind.db.uiSearchFilters.appearanceSets = true + safeAfter(0.3, done) + return + end + moveCursorTo(asRow, CURSOR_MOVE, function() + if not asRow:GetChecked() then + clickAnim(function() + asRow:SetChecked(true) + EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} + EasyFind.db.uiSearchFilters.appearanceSets = true + local h = asRow:GetScript("OnClick") + if h then pcall(h, asRow) end + safeAfter(STEP_PAUSE, done) + end) + else + safeAfter(0.8, done) + end + end) + end) + end) + end + + function asd.typeQuery(query, done) + moveCursorTo(searchFrame.editBox, CURSOR_MOVE, function() + clickAnim(function() + msui.closeFilterDropdown() + if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Hide() end + startBlinkCursor() + safeAfter(STEP_PAUSE, function() + typeText(query, TYPE_DELAY, function() + safeAfter(SETTLE_PAUSE, done) + end) + end) + end) + end) + end + + -- Animate cursor to a frame's close button and click it. + local function closeFrameAnimated(frame, afterClose) + if not frame or not frame:IsShown() then + if afterClose then afterClose() end + return + end + local closeBtn = frame.CloseButton + or (frame.BorderFrame and frame.BorderFrame.CloseButton) + if closeBtn and closeBtn:IsShown() then + moveCursorTo(closeBtn, CURSOR_MOVE, function() + clickAnim(function() + pcall(HideUIPanel, frame) + safeAfter(0.4, afterClose) + end) + end) + else + pcall(HideUIPanel, frame) + safeAfter(0.4, afterClose) + end + end + + function asd.ctrlClickSet(calloutText, done) + local row = findResultRowByName(nil) or findFirstResultRow() + if not row or not row.data or not row.data.transmogSetID then done(); return end + asd.showCtrlBadge() + moveCursorTo(row, CURSOR_MOVE, function() + clickAnim(function() + stopBlinkCursor() + mapSearchCallout:SetText(calloutText) + mapSearchCallout:Show() + if UI.DressUpAppearanceSet then + pcall(UI.DressUpAppearanceSet, UI, row.data.transmogSetID) + end + safeAfter(2.5, function() + ctrlBadge:Hide() + mapSearchCallout:Hide() + closeFrameAnimated(_G["DressUpFrame"], function() + asd.clearSearch() + safeAfter(0.3, done) + end) + end) + end) + end) + end + + function asd.regularClickSet(calloutText, useFast, done) + local row = findResultRowByName(nil) or findFirstResultRow() + if not row or not row.data or not row.data.transmogSetID then done(); return end + local clickedData = row.data + moveCursorTo(row, CURSOR_MOVE, function() + clickAnim(function() + stopBlinkCursor() + mapSearchCallout:SetText(calloutText) + mapSearchCallout:Show() + local guideData = { + name = clickedData.name, + steps = { + { buttonFrame = "CollectionsMicroButton" }, + { waitForFrame = "CollectionsJournal", tabIndex = 5 }, + { waitForFrame = "WardrobeCollectionFrame", wardrobeSetsTab = true }, + { waitForFrame = "WardrobeCollectionFrame", + transmogSetID = clickedData.transmogSetID, + transmogSetName = clickedData.name }, + }, + } + if useFast then + UI:DirectOpen(guideData) + else + EasyFind:StartGuide(guideData) + end + safeAfter(3.0, function() + mapSearchCallout:Hide() + if ns.Highlight and ns.Highlight.ClearAll then + pcall(ns.Highlight.ClearAll, ns.Highlight) + end + closeFrameAnimated(_G["CollectionsJournal"], function() + asd.clearSearch() + safeAfter(0.3, done) + end) + end) + end) + end) + end + + function asd.switchClassFilter(targetClassID, done) + msui.stepFilterBtnClick(function() + msui.openFilterDropdown() + safeAfter(STEP_PAUSE, function() + local dd = searchFrame.filterDropdown + local asRow = dd and dd.checkRows and dd.checkRows.appearanceSets + local csRow = asRow and asRow.asClassSelectRow + if not csRow then + EasyFind.db.appearanceSetClass = { classID = targetClassID } + if ns.Database and ns.Database.PopulateDynamicTransmogSets then + ns.Database:PopulateDynamicTransmogSets() + end + msui.closeFilterDropdown() + done() + return + end + moveCursorTo(csRow, CURSOR_MOVE, function() + clickAnim(function() + -- Fire the row's real OnClick which calls + -- LayoutClassPopup + positions + shows the popup. + local handler = csRow:GetScript("OnClick") + if handler then pcall(handler, csRow) end + local popup = _G["EasyFindAsClassPopup"] + safeAfter(0.5, function() + local found + if popup then + for _, child in ipairs({popup:GetChildren()}) do + if child._classID == targetClassID then + found = child; break + end + end + end + if found then + moveCursorTo(found, CURSOR_MOVE, function() + clickAnim(function() + local h = found:GetScript("OnClick") + if h then pcall(h, found) end + safeAfter(0.8, function() + msui.closeFilterDropdown() + done() + end) + end) + end) + else + EasyFind.db.appearanceSetClass = { classID = targetClassID } + if ns.Database and ns.Database.PopulateDynamicTransmogSets then + ns.Database:PopulateDynamicTransmogSets() + end + if popup then popup:Hide() end + msui.closeFilterDropdown() + done() + end + end) + end) + end) + end) + end) + end + + function asd.clearSearch() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end + end + + function asd.cleanupAll() + ctrlBadge:Hide() + mapSearchCallout:Hide() + local duf = _G["DressUpFrame"] + if duf and duf:IsShown() then pcall(HideUIPanel, duf) end + local cj = _G["CollectionsJournal"] + if cj and cj:IsShown() then pcall(HideUIPanel, cj) end + if ns.Highlight and ns.Highlight.ClearAll then + pcall(ns.Highlight.ClearAll, ns.Highlight) + end + asd.clearSearch() + end + + DEMOS.appearanceSets.supportsModeToggle = true + + DEMOS.appearanceSets.rebuild = function(def) + local _, _, playerClassID = UnitClass("player") + local myArmor = CLASS_ARMOR[playerClassID] + local altClassID + for cid, armor in pairs(CLASS_ARMOR) do + if armor ~= myArmor then altClassID = cid; break end + end + local altClassName = altClassID and (GetClassInfo(altClassID)) or "another class" + local isFast = demoModeFast["appearanceSets"] ~= false + + def.sections = { + { header = "Preview on your character", section = 1, firstStep = 1, lastStep = 3 }, + { header = isFast and "Open in Collections" + or "Guide to Collections", section = 2, firstStep = 4, lastStep = 5 }, + { header = "Try " .. altClassName .. "'s armor", section = 3, firstStep = 6, lastStep = 8 }, + } + def.stepDefs = { + { text = "Enable Appearance Sets search", section = 1 }, + { text = "Start typing an appearance set name", section = 1 }, + { text = "Ctrl+Click to preview on your character", section = 1 }, + { text = "Search the same set again", section = 2 }, + { text = isFast and "Click to open it in Collections" + or "Click to start the guide", section = 2 }, + { text = "Switch class filter to " .. altClassName, section = 3 }, + { text = "Search a " .. altClassName .. " appearance set", section = 3 }, + { text = "Ctrl+Click to preview on your character", section = 3 }, + } + local savedAsClass = EasyFind.db.appearanceSetClass + local savedAsFilters = {} + for _, k in ipairs({"appearanceSetCollected", "appearanceSetNotCollected", "appearanceSetPvE", "appearanceSetPvP"}) do + savedAsFilters[k] = EasyFind.db[k] + end + + def.run = { + -- 1: Enable filter + cache player's set + function(done) + asd.enableFilter(function() + asd.playerSetEntry = asd.findSetEntry() + done() + end) + end, + -- 2: Type set name + function(done) + local e = asd.playerSetEntry + if not e then done(); return end + asd.typeQuery(slower(e.name):sub(1, 4), done) + end, + -- 3: Ctrl+Click → DressUp (opens, shows callout, closes) + function(done) + asd.ctrlClickSet( + "Ctrl+Click previews the set on your character.", done) + end, + -- 4: Type same set again + function(done) + local e = asd.playerSetEntry + if not e then done(); return end + asd.typeQuery(slower(e.name):sub(1, 4), done) + end, + -- 5: Regular click → Collections (opens, shows callout, closes) + function(done) + local fast = demoModeFast["appearanceSets"] ~= false + asd.regularClickSet( + fast and "A regular click opens the set in Collections." + or "A regular click starts the step-by-step guide.", + fast, done) + end, + -- 6: Switch class filter + cache alt set + function(done) + if not altClassID then done(); return end + asd.switchClassFilter(altClassID, function() + asd.altSetEntry = asd.findSetEntry() + done() + end) + end, + -- 7: Type different class set + function(done) + local e = asd.altSetEntry + if not e then done(); return end + asd.typeQuery(slower(e.name):sub(1, 4), done) + end, + -- 8: Ctrl+Click → DressUp (different armor, then restore) + function(done) + asd.ctrlClickSet( + "You can preview any class's armor on your character.", + function() + EasyFind.db.appearanceSetClass = savedAsClass + for k, v in pairs(savedAsFilters) do EasyFind.db[k] = v end + if ns.Database and ns.Database.PopulateDynamicTransmogSets then + ns.Database:PopulateDynamicTransmogSets() + end + cursor:Hide() + done() + end) + end, + } + + local function snap(classOverride) + asd.cleanupAll() + EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} + EasyFind.db.uiSearchFilters.appearanceSets = true + if classOverride then + EasyFind.db.appearanceSetClass = classOverride + else + EasyFind.db.appearanceSetClass = savedAsClass + end + if ns.Database and ns.Database.PopulateDynamicTransmogSets then + ns.Database:PopulateDynamicTransmogSets() + end + end + def.setupAfter = { + function() snap() end, -- 1 + function() snap() end, -- 2 + function() snap() end, -- 3 + function() snap() end, -- 4 + function() snap() end, -- 5 + function() snap(altClassID and { classID = altClassID } or nil) end, -- 6 + function() snap(altClassID and { classID = altClassID } or nil) end, -- 7 + function() -- 8 + snap() + EasyFind.db.appearanceSetClass = savedAsClass + for k, v in pairs(savedAsFilters) do EasyFind.db[k] = v end + if ns.Database and ns.Database.PopulateDynamicTransmogSets then + ns.Database:PopulateDynamicTransmogSets() + end + end, + } + end + + -------------------------------------------------------------------- + -- Demo state machine. Transport controls: Prev / Play-Pause / Next. + -- State vars completedUpTo and animatingIdx are already declared + -- above next to refreshStepList. + -------------------------------------------------------------------- + local autoPlay = false + + -------------------------------------------------------------------- + -- Click-blocker lock system. While the demo is actively running + -- (animatingIdx > 0 or autoPlay && !paused), every interactive + -- frame the demo touches gets a transparent click-blocker child + -- frame on top that eats real mouse events. Programmatic clicks + -- (tab:Click(), UI:SelectResult(), etc.) bypass the blocker so + -- the demo can still drive the UI internally. The drag handler + -- and editbox keyboard are also disabled during run. + -- + -- The moment the demo is paused, idle (between manual steps), + -- or stopped, every blocker is released and the player gets + -- full access back, exactly as the user requested. + -------------------------------------------------------------------- + local clickBlockers = {} + local function lockFrame(f, topPad) + if not f or clickBlockers[f] then return end + topPad = topPad or 8 + local blocker = CreateFrame("Frame", nil, f) + blocker:SetPoint("TOPLEFT", f, "TOPLEFT", -8, topPad) + blocker:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 8, -8) + blocker:SetFrameStrata(f:GetFrameStrata()) + blocker:SetFrameLevel(1000) + blocker:EnableMouse(true) + -- Also eat scroll wheel events so the player can't scroll + -- the underlying frame's contents while a step is running. + -- Mouse wheel needs both EnableMouseWheel and an explicit + -- OnMouseWheel handler; without the handler the event + -- propagates to the parent. + blocker:EnableMouseWheel(true) + blocker:SetScript("OnMouseWheel", function() end) + blocker:Show() + clickBlockers[f] = blocker + end + local function unlockAllFrames() + for f, blocker in pairs(clickBlockers) do + blocker:EnableMouse(false) + blocker:EnableMouseWheel(false) + blocker:SetScript("OnMouseWheel", nil) + blocker:Hide() + blocker:SetParent(nil) + clickBlockers[f] = nil + end + end + -- Lock the map search bars (local + global) while the demo is + -- running so the real user can't click/focus them or type into + -- them. Clicks are eaten by the lockFrame blocker, EnableMouse + -- on the editbox is a belt-and-suspenders, and ClearFocus makes + -- sure the editbox doesn't hold pre-existing keyboard focus. + local function lockMapSearchEditBoxes() + for _, frameName in ipairs({ "EasyFindMapSearchFrame", "EasyFindMapGlobalSearchFrame" }) do + local lsf = _G[frameName] + local eb = lsf and lsf.editBox + if eb then + lockFrame(lsf) + eb:EnableMouse(false) + eb:ClearFocus() + end + end + end + local function unlockMapSearchEditBoxes() + for _, frameName in ipairs({ "EasyFindMapSearchFrame", "EasyFindMapGlobalSearchFrame" }) do + local lsf = _G[frameName] + local eb = lsf and lsf.editBox + if eb then + eb:EnableMouse(true) + end + end + end + + local function applyRunningLocks() + lockFrame(searchFrame) + lockFrame(resultsFrame) + -- Demo-specific frames (PlayerSpellsFrame, WorldMapFrame, + -- etc.) need a bigger top pad because frame tabs anchor + -- just outside the parent's bounds. + local def = DEMOS[currentDemoKey] + if def and def.lockFrames then + for _, name in ipairs(def.lockFrames) do + lockFrame(_G[name], 32) + end + end + searchFrame:SetScript("OnDragStart", nil) + searchFrame.setupMode = true + searchFrame.editBox:EnableMouse(false) + lockMapSearchEditBoxes() + end + local function releaseRunningLocks() + unlockAllFrames() + searchFrame:SetScript("OnDragStart", savedDragStart) + searchFrame.setupMode = nil + searchFrame.editBox:EnableMouse(true) + unlockMapSearchEditBoxes() + end + updateLockState = function() + if not active then releaseRunningLocks(); return end + local isRunning = (not paused) and (animatingIdx > 0 or autoPlay) + if isRunning then + applyRunningLocks() + else + releaseRunningLocks() + end + end + + local function updateButtons() + -- Empty demo (no animation steps yet): disable all transport + -- buttons and show the play icon in its idle state. + if not demoSteps or #demoSteps == 0 then + prevBtn:Disable() + nextBtn:Disable() + sectPrevBtn:Disable() + sectNextBtn:Disable() + playBtn:Show() + playBtn:Disable() + playBtn.icon:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") + playBtn.icon:SetTexCoord(0, 1, 0, 1) + playBtn.icon:SetSize(11, 11) + replayBtn:Hide() + updateLockState() + return + end + local animating = animatingIdx > 0 + local atEnd = completedUpTo >= #demoSteps and not animating + -- Prev: disabled only at the very start (no step has run yet + -- and nothing is animating). Otherwise always clickable, so the + -- player can skip backward even mid-animation. + if completedUpTo < 1 and not animating then + prevBtn:Disable() + sectPrevBtn:Disable() + else + prevBtn:Enable() + sectPrevBtn:Enable() + end + -- Next: disabled only when there's no further step to run and + -- nothing is currently animating. + if atEnd then + nextBtn:Disable() + sectNextBtn:Disable() + else + nextBtn:Enable() + sectNextBtn:Enable() + end + -- At the end of the demo, swap the Play button out for the + -- Replay button (in the same middle position). Otherwise the + -- Play button is visible with its play/pause icon. + playBtn:SetShown(not atEnd) + playBtn:Enable() + replayBtn:SetShown(atEnd) + if animating or autoPlay then + replayBtn:Disable() + else + replayBtn:Enable() + end + -- The play icon shows "pause" only while actively running (the + -- next click will pause). When idle, paused, or stepping + -- manually it shows "play" (the next click will resume/start). + if autoPlay and not paused then + playBtn.icon:SetTexture("Interface\\AddOns\\EasyFind\\demo-pause") + playBtn.icon:SetTexCoord(0, 1, 0, 1) + playBtn.icon:SetSize(11, 11) + else + playBtn.icon:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") + playBtn.icon:SetTexCoord(0, 1, 0, 1) + playBtn.icon:SetSize(11, 11) + end + updateLockState() + end + + local runStep -- forward decl + local resetDemoGameState -- forward decl, defined below cancelInFlight + runStep = function(i) + if not active then return end + highlightOverride = nil + if i > #demoSteps then + -- Demo finished: stop autoplay, clean up the cursor + + -- any frames the demo opened, and revert the user's + -- pre-demo settings (Fast/Guide mode, fade, etc.) + -- so the demo doesn't leave their preferences mutated. + autoPlay = false + cursor:Hide() + transitionText:Hide() + if resetDemoGameState then resetDemoGameState() end + restoreUserSettings() + updateButtons() + return + end + if i < 1 then i = 1 end + -- Starting a real step clears any section-transition + -- highlight left over from the previous step's action so + -- the new step can take focus. + pendingSectionHighlight = nil + animatingIdx = i + refreshStepList() + updateButtons() + local myGen = stepGen + -- PRE-ACT pause: highlight just moved to step i (and the + -- list scrolled). Wait long enough for the user to read + -- the step label before the cursor starts moving. + safeAfter(PRE_ACT_GAP, function() + if not active or myGen ~= stepGen then return end + demoSteps[i](function() + if not active or myGen ~= stepGen then return end + -- Snapshot the cursor's final position for this + -- step so later jumpToStep/jumpToBeforeStep calls + -- can restore it to the exact same spot instead + -- of letting moveCursorTo fall back to its + -- default middle-right starting position. + if cursor:IsShown() and cursor:GetLeft() then + cursorEndPos[i] = { + x = cursor:GetLeft() + 4, + y = cursor:GetTop() - 4, + shown = true, + } + else + cursorEndPos[i] = { shown = false } + end + -- Snapshot whether the step's action already + -- pre-highlighted a section header (via + -- beginSectionTransition during its transition + -- text). If so, the boundary beat below is + -- redundant since the header has been on screen + -- throughout the transition. + local transitionedMidStep = pendingSectionHighlight ~= nil + -- POST-ACT pause: keep animatingIdx = i so the + -- step stays highlighted while the user sees the + -- result of the action. Only after this pause does + -- the highlight advance to the next step. + safeAfter(POST_ACT_GAP, function() + if not active or myGen ~= stepGen then return end + animatingIdx = 0 + if i > completedUpTo then + completedUpTo = i + end + refreshStepList() + -- Final step finished (manual path): settle, + -- clean up, restore user settings. + if completedUpTo >= #demoSteps and not autoPlay then + safeAfter(0.6, function() + if not active then return end + cursor:Hide() + if resetDemoGameState then resetDemoGameState() end + restoreUserSettings() + updateButtons() + end) + end + if autoPlay and active then + updateButtons() + -- Section transition was pre-announced by + -- the step's own action (header shown + -- during transition text). Skip the + -- boundary beat and chain straight to the + -- next step, whose PRE-ACT pause will + -- display the first step of the new + -- section before it acts. + if transitionedMidStep then + runStep(i + 1) + else + -- Normal section boundary: the + -- refreshStepList above highlighted + -- the header via the atBoundary logic + -- (not pendingSectionHighlight). Give + -- the header its own beat before the + -- next step takes over. + local nextIdx = i + 1 + local atBoundary = false + if nextIdx <= #demoSteps and DEMO_SECTIONS then + for _, sect in ipairs(DEMO_SECTIONS) do + if nextIdx == sect.firstStep and sect.header and sect.header ~= "" then + atBoundary = true + break + end + end + end + if atBoundary then + safeAfter(POST_ACT_GAP, function() + if not autoPlay or not active or myGen ~= stepGen then + updateButtons() + return + end + runStep(i + 1) + end) + else + runStep(i + 1) + end + end + else + updateButtons() + end + end) + end) + end) + end + + -- Reset the game state to a clean slate (nothing typed, no frames + -- open, no highlights showing). Used by Previous to rewind from + -- whatever the current state is before applying a step's "after" + -- state. Does NOT touch the demo panel itself or the mock cursor. + -- Forward-declared above so runStep's done callback can call it. + resetDemoGameState = function() + stopBlinkCursor() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if searchFrame.editBox.placeholder then + searchFrame.editBox.placeholder:Show() + end + if searchFrame.filterDropdown and searchFrame.filterDropdown:IsShown() then + searchFrame.filterDropdown:Hide() + end + if ns.Highlight and ns.Highlight.ClearAll then + pcall(ns.Highlight.ClearAll, ns.Highlight) + end + -- Wipe every demo-modified map state so switching demos + -- doesn't leave a prior demo's pin/waypoint/search text + -- bleeding into the next one. Hide the callout and arrow. + if minimapCallout then minimapCallout:Hide() end + if minimapArrow then minimapArrow:Hide() end + if mapSearchCallout then mapSearchCallout:Hide() end + if clearDemoWaypoint then pcall(clearDemoWaypoint) end + if resetMapSearchState then pcall(resetMapSearchState) end + if closeWorldMap then pcall(closeWorldMap) end + if not InCombatLockdown() then + local psf = _G["PlayerSpellsFrame"] + if psf and psf.IsShown and psf:IsShown() then + pcall(HideUIPanel, psf) + end + end + resetPlayerSpellsFrame() + end + + -- Cancel any in-flight step animation. Bumps the generation counter + -- so every async callback captured before this point aborts on its + -- next check, stops the current timer / cursor OnUpdate / blink + -- cursor, and clears stray visuals left by hoverModeButton. + local function cancelInFlight() + stepGen = stepGen + 1 + wipe(pendingTimers) + paused = false + highlightOverride = nil + pendingSectionHighlight = nil + cursor:SetScript("OnUpdate", nil) + cursor:SetSize(36, 36) + -- Hide the fake cursor. The next runStep will re-show it + -- at moveCursorTo's default starting position (mid-right + -- of the screen), so jumping to any step via the step + -- list starts the cursor from a clean spot instead of + -- wherever the previous animation left it. + cursor:Hide() + stopBlinkCursor() + setHoveredRow(nil) + clearButtonHover() + if transitionText then transitionText:Hide() end + if minimapCallout then minimapCallout:Hide() end + if minimapArrow then minimapArrow:Hide() end + if mapSearchCallout then mapSearchCallout:Hide() end + animatingIdx = 0 + end + + -- setupAfterStep[i] puts the game in the end-state of step i, no + -- animation. These are used by Previous to rewind: reset to clean + -- slate, then call setupAfterStep[target] for the new position. + DEMOS.uiSearch.setupAfter = { + -- 1: Fast mode enabled + function() + EasyFind.db.directOpen = true + if ns.UpdateModeButtonVisual then + pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) + end + end, + -- 2: Fast mode + "sp" typed + results showing + function() + EasyFind.db.directOpen = true + if ns.UpdateModeButtonVisual then + pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) + end + searchFrame.editBox:SetText("sp") + UI:OnSearchTextChanged("sp") + startBlinkCursor() + end, + -- 3: Fast mode, Spellbook opened then cleaned up (the run + -- function closes its window and clears the search before + -- calling done), so the end state is just fast mode idle. + function() + EasyFind.db.directOpen = true + if ns.UpdateModeButtonVisual then + pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) + end + end, + -- 4: Guide mode toggled, clean state + function() + EasyFind.db.directOpen = false + if ns.UpdateModeButtonVisual then + pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) + end + end, + -- 5: Guide mode + "sp" typed + results showing + function() + EasyFind.db.directOpen = false + if ns.UpdateModeButtonVisual then + pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) + end + searchFrame.editBox:SetText("sp") + UI:OnSearchTextChanged("sp") + startBlinkCursor() + end, + -- 6: Spellbook result selected in guide mode; Highlight + -- system is now showing its first arrow on the micro + -- button. + function() + EasyFind.db.directOpen = false + if ns.UpdateModeButtonVisual then + pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) + end + UI:SelectResult(spellbookEntry) + end, + -- 7: PlayerSpellsFrame now open, Highlight advances to tab 3 + function() + EasyFind.db.directOpen = false + if ns.UpdateModeButtonVisual then + pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) + end + UI:SelectResult(spellbookEntry) + if not InCombatLockdown() then + pcall(ShowUIPanel, _G["PlayerSpellsFrame"]) + end + end, + -- 8: Spellbook tab (3) selected + function() + EasyFind.db.directOpen = false + if ns.UpdateModeButtonVisual then + pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) + end + UI:SelectResult(spellbookEntry) + if not InCombatLockdown() then + pcall(ShowUIPanel, _G["PlayerSpellsFrame"]) + end + local psf = _G["PlayerSpellsFrame"] + local tab + if psf and psf.TabSystem and psf.TabSystem.tabs then + tab = psf.TabSystem.tabs[3] + end + if not tab then tab = _G["PlayerSpellsFrameTab3"] end + if tab and tab.Click then pcall(tab.Click, tab) end + end, + } + setupAfterStep = DEMOS.uiSearch.setupAfter + + -- Swap the active demo. Cancels any in-flight animation, restores + -- game state, retargets the working refs to the new demo's + -- tables, rewires the title, and rebuilds the step rows. + loadDemo = function(key) + local def = DEMOS[key] + if not def or not active then return end + cancelInFlight() + autoPlay = false + resetDemoGameState() + cursor:Hide() + -- Cursor-end snapshots are per-demo: wipe so the new + -- demo's step indices don't pick up stale positions from + -- the previous demo. + wipe(cursorEndPos) + -- Dynamic demos rebuild their step lists based on current + -- user settings (e.g. mapSearchCurrent adapts to the user's + -- Fast/Guide mode). Run rebuild BEFORE restoreUserSettings + -- so it can resync saved mode values to the live ones, + -- preserving any toggle the user made between demo loads. + if def.rebuild then def.rebuild(def) end + restoreUserSettings() + currentDemoKey = key + DEMO_STEPS = def.stepDefs + DEMO_SECTIONS = def.sections + demoSteps = def.run + setupAfterStep = def.setupAfter + title:SetText("|cffFFD100" .. def.title .. "|r") + completedUpTo = 0 + animatingIdx = 0 + rebuildStepRows() + refreshStepList() + updateButtons() + refreshModeToggle() + if refreshDemoMenuActive then refreshDemoMenuActive() end + end + + -- Restore the fake cursor to its saved end-of-step position + -- (captured during a previous playthrough). If there's no + -- recorded position for that step, leaves the cursor hidden + -- so the next runStep places it at moveCursorTo's default + -- starting spot. + local function restoreCursorForStep(stepIdx) + local pos = cursorEndPos[stepIdx] + if pos and pos.shown and pos.x and pos.y then + placeCursorAt(pos.x, pos.y) + cursor:Show() + else + cursor:Hide() + end + end + + -- Jump directly to step N's end state. Used by clicking a row in + -- the step list. No animation: cancel anything in flight, wipe the + -- game state, then apply setupAfterStep[N]. + jumpToStep = function(target) + if not active then return end + if target < 1 or target > #demoSteps then return end + cancelInFlight() + autoPlay = false + resetDemoGameState() + if setupAfterStep[target] then + setupAfterStep[target]() + end + completedUpTo = target + refreshStepList() + -- Cursor belongs at the end of step `target`. + restoreCursorForStep(target) + updateButtons() + end + + -- Jump to the state right BEFORE step N (i.e., end state of N-1), + -- so the next Play / Next runs step N from the beginning. Used + -- when clicking a section header to start that section fresh. + jumpToBeforeStep = function(target) + if not active then return end + if target < 1 or target > #demoSteps then return end + cancelInFlight() + autoPlay = false + resetDemoGameState() + if target > 1 and setupAfterStep[target - 1] then + setupAfterStep[target - 1]() + end + completedUpTo = target - 1 + refreshStepList() + -- Cursor belongs at the end of step `target-1`, which is + -- the exact spot where step `target` would start naturally + -- during a linear playthrough. + if target > 1 then + restoreCursorForStep(target - 1) + else + cursor:Hide() + end + updateButtons() + end + + -- Prev/Next jump to the beginning of the adjacent step without + -- playing any animation. Only Play triggers animations. + -- The target step gets highlightOverride so it's always a step + -- that lights up (never a section header). + local function isAtBoundary() + local nextIdx = completedUpTo + 1 + if nextIdx > #demoSteps then return false end + for _, sect in ipairs(DEMO_SECTIONS) do + if nextIdx == sect.firstStep then return true end + end + return false + end + + prevBtn:SetScript("OnClick", function() + if not active then return end + if animatingIdx > 0 then cancelInFlight(); autoPlay = false end + local target + if highlightOverride then + target = highlightOverride - 1 + else + target = completedUpTo + end + if target < 1 then return end + jumpToBeforeStep(target) + highlightOverride = target + refreshStepList() + end) + + nextBtn:SetScript("OnClick", function() + if not active then return end + if animatingIdx > 0 then cancelInFlight(); autoPlay = false end + local target + if highlightOverride then + target = highlightOverride + 1 + elseif isAtBoundary() then + target = completedUpTo + 1 + else + target = completedUpTo + 2 + end + if target > #demoSteps then return end + jumpToBeforeStep(target) + highlightOverride = target + refreshStepList() + end) + + -- Find the section that the current position belongs to. A + -- section is "current" while completedUpTo < lastStep, which + -- treats end-of-section-N as "at section N+1's header" for + -- non-last sections, and "in the last section" for the final. + local function findCurrentSectionIdx() + if not DEMO_SECTIONS then return nil end + for idx, sect in ipairs(DEMO_SECTIONS) do + if completedUpTo < sect.lastStep then + return idx + end + end + return nil + end + + sectPrevBtn:SetScript("OnClick", function() + if not active then return end + local idx = findCurrentSectionIdx() + if not idx then + -- Past all sections (very end): jump to the last section's + -- header so the player can replay that section. + local last = DEMO_SECTIONS[#DEMO_SECTIONS] + if last and jumpToBeforeStep then + jumpToBeforeStep(last.firstStep) + end + return + end + local sect = DEMO_SECTIONS[idx] + if completedUpTo == sect.firstStep - 1 then + -- Already at the current section's header: go to the + -- previous section's header (or no-op if first). + if idx > 1 and jumpToBeforeStep then + jumpToBeforeStep(DEMO_SECTIONS[idx - 1].firstStep) + end + else + -- Mid section: go to the start of the current section. + if jumpToBeforeStep then + jumpToBeforeStep(sect.firstStep) + end + end + end) + + sectNextBtn:SetScript("OnClick", function() + if not active then return end + local idx = findCurrentSectionIdx() + if not idx then + -- Already past all sections: nothing to do. + return + end + if idx < #DEMO_SECTIONS then + -- Not the last section: jump to the next section's header. + if jumpToBeforeStep then + jumpToBeforeStep(DEMO_SECTIONS[idx + 1].firstStep) + end + else + -- Last section: jump to the very end of the demo. + if jumpToStep then + jumpToStep(#demoSteps) + end + end + end) + + playBtn:SetScript("OnClick", function() + if not active then return end + if paused then + -- Resume from the frozen state. Cursor OnUpdates and the + -- safeAfter queue start advancing again. + paused = false + updateButtons() + elseif autoPlay then + -- Currently running: freeze everything in place. + paused = true + updateButtons() + else + -- Not running: start auto-play from current position. + autoPlay = true + updateButtons() + if animatingIdx == 0 then + runStep(completedUpTo + 1) + end + end + end) + + replayBtn:SetScript("OnClick", function() + if not active then return end + -- Reset to the state shown when the demo window first opened. + -- User can then click Play to auto-run or Next to step through. + cancelInFlight() + autoPlay = false + resetDemoGameState() + completedUpTo = 0 + refreshStepList() + updateButtons() + end) + + -- Stop = full reset to as-if-just-opened state for the current + -- demo. Equivalent to switching to the same demo via the dropdown. + stopBtn:SetScript("OnClick", function() + if not active then return end + if loadDemo then loadDemo(currentDemoKey) end + end) + + updateButtons() + end -- _runDemo + _runDemo() + end diff --git a/EasyFind.toc b/EasyFind.toc index d618ab8..68574b9 100644 --- a/EasyFind.toc +++ b/EasyFind.toc @@ -18,3 +18,4 @@ Highlight.lua MapSearch.lua Rescaler.lua Options.lua +Demo.lua diff --git a/Options.lua b/Options.lua index 1885083..39af202 100644 --- a/Options.lua +++ b/Options.lua @@ -1009,7 +1009,7 @@ function Options:Initialize() uiSpeedLabel:SetText("Speed") local directOpenCheckbox = CreateCheckbox(sec1, "DirectOpen", "Open Panels Directly", - "When enabled, clicking a UI search result will immediately open the destination panel.\n\nWhen disabled (default), you will be guided step-by-step with highlights showing you where to click.") + "When enabled (default), Fast Mode opens the destination panel immediately.\n\nWhen disabled, Guide Mode walks you through step-by-step with highlights showing where to click.") directOpenCheckbox:SetPoint("TOPLEFT", uiSpeedBox, "TOPLEFT", 8, -4) directOpenCheckbox:SetChecked(EasyFind.db.directOpen or false) directOpenCheckbox:SetScript("OnClick", function(self) diff --git a/UI.lua b/UI.lua index 015b93b..50eaa20 100644 --- a/UI.lua +++ b/UI.lua @@ -24,6 +24,8 @@ local GameTooltip = GameTooltip local GameTooltip_Hide = GameTooltip_Hide local IsShiftKeyDown = IsShiftKeyDown local GetCursorPosition = GetCursorPosition +local InCombatLockdown = InCombatLockdown +local HideUIPanel = HideUIPanel local wipe = wipe local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind\\textures\\lightning-bolt" @@ -184,50 +186,19 @@ end function UI:DressUpAppearanceSet(setID) if not setID or not C_TransmogSets then return end - -- Gather source IDs from whichever API returns data. GetAllSourceIDs is - -- the canonical form (a numeric array), preferred when available. - local sources, n = {}, 0 - local seen = {} - local function add(sid) - if sid and sid ~= 0 and not seen[sid] then - seen[sid] = true - n = n + 1 - sources[n] = sid - end - end - - if C_TransmogSets.GetAllSourceIDs then - local ids = C_TransmogSets.GetAllSourceIDs(setID) - if ids then - for i = 1, #ids do add(ids[i]) end - end - end - if n == 0 and C_TransmogSets.GetSetSources then - local set = C_TransmogSets.GetSetSources(setID) - if set then - for sourceID in pairs(set) do add(sourceID) end - end - end - if n == 0 and C_TransmogSets.GetSetPrimaryAppearances then - local appearances = C_TransmogSets.GetSetPrimaryAppearances(setID) - if appearances then - for i = 1, #appearances do - local app = appearances[i] - add(app and app.appearanceID) - end - end - end - - if n == 0 then + local allIDs = C_TransmogSets.GetAllSourceIDs + and C_TransmogSets.GetAllSourceIDs(setID) + if not allIDs or #allIDs == 0 then EasyFind:Print("could not load sources for this appearance set.") return end if DressUpTransmogSet then - DressUpTransmogSet(sources) + DressUpTransmogSet(allIDs) end end + -- Sync pinned outfit names/icons with current outfit data. -- Called when TRANSMOG_OUTFITS_CHANGED fires (outfits renamed/deleted). function UI:SyncOutfitPins() @@ -647,11 +618,11 @@ function UI:CreateSearchFrame() self.btnBg:Show() GameTooltip:SetOwner(self, "ANCHOR_BOTTOM") if EasyFind.db.directOpen then - GameTooltip:SetText("Fast Search (ON)") - GameTooltip:AddLine("Click to switch to step-by-step guided mode.", 1, 1, 1, true) + GameTooltip:SetText("Fast Mode") + GameTooltip:AddLine("Click to switch to step-by-step Guide Mode.", 1, 1, 1, true) else - GameTooltip:SetText("Standard Search") - GameTooltip:AddLine("Click to enable fast search (opens panels directly).", 1, 1, 1, true) + GameTooltip:SetText("Guide Mode") + GameTooltip:AddLine("Click to enable Fast Mode (opens panels directly).", 1, 1, 1, true) end GameTooltip:Show() end) @@ -856,11 +827,16 @@ function UI:CreateSearchFrame() editBox:SetFocus() end) - -- Show/hide the clear X based on whether there's text or an active guide + -- Show/hide the clear X based on whether there's text, an active + -- UI highlight guide, or active map navigation (waypoint, SuperTrack, + -- pin). So the player can press it to cancel ANY kind of active + -- navigation without typing /ef c. local function UpdateClearButtonVisibility() local hasText = editBox:GetText() ~= "" local guideActive = ns.Highlight and ns.Highlight:IsActive() - clearTextBtn:SetShown(hasText or guideActive) + local mapActive = ns.MapSearch and ns.MapSearch.HasActiveNavigation + and ns.MapSearch:HasActiveNavigation() + clearTextBtn:SetShown(hasText or guideActive or mapActive) end editBox:HookScript("OnTextChanged", UpdateClearButtonVisibility) searchFrame.UpdateClearButtonVisibility = UpdateClearButtonVisibility @@ -1214,12 +1190,19 @@ function UI:CreateSearchFrame() if searchFrame.editBox:HasFocus() or searchFrame.editBox:GetText() ~= "" then return end -- Don't hide if results are showing if resultsFrame and resultsFrame:IsShown() then return end + -- Don't hide while the player is actively resizing the bar + if searchFrame.resizing then return end if smartShowTimer then smartShowTimer:Cancel() end smartShowTimer = C_Timer.NewTimer(0.4, function() smartShowTimer = nil - -- Re-check conditions after the delay + -- Re-check conditions after the delay. Smart Show might have + -- been disabled while the timer was pending (e.g., unchecked + -- from the tutorial or options panel mid-hover-out) in which + -- case we must not fade the bar out. + if not EasyFind.db.smartShow then return end if searchFrame.editBox:HasFocus() or searchFrame.editBox:GetText() ~= "" then return end if resultsFrame and resultsFrame:IsShown() then return end + if searchFrame.resizing then return end if hoverZone:IsMouseOver() or searchFrame:IsMouseOver() then return end smartShowVisible = false UIFrameFadeOut(searchFrame, 0.25, searchFrame:GetAlpha(), 0) @@ -1244,6 +1227,12 @@ function UI:CreateSearchFrame() searchFrame.smartShowFadeOut = SmartShowFadeOut searchFrame.smartShowVisible = function() return smartShowVisible end searchFrame.setSmartShowVisible = function(val) smartShowVisible = val end + searchFrame.cancelSmartShowTimer = function() + if smartShowTimer then + smartShowTimer:Cancel() + smartShowTimer = nil + end + end -- OnUpdate: detect movement and adjust opacity accordingly (throttled to ~10Hz) local moveCheckAccum = 0 @@ -1260,6 +1249,16 @@ function UI:CreateSearchFrame() return end if EasyFind.db.smartShow and not smartShowVisible then return end + -- While the player is resizing the bar, keep it fully visible so + -- they can see the live size/font changes. + if self.resizing then + if moveFading then + moveFading = false + UIFrameFadeRemoveFrame(self) + self:SetAlpha(1.0) + end + return + end local speed = GetUnitSpeed("player") local hovering = self:IsMouseOver() @@ -2860,8 +2859,17 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) 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 + -- Don't wipe the hover highlight if the cursor is still on + -- the filter button (the common case when clicking the + -- button to toggle the dropdown closed). Otherwise the + -- outline disappears and OnEnter doesn't re-fire until + -- the cursor leaves and comes back. + if searchFrame.filterBtn.btnBg and not searchFrame.filterBtn:IsMouseOver() then + searchFrame.filterBtn.btnBg:Hide() + end + if searchFrame.filterBtn.UnlockHighlight and not searchFrame.filterBtn:IsMouseOver() then + searchFrame.filterBtn:UnlockHighlight() + end end if searchFrame.editBox and not searchFrame.editBox:IsMouseOver() then searchFrame.editBox:ClearFocus() @@ -2898,6 +2906,10 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end) searchFrame.filterDropdown = dropdown + -- Expose the checkRows table so the demo system can hover/click + -- specific filter rows (e.g., "map") by key without duplicating the + -- layout logic. + dropdown.checkRows = checkRows end function UI:CreateResultsFrame() @@ -5581,14 +5593,21 @@ function UI:SelectResult(data) return end + local setID = data.transmogSetID + local GetBaseSetID = C_TransmogSets.GetBaseSetID + local baseID = GetBaseSetID and GetBaseSetID(setID) or setID local guideData = { steps = { { buttonFrame = "CollectionsMicroButton" }, { waitForFrame = "CollectionsJournal", tabIndex = 5 }, { waitForFrame = "WardrobeCollectionFrame", wardrobeSetsTab = true }, - { waitForFrame = "WardrobeCollectionFrame", transmogSetID = data.transmogSetID, transmogSetName = data.name }, + { waitForFrame = "WardrobeCollectionFrame", transmogSetID = baseID }, }, } + if baseID ~= setID then + guideData.steps[#guideData.steps + 1] = { waitForFrame = "WardrobeCollectionFrame", transmogVariantDropdown = true } + guideData.steps[#guideData.steps + 1] = { waitForFrame = "WardrobeCollectionFrame", transmogVariantSetID = setID } + end if EasyFind.db.directOpen then self:DirectOpen(guideData) else @@ -5732,6 +5751,8 @@ function UI:DirectOpen(data) if step.ejLootTab then return true end if step.wardrobeSetsTab then return true end if step.transmogSetID then return true end + if step.transmogVariantDropdown then return true end + if step.transmogVariantSetID 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 @@ -5923,57 +5944,58 @@ function UI:DirectOpen(data) end end - -- Wardrobe Sets tab: click the Sets tab within WardrobeCollectionFrame + -- Wardrobe Sets tab: click the Sets tab within WardrobeCollectionFrame. + -- Defer remaining steps so the SetsCollectionFrame ScrollBox populates. if step.wardrobeSetsTab then + local wcf = _G["WardrobeCollectionFrame"] local setsTab = Highlight:GetTabButton("WardrobeCollectionFrame", 2) if setsTab then ClickButton(setsTab) - elseif PanelTemplates_SetTab then - local wcf = _G["WardrobeCollectionFrame"] - if wcf then pcall(PanelTemplates_SetTab, wcf, 2) end + end + if wcf then + local scf = wcf.SetsCollectionFrame + if not scf or not scf:IsShown() then + if wcf.SetTab then + pcall(wcf.SetTab, wcf, 2) + elseif PanelTemplates_SetTab then + pcall(PanelTemplates_SetTab, wcf, 2) + end + end + end + if i < executeCount then + local resume = i + 1 + C_Timer.After(0.1, function() executeFrom(resume) end) + return end end - -- Transmog set: scroll to and highlight in the Sets ScrollBox + -- Transmog set: scroll via SetScrollPercentage + select if step.transmogSetID and i == executeCount then - local setsFrame = _G["WardrobeCollectionFrame"] + local scf = _G["WardrobeCollectionFrame"] and _G["WardrobeCollectionFrame"].SetsCollectionFrame - local scrollBox = setsFrame and setsFrame.ListContainer - and setsFrame.ListContainer.ScrollBox - if scrollBox then - local targetName = step.transmogSetName and slower(step.transmogSetName) + if scf then C_Timer.After(0.1, function() - if targetName then - Utils.ScrollBoxScrollTo(scrollBox, function(elementData) - if not elementData then return false end - local setID = elementData.setID - if setID and setID == step.transmogSetID then return true end - return false - end) - end - C_Timer.After(0.05, function() - local setBtn = Utils.ScrollBoxFindButton(scrollBox, function(btn) - local edata = btn.GetElementData and btn:GetElementData() - if edata and edata.setID == step.transmogSetID then return true end - if targetName then - local text = Utils.GetButtonText(btn) - if text and slower(text) == targetName then return true end - end - return false - end) - if setBtn and Highlight then - Highlight:HighlightFrame(setBtn) - local checkHover - checkHover = function() - if setBtn:IsMouseOver() then - Highlight:HideHighlight() - else - C_Timer.After(0.1, checkHover) + local lc = scf.ListContainer + local scrollBox = lc and lc.ScrollBox + if scrollBox and scrollBox.SetScrollPercentage then + local dp = scrollBox.GetDataProvider and scrollBox:GetDataProvider() + if dp then + local finder = dp.FindElementDataByPredicate or dp.FindByPredicate + local found = finder and finder(dp, function(ed) + return ed and ed.setID == step.transmogSetID + end) + if found then + local idx = dp.FindIndex and dp:FindIndex(found) + local total = dp.GetSize and dp:GetSize() + if idx and total and total > 1 then + scrollBox:SetScrollPercentage((idx - 1) / (total - 1)) end end - C_Timer.After(0.3, checkHover) end - end) + end + if lc and lc.SelectElementDataMatchingSetID then + pcall(lc.SelectElementDataMatchingSetID, lc, step.transmogSetID) + end end) end end @@ -6467,8 +6489,12 @@ function UI:UpdateSmartShow() searchFrame.setSmartShowVisible(false) end else - -- Disable smart show: hide hover zone, restore normal opacity + -- Disable smart show: hide hover zone, cancel any pending fade-out + -- timer (the player may be mid-hover-out when they flip the toggle), + -- and restore normal opacity. searchFrame.hoverZone:Hide() + if searchFrame.cancelSmartShowTimer then searchFrame.cancelSmartShowTimer() end + UIFrameFadeRemoveFrame(searchFrame) searchFrame.setSmartShowVisible(true) if EasyFind.db.visible ~= false and not inCombat then local alpha = searchFrame.getEffectiveAlpha and searchFrame.getEffectiveAlpha() or 1.0 @@ -6564,8 +6590,9 @@ function UI:ShowWhatsNew(version) end -- FIRST-TIME SETUP OVERLAY --- Shown once on fresh install to let the user position & scale the search --- bar before normal use. Persisted via EasyFind.db.setupComplete. +-- Shown once on fresh install to let the user position & scale the search bar +-- and learn about Fast vs Guide mode. Persisted account-wide via +-- EasyFind.db.setupComplete. function UI:ShowFirstTimeSetup() if not searchFrame then return end if EasyFind.db.setupComplete then return end @@ -6584,110 +6611,163 @@ function UI:ShowFirstTimeSetup() searchFrame.setupMode = true searchFrame.editBox:EnableMouse(false) - -- Golden glow overlay - local glow = CreateFrame("Frame", "EasyFindSetupGlow", searchFrame, "BackdropTemplate") - glow:SetPoint("TOPLEFT", searchFrame, "TOPLEFT", -6, 6) - glow:SetPoint("BOTTOMRIGHT", searchFrame, "BOTTOMRIGHT", 6, -6) - glow:SetFrameStrata("DIALOG") - glow:SetFrameLevel(100) - glow:EnableMouse(false) -- clicks pass through to search bar - glow:SetIgnoreParentAlpha(true) -- stay opaque when search bar fades - - glow:SetBackdrop({ - bgFile = "Interface\\Buttons\\WHITE8x8", - edgeFile = TOOLTIP_BORDER, - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 } - }) - glow:SetBackdropColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.20) - glow:SetBackdropBorderColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) - - -- Gentle pulse on the gold fill - local pulseUp = true - local pulseAlpha = 0.20 - glow:SetScript("OnUpdate", function(self, elapsed) - if pulseUp then - pulseAlpha = pulseAlpha + elapsed * 0.12 - if pulseAlpha >= 0.35 then pulseAlpha = 0.35; pulseUp = false end - else - pulseAlpha = pulseAlpha - elapsed * 0.12 - if pulseAlpha <= 0.12 then pulseAlpha = 0.12; pulseUp = true end - end - self:SetBackdropColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], pulseAlpha) - end) - - -- "EasyFind" label overlaid on the glow (like edit-mode frame labels) - local setupLabel = glow:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") - setupLabel:SetPoint("CENTER", glow, "CENTER", 0, 0) - setupLabel:SetText("EasyFind") - setupLabel:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.7) - - -- Resize handle (bottom-left corner) - local resizer = CreateFrame("Button", nil, glow) + -- Resize handle (bottom-left corner of the search bar). Parents to + -- searchFrame so it fades in sync when Smart Show hides the bar. + local resizer = CreateFrame("Button", nil, searchFrame) + resizer:SetFrameStrata("DIALOG") + resizer:SetFrameLevel(searchFrame:GetFrameLevel() + 20) resizer:SetSize(16, 16) - resizer:SetPoint("BOTTOMLEFT", glow, "BOTTOMLEFT", 0, 0) + resizer:SetPoint("BOTTOMRIGHT", searchFrame, "BOTTOMRIGHT", 0, 0) resizer:EnableMouse(true) - resizer:RegisterForDrag("LeftButton") + -- Bright gold grabber: additive blend mode over a solid-gold vertex + -- color makes the lines read well on any background. The Blizzard + -- grabber texture is already oriented for bottom-right, so no flip. + local function styleResizerTex(tex) + tex:SetVertexColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1) + tex:SetBlendMode("ADD") + end resizer:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up") - resizer:GetNormalTexture():SetTexCoord(1, 0, 0, 1) -- flip for bottom-left + styleResizerTex(resizer:GetNormalTexture()) resizer:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight") - resizer:GetHighlightTexture():SetTexCoord(1, 0, 0, 1) + styleResizerTex(resizer:GetHighlightTexture()) resizer:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down") - resizer:GetPushedTexture():SetTexCoord(1, 0, 0, 1) + styleResizerTex(resizer:GetPushedTexture()) resizer:SetScript("OnEnter", function(self) + if self.dragging then return end -- don't show tooltip mid-drag GameTooltip:SetOwner(self, "ANCHOR_TOP") GameTooltip:SetText("Drag to resize") GameTooltip:Show() end) resizer:SetScript("OnLeave", GameTooltip_Hide) + local function scaleResizerVisual() + local s = mmax(0.5, mmin(2.0, EasyFind.db.fontSize or 1.0)) + resizer:SetSize(16 * s, 16 * s) + end + + -- Dragging the bottom-left corner resizes the bar in two axes at once: + -- horizontal -> uiSearchWidth (symmetric growth around a locked top-center) + -- vertical -> fontSize (which also grows the bar height downward) + -- + -- Uses OnMouseDown (not RegisterForDrag) so there's no 4-pixel drag + -- threshold: the bar starts resizing the instant the cursor moves. + -- Delta-from-start math keeps the cursor locked to wherever on the + -- resizer the user originally clicked - if they clicked the middle + -- of the hitbox, the middle of the hitbox follows the cursor. resizer.dragging = false - resizer.lastY = nil - resizer:SetScript("OnDragStart", function(self) + resizer:SetScript("OnMouseDown", function(self, button) + if button ~= "LeftButton" then return end + GameTooltip_Hide() -- hide the hover tooltip while dragging self.dragging = true - local _, cy = GetCursorPosition() - self.lastY = cy / UIParent:GetEffectiveScale() + searchFrame.resizing = true + + -- Snapshot the current top-center as a stationary anchor for + -- symmetric horizontal growth and top-down vertical growth. + local left, right, top = searchFrame:GetLeft(), searchFrame:GetRight(), searchFrame:GetTop() + if not (left and right and top) then + self.dragging = false + searchFrame.resizing = nil + return + end + self.anchorCenterX = (left + right) / 2 + self.anchorTopY = top + searchFrame:ClearAllPoints() + searchFrame:SetPoint("TOP", UIParent, "BOTTOMLEFT", self.anchorCenterX, self.anchorTopY) + + -- Snapshot starting cursor position and starting db values so the + -- per-frame math can compute deltas absolutely without drift and + -- without snapping the corner to the cursor at drag start. + self.startCx, self.startCy = GetCursorPosition() + self.startWidth = EasyFind.db.uiSearchWidth or 1.0 + self.startFont = EasyFind.db.fontSize or 1.0 end) - resizer:SetScript("OnDragStop", function(self) + + local function stopResize(self) + if not self.dragging then return end self.dragging = false - self.lastY = nil + searchFrame.resizing = nil + self.anchorCenterX = nil + self.anchorTopY = nil + self.startCx = nil + self.startCy = nil + self.startWidth = nil + self.startFont = nil + + -- Persist the new TOP-anchored position so the bar sticks after drag. + local point, _, relPoint, x, y = searchFrame:GetPoint() + EasyFind.db.uiSearchPosition = {point, relPoint, x, y} + end + + resizer:SetScript("OnMouseUp", function(self, button) + if button ~= "LeftButton" then return end + stopResize(self) end) + resizer:SetScript("OnUpdate", function(self) if not self.dragging then return end - local _, cy = GetCursorPosition() - cy = cy / UIParent:GetEffectiveScale() - if self.lastY then - local dy = self.lastY - cy -- drag down = bigger for bottom-left handle - local curScale = EasyFind.db.uiSearchScale or 1.0 - local newScale = curScale + dy * 0.005 - newScale = mmax(0.5, mmin(2.0, newScale)) - EasyFind.db.uiSearchScale = newScale - EasyFind.db.uiResultsScale = newScale - searchFrame:SetScale(newScale) - if resultsFrame then resultsFrame:SetScale(newScale) end - end - self.lastY = cy + -- If the user released the button outside the hitbox, OnMouseUp + -- won't fire - bail out when we detect the button is no longer down. + if not IsMouseButtonDown("LeftButton") then + stopResize(self) + return + end + if not (self.startCx and self.startCy and self.startWidth and self.startFont) then return end + + local cx, cy = GetCursorPosition() + local effScale = searchFrame:GetEffectiveScale() or 1.0 + if effScale <= 0 then return end + + -- Delta from the cursor's starting position (in raw screen pixels). + local dxScreen = cx - self.startCx + local dyScreen = cy - self.startCy + + -- Bottom-right corner: cursor moving RIGHT should grow the bar + -- symmetrically, cursor moving DOWN should grow the font/height. + -- d(uiSearchWidth) = dxScreen / (effScale * 125) + -- d(fontSize) = -dyScreen / (effScale * SEARCHBAR_HEIGHT) + local newWidth = self.startWidth + dxScreen / (effScale * 125) + local newFont = self.startFont - dyScreen / (effScale * ns.SEARCHBAR_HEIGHT) + + newWidth = mmax(0.5, mmin(2.5, newWidth)) + newFont = mmax(0.5, mmin(2.0, newFont)) + + EasyFind.db.uiSearchWidth = newWidth + EasyFind.db.fontSize = newFont + UI:UpdateWidth() + UI:UpdateFontSize() + scaleResizerVisual() end) - -- Instruction panel (anchored below the glow) - local panel = CreateFrame("Frame", nil, glow, "BackdropTemplate") - panel:SetSize(340, 215) - panel:SetPoint("TOP", glow, "BOTTOM", 0, -6) + -- Set the initial resizer size to match the current font scale + scaleResizerVisual() + + -- Fixed panel width - text wraps to fit. 290px comfortably holds the + -- longest header line and lets the descriptions wrap to 2 lines. + local SIDE_BUFFER = 16 + local panelWidth = 290 + + -- Instruction panel (anchored below the search bar) + local panel = CreateFrame("Frame", nil, searchFrame, "BackdropTemplate") + panel:SetSize(panelWidth, 245) + panel:SetPoint("TOP", searchFrame, "BOTTOM", 0, -6) + panel:SetIgnoreParentAlpha(true) -- survive Smart Show fade panel:SetFrameStrata("DIALOG") + -- Solid flat background (same WHITE8x8 texture the search bar uses) so + -- the panel is fully opaque and text stays easy to read. panel:SetBackdrop({ - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + bgFile = "Interface\\Buttons\\WHITE8x8", edgeFile = TOOLTIP_BORDER, - tile = true, tileSize = 32, edgeSize = 16, + edgeSize = 16, insets = { left = 4, right = 4, top = 4, bottom = 4 } }) - panel:SetBackdropColor(DARK_PANEL_BG[1], DARK_PANEL_BG[2], DARK_PANEL_BG[3], DARK_PANEL_BG[4]) + panel:SetBackdropColor(0.05, 0.05, 0.05, 0.9) - -- Top header lines (centered) + -- Top header lines (centered). local header = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight") header:SetPoint("TOP", panel, "TOP", 0, -12) - header:SetWidth(310) + header:SetWidth(panelWidth - SIDE_BUFFER * 2) header:SetJustifyH("CENTER") header:SetText( "|cffffffffDrag the search bar to position it.|r\n" .. @@ -6703,20 +6783,44 @@ function UI:ShowFirstTimeSetup() "\226\128\162 |cff999999Hold |cffFFD100Shift|r|cff999999 + drag to reposition later.|r" ) - -- Horizontal separator between tip and Smart Show section + -- Reset to defaults button: escape hatch if a new user accidentally + -- shrinks the bar to an unclickable size or drags it off-screen. + local resetDefaultsBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + resetDefaultsBtn:SetSize(180, 20) + resetDefaultsBtn:SetPoint("TOP", tip, "BOTTOM", 0, -6) + resetDefaultsBtn:SetText("Reset size & position") + resetDefaultsBtn:SetScript("OnClick", function() + EasyFind.db.uiSearchPosition = nil + EasyFind.db.uiSearchWidth = 0.88 + EasyFind.db.fontSize = 0.9 + if UI.ResetPosition then UI:ResetPosition() end + UI:UpdateWidth() + UI:UpdateFontSize() + scaleResizerVisual() + end) + + -- Horizontal separator between the reset button and Smart Show section local sep = panel:CreateTexture(nil, "ARTWORK") sep:SetHeight(1) - sep:SetPoint("TOPLEFT", tip, "BOTTOMLEFT", 0, -6) - sep:SetPoint("TOPRIGHT", tip, "BOTTOMRIGHT", 0, -6) + sep:SetPoint("TOPLEFT", resetDefaultsBtn, "BOTTOMLEFT", -70, -8) + sep:SetPoint("TOPRIGHT", resetDefaultsBtn, "BOTTOMRIGHT", 70, -8) sep:SetColorTexture(0.4, 0.4, 0.4, 0.6) -- Smart Show checkbox (default checked - matches DB_DEFAULTS.smartShow = true) + -- The separator anchor sits ~15px outside the panel's left edge + -- (sep extends past the panel for a full hr-rule look), so anchoring + -- the checkbox with offset 30 from sep.BOTTOMLEFT places it at + -- roughly panel.left + 15 - a comfortable left margin inside the + -- panel. local smartShowCheckbox = CreateFrame("CheckButton", nil, panel, "InterfaceOptionsCheckButtonTemplate") - smartShowCheckbox:SetPoint("TOPLEFT", sep, "BOTTOMLEFT", 0, -6) + smartShowCheckbox:SetPoint("TOPLEFT", sep, "BOTTOMLEFT", 30, -6) smartShowCheckbox.Text:SetText("|cffFFD100Smart Show|r |cff999999(Recommended)|r") smartShowCheckbox:SetChecked(false) smartShowCheckbox:SetScript("OnClick", function(self) - -- Update live so the user can see the hover behavior immediately + -- Apply live so the player can see the hover behavior immediately, + -- same as the matching toggle in /ef options. The tutorial panel + -- has SetIgnoreParentAlpha(true) so it stays visible even when the + -- search bar fades out. EasyFind.db.smartShow = self:GetChecked() UI:UpdateSmartShow() end) @@ -6725,7 +6829,7 @@ function UI:ShowFirstTimeSetup() local smartDesc = smartShowCheckbox:CreateFontString(nil, "OVERLAY") smartDesc:SetFontObject(smartShowCheckbox.Text:GetFontObject()) smartDesc:SetPoint("TOPLEFT", smartShowCheckbox.Text, "BOTTOMLEFT", 0, -2) - smartDesc:SetWidth(284) + smartDesc:SetWidth(panelWidth - 60) smartDesc:SetJustifyH("LEFT") smartDesc:SetText("|cff999999Bar hides when your mouse moves away and reappears when you hover near it.|r") @@ -6742,44 +6846,123 @@ function UI:ShowFirstTimeSetup() local fadeDesc = fadeCheckbox:CreateFontString(nil, "OVERLAY") fadeDesc:SetFontObject(fadeCheckbox.Text:GetFontObject()) fadeDesc:SetPoint("TOPLEFT", fadeCheckbox.Text, "BOTTOMLEFT", 0, -2) - fadeDesc:SetWidth(284) + fadeDesc:SetWidth(panelWidth - 60) fadeDesc:SetJustifyH("LEFT") - fadeDesc:SetText("|cff999999Reduces bar opacity while your character is moving.|r") + fadeDesc:SetText("|cff999999Reduces bar opacity while you're moving.|r") -- Footer note local footer = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight") footer:SetPoint("BOTTOM", panel, "BOTTOM", 0, 36) - footer:SetWidth(310) + footer:SetWidth(panelWidth - SIDE_BUFFER * 2) footer:SetJustifyH("CENTER") - footer:SetText("|cff666666These and more settings can be changed in |cffFFD100/ef|r|cff666666.|r") - - -- Done button - local doneBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") - doneBtn:SetSize(80, 22) - doneBtn:SetPoint("BOTTOM", panel, "BOTTOM", 0, 12) - doneBtn:SetText("Done") + footer:SetText("|cff666666See all settings in |cffFFD100/ef|r|cff666666.|r") + + -- "Got it" button: just closes the tutorial, no mode-intro step. + local gotItBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + gotItBtn:SetSize(100, 22) + gotItBtn:SetPoint("BOTTOM", panel, "BOTTOM", 0, 12) + gotItBtn:SetText("Got it") + + -- Floating "Mode toggle" pointer group, independent of the panel. + -- Sits to the LEFT of the search bar so the arrow can point RIGHT at + -- the mode button. Uses the Blizzard atlas arrow (sheet 1121272), + -- rotated -90deg so the natively-up arrow points right. + -- + -- The frame itself stays fixed next to the mode button; the TEXTURE + -- inside the frame slides via a sin wave so only the arrow bounces, + -- leaving the label and See Demo button rock-steady. + local ARROW_SIZE = 28 + local modePointerFrame = CreateFrame("Frame", nil, searchFrame) + modePointerFrame:SetSize(ARROW_SIZE, ARROW_SIZE) + modePointerFrame:SetPoint("RIGHT", searchFrame.modeBtn, "LEFT", -4, 0) + modePointerFrame:SetIgnoreParentAlpha(true) + local modePointer = modePointerFrame:CreateTexture(nil, "OVERLAY") + modePointer:SetSize(ARROW_SIZE, ARROW_SIZE) + modePointer:SetPoint("CENTER", modePointerFrame, "CENTER", 0, 0) + modePointer:SetTexture(1121272) + modePointer:SetTexCoord(0.6078, 0.6402, 0.9381, 0.9688) + modePointer:SetRotation(-1.5708) -- -90deg: up -> right + + local POKE_AMOUNT = 10 + local POKE_PERIOD = 1.4 -- seconds per full cycle; shared by both the + -- arrow poke and the mode-button flash so + -- the animations stay phase-locked + local pokeElapsed = 0 + + -- Label and See Demo button anchor to the mode button directly, NOT + -- to the arrow frame, so they don't inherit the poke animation. + local modePointerLabel = searchFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + modePointerLabel:SetPoint("RIGHT", searchFrame.modeBtn, "LEFT", -4 - ARROW_SIZE - 4, 0) + modePointerLabel:SetText("|cffFFD100Mode toggle|r") + modePointerLabel:SetIgnoreParentAlpha(true) + + local seeDemoBtn = CreateFrame("Button", nil, searchFrame, "UIPanelButtonTemplate") + seeDemoBtn:SetSize(120, 24) + seeDemoBtn:SetPoint("TOPRIGHT", modePointerLabel, "BOTTOMRIGHT", 0, -8) + seeDemoBtn:SetText("See demo") + seeDemoBtn:SetIgnoreParentAlpha(true) + + -- Pulsing gold flash over the mode button. Uses a radial star glow + -- texture (already referenced elsewhere in the addon) so the flash + -- has soft edges instead of the hard square corners you'd get from + -- tinting WHITE8x8. Sized larger than the button so the glow halo + -- extends past the button's bounds. + local modeFlashFrame = CreateFrame("Frame", nil, searchFrame.modeBtn) + modeFlashFrame:SetPoint("CENTER", searchFrame.modeBtn, "CENTER", 0, 0) + local flashSize = searchFrame.modeBtn:GetHeight() * 1.8 + modeFlashFrame:SetSize(flashSize, flashSize) + modeFlashFrame:SetFrameLevel(searchFrame.modeBtn:GetFrameLevel() + 5) + modeFlashFrame:SetIgnoreParentAlpha(true) + local modeFlash = modeFlashFrame:CreateTexture(nil, "OVERLAY") + modeFlash:SetAllPoints() + modeFlash:SetTexture("Interface\\Cooldown\\star4") + modeFlash:SetVertexColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1) + modeFlash:SetBlendMode("ADD") + + local FLASH_MIN, FLASH_MAX = 0.15, 0.55 + modeFlashFrame:SetAlpha(FLASH_MIN) + + -- Shared ticker drives both the arrow poke and the mode-button flash + -- so they're perfectly in sync: arrow closest to button == brightest + -- flash, arrow farthest == dimmest flash. + modePointerFrame:SetScript("OnUpdate", function(_, dt) + pokeElapsed = pokeElapsed + dt + local phase = (1 - math.cos(pokeElapsed * 2 * math.pi / POKE_PERIOD)) * 0.5 + modePointer:ClearAllPoints() + modePointer:SetPoint("CENTER", modePointerFrame, "CENTER", phase * POKE_AMOUNT, 0) + modeFlashFrame:SetAlpha(FLASH_MIN + (FLASH_MAX - FLASH_MIN) * phase) + end) -- During setup: allow drag without holding Shift searchFrame:SetScript("OnDragStart", function(self) self:StartMoving() end) - -- Done handler: persist, cleanup, restore normal drag - doneBtn:SetScript("OnClick", function() + -- Forward declarations for the demo frames so FinishSetup can clean + -- them up regardless of whether the player went through See Demo. + local demoFrame + local startDemo + + local function FinishSetup() EasyFind.db.setupComplete = true -- Save current position local point, _, relPoint, x, y = searchFrame:GetPoint() EasyFind.db.uiSearchPosition = {point, relPoint, x, y} - -- Destroy overlay & restore normal state + -- Destroy overlays & restore normal state searchFrame.setupMode = nil searchFrame.editBox:EnableMouse(true) UI:UpdateSearchBarTheme() -- restore proper backdrop colors - glow:SetScript("OnUpdate", nil) resizer:SetScript("OnUpdate", nil) - glow:Hide() + resizer:Hide() panel:Hide() + modePointerFrame:SetScript("OnUpdate", nil) + modePointerFrame:Hide() + modePointerLabel:Hide() + seeDemoBtn:Hide() + modeFlashFrame:Hide() + if demoFrame then demoFrame:Hide() end -- Restore shift-only drag searchFrame:SetScript("OnDragStart", function(self) @@ -6796,6 +6979,49 @@ function UI:ShowFirstTimeSetup() -- Record current version so What's New won't fire on next login -- (brand-new users don't need to see it - all features are new for them) EasyFind.db.lastSeenVersion = ns.version + end + + startDemo = function() + ns.Demo.Start({ + searchFrame = searchFrame, + resultsFrame = resultsFrame, + resultButtons = resultButtons, + finishSetup = FinishSetup, + }) + end + + gotItBtn:SetScript("OnClick", FinishSetup) + + -- Escape closes the whole tutorial from the positioning panel. + panel:EnableKeyboard(true) + panel:SetPropagateKeyboardInput(true) + panel:SetScript("OnKeyDown", function(self, key) + if key == "ESCAPE" then + self:SetPropagateKeyboardInput(false) + FinishSetup() + else + self:SetPropagateKeyboardInput(true) + end + end) + + seeDemoBtn:SetScript("OnClick", function() + -- Save position + apply preferences, then skip straight to the + -- interactive demo (no mode-intro step in between). + local point, _, relPoint, x, y = searchFrame:GetPoint() + EasyFind.db.uiSearchPosition = {point, relPoint, x, y} + EasyFind.db.smartShow = smartShowCheckbox:GetChecked() + EasyFind.db.staticOpacity = not fadeCheckbox:GetChecked() + UI:UpdateSmartShow() + + -- Hide the positioning UI before the demo runs. + panel:Hide() + resizer:Hide() + modePointerFrame:SetScript("OnUpdate", nil) + modePointerFrame:Hide() + modePointerLabel:Hide() + seeDemoBtn:Hide() + modeFlashFrame:Hide() + startDemo() end) end diff --git a/demo-cog.tga b/demo-cog.tga new file mode 100644 index 0000000000000000000000000000000000000000..1ea283e73692fa2d67897f9f2694cbd4bf5f7020 GIT binary patch literal 16402 zcmeHJ%Z=MG5H-?(@3}C?+*HDDCn~`uumqQ&QdojZ@GV!D;JmlsEM%@I&QPQmN^IbP zlE`_Vp(N{Y_;HZm`{DiY>$jY}#=af+8U8q61UxVSApx&(ZUkIkH_vB}+3pxM@XvSH zSMinqSaDGJ(WF26 zbihvE2p>EjFgS9Nrnq&6t zJ87OZcf8{sY|bt{*J0b@9`o9k_hR?#8+&j?z7fd& z7jZ7G;aW>Co|c_GfH!CKXZ{8Ozc*?)*`MAcd`(Jk+?TgnKdc4FaaUlf)v?sU2k%jh zAFip+E}qC?(91qs!nbvVmy^N0`(DO>olhjy7aJ#5Mof0kzFIS7cW-T=k-+0=UN|H2WWn`%=>5{K=A~W-T-<-=S|c8uJ?Hy#bo+ zT+{r$Gxs9L(3zB;Zdg~{1J(lM(Bz-QU+)oqoYIr{Ha`pC_eKrZJGbz|*8phuMS5^W z%?M=gQO?CRTq|{&kzNMz*K?!}V9lOp2;evaK7z-*maem$(^JqQ0C`12M*3LI9_#%=t#G8{YW~PKfREZ~Ej>2PPCfp}3xt^_^^4hK zt)JDUp40PU{>V9ilOCi#;J8b6>d6lpKI*Envo6+%&(f&MA9Ke(hWwiFWSN_<8 z?t8waSJ5NynbN+?{8RUGYc@@N&;8fvfA^YK{x;t~-@T8u`q+O5jPX90Fop!Y=7AA# LwK}glTL=CE=EbI0 literal 0 HcmV?d00001 diff --git a/demo-pause.tga b/demo-pause.tga new file mode 100644 index 0000000000000000000000000000000000000000..d4dd4df60bd3a365933bab6829da70e2365bfedd GIT binary patch literal 16402 zcmeH@!HGg)7{q?Ij6Hd@3_IU$Y(w$jIY@$sWdxDO_~m8hoS*al+|KP>uJ5P)Zh_u0Jmt$DT|-PgV~kNj<3 z`_??$kM3*Vnn(UNuYGHt?ML^uZ_Oiro7cWI&-SDH+PCJBzs+mknrHjbeeGNG$lvC* zZ_Ts)=)U%?dE{^N+PCJ}eso{^);#jJdF@;CY(KiMeQO^1+r0LzdA1+j*SJ9XZz88?OXH6-{!S% z&9nXJzV@woOo^3kVSa literal 0 HcmV?d00001 diff --git a/demo-replay.tga b/demo-replay.tga new file mode 100644 index 0000000000000000000000000000000000000000..d81ee7b2b9cf27a97194c628dbc30de5509a2c2d GIT binary patch literal 65554 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 Date: Mon, 13 Apr 2026 09:24:38 -0400 Subject: [PATCH 011/103] Fix map search hover preview dismissing while mouse is on row --- MapSearch.lua | 121 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 81 insertions(+), 40 deletions(-) diff --git a/MapSearch.lua b/MapSearch.lua index cc88dfe..ac3a506 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -1770,8 +1770,14 @@ function MapSearch:CreateSearchFrame() GameTooltip:Hide() end) - editBox:HookScript("OnTextChanged", function(self) - clearBtn:SetShown(self:GetText() ~= "") + local function UpdateLocalClearVisibility() + local hasText = editBox:GetText() ~= "" + local navActive = MapSearch.HasActiveNavigation and MapSearch:HasActiveNavigation() + clearBtn:SetShown(hasText or navActive) + end + searchFrame.UpdateClearButtonVisibility = UpdateLocalClearVisibility + editBox:HookScript("OnTextChanged", function() + UpdateLocalClearVisibility() end) -- Shift-click editbox starts parent drag when not focused @@ -2205,8 +2211,14 @@ function MapSearch:CreateSearchFrame() GameTooltip:Hide() end) - globalEditBox:HookScript("OnTextChanged", function(self) - globalClearBtn:SetShown(self:GetText() ~= "") + local function UpdateGlobalClearVisibility() + local hasText = globalEditBox:GetText() ~= "" + local navActive = MapSearch.HasActiveNavigation and MapSearch:HasActiveNavigation() + globalClearBtn:SetShown(hasText or navActive) + end + globalSearchFrame.UpdateClearButtonVisibility = UpdateGlobalClearVisibility + globalEditBox:HookScript("OnTextChanged", function() + UpdateGlobalClearVisibility() end) -- Shift-click editbox starts parent drag when not focused @@ -2846,7 +2858,10 @@ function MapSearch:CreateResultButton(index) activePinState = MapSearch._savedPinState end) resultRow:SetScript("OnLeave", function(self) + if MapSearch._demoHoverLock then return end if not MapSearch._previewing then return end + -- Mouse moved to a child frame (navBtn, etc.) - still hovering + if self:IsMouseOver() then return end MapSearch._previewing = nil -- Clear the preview pin MapSearch:ClearHighlight() @@ -2995,10 +3010,7 @@ function MapSearch:CreateHighlightFrame() C_SuperTrack.SetSuperTrackedUserWaypoint(true) efPlacedWaypoint = true ShowSuperTrackGlow() - local sf = _G["EasyFindSearchFrame"] - if sf and sf.UpdateClearButtonVisibility then - sf.UpdateClearButtonVisibility() - end + MapSearch:RefreshAllClearButtons() end end if button == "RightButton" then @@ -4892,6 +4904,9 @@ function MapSearch:HookWorldMap() local vignetteFrame = CreateFrame("Frame") vignetteFrame:RegisterEvent("VIGNETTES_UPDATED") vignetteFrame:SetScript("OnEvent", function() + -- Don't wipe a hover preview; the refresh will happen when + -- the preview ends or the user types more. + if MapSearch._previewing then return end if isGlobalSearch then return end local editBox = searchFrame and searchFrame.editBox if editBox and editBox:HasFocus() then @@ -6836,8 +6851,12 @@ function MapSearch:SelectResult(data) return end - local directMode = isGlobalSearch and (EasyFind.db.globalMapDirectOpen or false) - or (EasyFind.db.localMapDirectOpen or false) + local directMode + if isGlobalSearch then + directMode = EasyFind.db.globalMapDirectOpen or false + else + directMode = EasyFind.db.localMapDirectOpen or false + end -- Handle zone selection if data.isZone and data.zoneMapID then @@ -6910,15 +6929,21 @@ function MapSearch:SelectResult(data) -- map when data.mapID isn't present. if not isGlobalSearch and directMode then local autoX, autoY, autoMapID - if data.x and data.y then - autoX, autoY = data.x, data.y - autoMapID = data.mapID - or (data.allInstances and data.allInstances[1] and data.allInstances[1].mapID) - or (WorldMapFrame and WorldMapFrame:GetMapID()) + local viewedMap = WorldMapFrame and WorldMapFrame:GetMapID() + if data.allInstances and #data.allInstances > 1 then + -- Multiple instances: navigate to the nearest one + local nearest = self:GetNearestInstance(data.allInstances, viewedMap) + if nearest then + autoX, autoY = nearest.x, nearest.y + autoMapID = nearest.mapID or viewedMap + end elseif data.allInstances and #data.allInstances == 1 then local single = data.allInstances[1] autoX, autoY = single.x, single.y - autoMapID = single.mapID or (WorldMapFrame and WorldMapFrame:GetMapID()) + autoMapID = single.mapID or viewedMap + elseif data.x and data.y then + autoX, autoY = data.x, data.y + autoMapID = data.mapID or viewedMap end if autoX and autoY and autoMapID and autoX >= 0 and autoX <= 1 and autoY >= 0 and autoY <= 1 then @@ -6926,10 +6951,7 @@ function MapSearch:SelectResult(data) C_SuperTrack.SetSuperTrackedUserWaypoint(true) efPlacedWaypoint = true ShowSuperTrackGlow() - local sf = _G["EasyFindSearchFrame"] - if sf and sf.UpdateClearButtonVisibility then - sf.UpdateClearButtonVisibility() - end + MapSearch:RefreshAllClearButtons() end end end @@ -7185,6 +7207,17 @@ function MapSearch:ShowMultipleWaypoints(instances) end end + -- During hover preview, prevent pins from intercepting mouse events + -- that belong to the results panel (pins can overlap at TOOLTIP strata) + if self._previewing then + waypointPin:EnableMouse(false) + if self.extraPins then + for _, ep in ipairs(self.extraPins) do + if ep:IsShown() then ep:EnableMouse(false) end + end + end + end + -- Auto-track on minimap if requested by navigate button if self.autoTrackNextPin then self.autoTrackNextPin = nil @@ -7233,6 +7266,9 @@ function MapSearch:ShowWaypointAt(x, y, icon, category) waypointPin.waypointY = y waypointPin.isLocalSearch = not isGlobalSearch waypointPin:Show() + if self._previewing then + waypointPin:EnableMouse(false) + end -- Resize and position highlight highlightFrame:SetSize(highlightSize, highlightSize) @@ -7261,10 +7297,7 @@ function MapSearch:ShowWaypointAt(x, y, icon, category) -- Refresh the UI search bar's clear button so it appears while a -- pin is visible (active map navigation). - local sf = _G["EasyFindSearchFrame"] - if sf and sf.UpdateClearButtonVisibility then - sf.UpdateClearButtonVisibility() - end + self:RefreshAllClearButtons() end -- Tracks the canvas pin currently scaled up by HighlightPin so ClearHighlight @@ -7363,13 +7396,16 @@ function MapSearch:ClearHighlight() indicatorFrame:SetPoint("BOTTOM", highlightFrame, "TOP", 0, 2) indicatorFrame:Hide() waypointPin:Hide() + -- Reset strata to creation defaults so the mouse-enabled pin doesn't + -- linger at TOOLTIP (set by ShowMultipleWaypoints) where it can + -- overlap and steal mouse from the TOOLTIP-strata results panel. + waypointPin:SetFrameStrata("HIGH") + waypointPin:SetFrameLevel(2000) + waypointPin:EnableMouse(true) -- Refresh the UI search bar's clear button (pin is gone, but map -- navigation may still be active via efPlacedWaypoint). - local sf = _G["EasyFindSearchFrame"] - if sf and sf.UpdateClearButtonVisibility then - sf.UpdateClearButtonVisibility() - end + self:RefreshAllClearButtons() waypointPin.waypointX = nil waypointPin.waypointY = nil waypointPin.isLocalSearch = nil @@ -7387,6 +7423,7 @@ function MapSearch:ClearHighlight() if self.extraPins then for _, pin in ipairs(self.extraPins) do pin:Hide() + pin:EnableMouse(true) pin.waypointX = nil pin.waypointY = nil pin.isLocalSearch = nil @@ -7465,6 +7502,19 @@ function MapSearch:HasActiveNavigation() return false end +function MapSearch:RefreshAllClearButtons() + local frames = { + _G["EasyFindSearchFrame"], + _G["EasyFindMapSearchFrame"], + _G["EasyFindMapGlobalSearchFrame"], + } + for _, f in ipairs(frames) do + if f and f.UpdateClearButtonVisibility then + f.UpdateClearButtonVisibility() + end + end +end + -- Return the currently-highlighted pin's coordinates and mapID, whether -- it was shown via ShowWaypointAt (waypointPin) or HighlightPin (native -- Blizzard pin). Used by the demo's "click to start tracking" step to @@ -7490,10 +7540,7 @@ function MapSearch:TrackActiveLocation() C_SuperTrack.SetSuperTrackedUserWaypoint(true) efPlacedWaypoint = true ShowSuperTrackGlow() - local sf = _G["EasyFindSearchFrame"] - if sf and sf.UpdateClearButtonVisibility then - sf.UpdateClearButtonVisibility() - end + self:RefreshAllClearButtons() return true end @@ -7520,10 +7567,7 @@ function MapSearch:ClearAll() end end -- Notify the UI search bar to refresh its clear-button state. - local sf = _G["EasyFindSearchFrame"] - if sf and sf.UpdateClearButtonVisibility then - sf.UpdateClearButtonVisibility() - end + self:RefreshAllClearButtons() end function MapSearch:GetNearestInstance(instances, mapID) @@ -8217,10 +8261,7 @@ function MapSearch:HandleUISearchClick(data) icon = data.icon, category = data.category, isLocal = true, } - local sf = _G["EasyFindSearchFrame"] - if sf and sf.UpdateClearButtonVisibility then - sf.UpdateClearButtonVisibility() - end + MapSearch:RefreshAllClearButtons() end end else From edce6ad00e44f19e87fa19e84e342db89b9bca04 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 14 Apr 2026 18:58:24 -0400 Subject: [PATCH 012/103] Make fullscreen map search opt-in by default hideSearchBarsMaximized defaults to true. New installs get bars hidden on the maximized map; users who want fullscreen search uncheck the "Hide Fullscreen" option. --- Core.lua | 2 +- Options.lua | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Core.lua b/Core.lua index ab99a47..5d2c313 100644 --- a/Core.lua +++ b/Core.lua @@ -51,7 +51,7 @@ local DB_DEFAULTS = { mapSearchPositionMax = nil, -- x offset from map left edge (maximized) globalSearchPositionMax = nil, -- x offset from map right edge (maximized) mapSearchYOffset = 0, -- y offset for search bars relative to map bottom - hideSearchBarsMaximized = false, -- Hide search bars when map is full screen + hideSearchBarsMaximized = true, -- Hide search bars when map is full screen (opt-in fullscreen search) directOpen = true, -- Open panels directly (Fast Mode) instead of step-by-step (Guide Mode) localMapDirectOpen = false, -- Zone bar: navigate directly (no zone highlighting) globalMapDirectOpen = false, -- Global bar: navigate directly (no zone highlighting) diff --git a/Options.lua b/Options.lua index 39af202..1dca563 100644 --- a/Options.lua +++ b/Options.lua @@ -1207,7 +1207,7 @@ function Options:Initialize() { label = "Smart Show |cFF888888(Recommended)|r", shortLabel = "Smart", dbKey = "mapSmartShow", default = false, tooltip = "Map search bars hide until you move your mouse near them.\nBars reappear on hover and fade when you move away.\nText in search bars or an open results list prevents fading.", callback = function() if ns.MapSearch and ns.MapSearch.UpdateMapSmartShow then ns.MapSearch:UpdateMapSmartShow() end end }, - { label = "Hide Fullscreen", shortLabel = "No Full", dbKey = "hideSearchBarsMaximized", default = false, + { label = "Hide Fullscreen", shortLabel = "No Full", dbKey = "hideSearchBarsMaximized", default = true, tooltip = "Both map search bars are hidden when the world map is maximized (full screen).\nThey reappear when you return to the windowed map.", callback = function() if ns.MapSearch and ns.MapSearch.UpdateHideMaximized then ns.MapSearch:UpdateHideMaximized() end end }, { label = "Results Above", shortLabel = "Above", dbKey = "mapResultsAbove", default = false, @@ -1715,7 +1715,7 @@ function Options:DoResetAll() EasyFind.db.minimapPinGlow = true EasyFind.db.guideCircleScale = 1.0 EasyFind.db.mapSmartShow = false - EasyFind.db.hideSearchBarsMaximized = false + EasyFind.db.hideSearchBarsMaximized = true EasyFind.db.visible = true EasyFind.db.enableUISearch = true EasyFind.db.enableMapSearch = true @@ -1895,7 +1895,7 @@ function Options:DoResetMap() EasyFind.db.localMapDirectOpen = false EasyFind.db.globalMapDirectOpen = false EasyFind.db.mapSmartShow = false - EasyFind.db.hideSearchBarsMaximized = false + EasyFind.db.hideSearchBarsMaximized = true EasyFind.db.mapResultsAbove = false EasyFind.db.mapFontSize = 0.9 EasyFind.db.mapSearchYOffset = 0 From 7978b3faef69d6815b07d45f3df1f2e99111589d Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 14 Apr 2026 21:46:48 -0400 Subject: [PATCH 013/103] Rework tutorial boxes with chamfered glow border textures Replaces the ad-hoc rect- and mask-based border with a single createTutorialBox() helper that composes three custom TGAs (one corner + horizontal/vertical edges) into a chamfered, multi-layer, pulsing neon-yellow border. The corner chamfer is anti-aliased at 4x4 sub-pixel density while layer interiors and layer-to-transparent boundaries stay sharp, so the chamfer reads as a smooth diagonal without fuzzy ghosting where the bright band meets the dark interior. The bright band sits just outside the frame edge so the pulse animation doesn't reveal the interior gradient behind it. tools/gen_glow_tgas.py regenerates the three TGAs from a simple LAYERS list plus CHAMFER_OUTER/CHAMFER_INNER knobs; rerun it after tweaking parameters. Demo.lua's transition banner, minimap callout, and map-search callout all rebuild on top of the new helper. --- Demo.lua | 1298 +++++++++++++++++++++++++++++++------- textures/glow-corner.tga | Bin 0 -> 1042 bytes textures/glow-edge-h.tga | Bin 0 -> 274 bytes textures/glow-edge-v.tga | Bin 0 -> 274 bytes tools/gen_glow_tgas.py | 198 ++++++ 5 files changed, 1267 insertions(+), 229 deletions(-) create mode 100644 textures/glow-corner.tga create mode 100644 textures/glow-edge-h.tga create mode 100644 textures/glow-edge-v.tga create mode 100644 tools/gen_glow_tgas.py diff --git a/Demo.lua b/Demo.lua index 51da74c..22cef19 100644 --- a/Demo.lua +++ b/Demo.lua @@ -949,12 +949,8 @@ function Demo.Start(ctx) -- state still reads true, which causes spurious auto-close -- during demo playback. Swap in a no-op OnUpdate for the demo's -- lifetime so the dropdown stays open when the demo wants it - -- open. Restored on endDemo. - local savedFilterDropdownOnUpdate - if searchFrame.filterDropdown then - savedFilterDropdownOnUpdate = searchFrame.filterDropdown:GetScript("OnUpdate") - searchFrame.filterDropdown:SetScript("OnUpdate", nil) - end + -- open. Suspend/resume is tied to applyRunningLocks/releaseRunningLocks + -- so the dropdown works normally when the demo is idle. -- Temporarily force-disable movement fade (staticOpacity = true) -- so the search bar stays at full alpha even if the player walks @@ -969,6 +965,7 @@ function Demo.Start(ctx) local cursor = CreateFrame("Frame", nil, UIParent) cursor:SetSize(36, 36) cursor:SetFrameStrata("TOOLTIP") + cursor:SetFrameLevel(10001) cursor:EnableMouse(false) local cursorTex = cursor:CreateTexture(nil, "OVERLAY") cursorTex:SetAllPoints() @@ -977,6 +974,12 @@ function Demo.Start(ctx) cursorTex:SetTexCoord(0.0000, 0.2315, 0.0000, 0.4104) cursor:Hide() + local rightClickIcon = cursor:CreateTexture(nil, "OVERLAY") + rightClickIcon:SetAtlas("newplayertutorial-icon-mouse-rightbutton") + rightClickIcon:SetSize(48, 48) + rightClickIcon:SetPoint("LEFT", cursor, "RIGHT", 2, 0) + rightClickIcon:Hide() + active = true -- stepGen is bumped whenever an in-flight animation is cancelled so -- Prev/Next can interrupt at any time. Every async helper captures @@ -991,10 +994,14 @@ function Demo.Start(ctx) local pendingTimers = {} local stopBlinkCursor -- forward decl; defined below next to typeText local updateLockState -- forward decl; defined where the locks are built + local locksSuppressed -- forward decl; defined where the locks are built + local applyRunningLocks -- forward decl; defined where the locks are built + local releaseRunningLocks -- forward decl; defined where the locks are built local setHoveredRow -- forward decl; defined with result-row hover local clearButtonHover -- forward decl; defined before moveCursorTo local resetMapSearchState -- forward decl; defined with map search demo local closeWorldMap -- forward decl; defined with map search demo + local hideMapCaret -- forward decl; defined with map search demo local tickFrame = CreateFrame("Frame") tickFrame:SetScript("OnUpdate", function(_, dt) @@ -1057,27 +1064,135 @@ function Demo.Start(ctx) end end + -- Builds a tutorial-style floating text box: vertical black-to- + -- dim-yellow interior plus a chamfered glow border rendered + -- from three custom TGAs (corner, horizontal edge, vertical + -- edge) tinted to the neon yellow. The alpha profile -- bright + -- band, soft falloff, diagonal corner chamfer -- is baked into + -- the texture files (see tools/gen_glow_tgas.py), so scaling + -- the frame just re-stretches the edge strips between the + -- fixed-size corner pieces. The glow container pulses via a + -- single alpha animation. + local function createTutorialBox(textFont) + local f = CreateFrame("Frame", nil, UIParent) + f:SetFrameStrata("TOOLTIP") + f:SetFrameLevel(1000) + f:SetIgnoreParentAlpha(true) + + local BR, BG, BB = 1.0, 0.98, 0.45 + local TR, TG, TB = 1.0, 0.96, 0.15 + + -- Interior: black at top fading to a dim yellow-brown at bottom. + local interior = f:CreateTexture(nil, "BACKGROUND", nil, 2) + interior:SetAllPoints(f) + interior:SetColorTexture(1, 1, 1, 1) + interior:SetGradient("VERTICAL", + CreateColor(0.32, 0.26, 0.02, 0.94), + CreateColor(0.00, 0.00, 0.00, 0.96)) + + -- Text (on f so the pulse doesn't dim it). + f.fs = f:CreateFontString(nil, "OVERLAY", textFont or "GameFontNormalLarge") + f.fs:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -12) + f.fs:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -16, 12) + f.fs:SetJustifyH("CENTER") + f.fs:SetJustifyV("MIDDLE") + f.fs:SetTextColor(TR, TG, TB, 1.0) + f.fs:SetShadowColor(0, 0, 0, 1) + f.fs:SetShadowOffset(1, -1) + + -- Chamfered glow border via custom TGA textures. The three + -- files encode the exact alpha profile we want (bright + -- band in the middle, soft falloff on both sides, diagonal + -- chamfer cut at the outer corner), so there's no rect- + -- math stacking. The shape is real pixels in an alpha + -- channel, tinted to the neon yellow via SetVertexColor. + local glow = CreateFrame("Frame", nil, f) + glow:SetAllPoints(f) + + local TEX_CORNER = "Interface\\AddOns\\EasyFind\\textures\\glow-corner" + local TEX_EDGE_H = "Interface\\AddOns\\EasyFind\\textures\\glow-edge-h" + local TEX_EDGE_V = "Interface\\AddOns\\EasyFind\\textures\\glow-edge-v" + local CORNER_SZ = 16 -- native texture dimension + local HALF = CORNER_SZ / 2 + + -- The corner TGA is designed TL-style (outermost pixel at + -- its own top-left). For the other three corners we flip + -- the texture via SetTexCoord's left/right and top/bottom + -- being swapped. + local function addCorner(frameAnchor, dx, dy, texL, texR, texT, texB) + local t = glow:CreateTexture(nil, "BORDER") + t:SetTexture(TEX_CORNER) + t:SetTexCoord(texL, texR, texT, texB) + t:SetVertexColor(BR, BG, BB) + t:SetSize(CORNER_SZ, CORNER_SZ) + t:SetPoint(frameAnchor, f, frameAnchor, dx, dy) + end + addCorner("TOPLEFT", -HALF, HALF, 0, 1, 0, 1) -- TL + addCorner("TOPRIGHT", HALF, HALF, 1, 0, 0, 1) -- TR + addCorner("BOTTOMLEFT", -HALF, -HALF, 0, 1, 1, 0) -- BL + addCorner("BOTTOMRIGHT", HALF, -HALF, 1, 0, 1, 0) -- BR + + -- Edges stretch between the corners. The horizontal edge + -- texture has its cross-section gradient along Y; we flip + -- Y for the bottom edge. Vertical edge texture gradient is + -- along X; we flip X for the right edge. + local function addHEdge(frameAnchorL, frameAnchorR, dyOuter, dyInner, flipY) + local t = glow:CreateTexture(nil, "BORDER") + t:SetTexture(TEX_EDGE_H) + if flipY then t:SetTexCoord(0, 1, 1, 0) else t:SetTexCoord(0, 1, 0, 1) end + t:SetVertexColor(BR, BG, BB) + t:SetPoint("TOPLEFT", f, frameAnchorL, HALF, dyOuter) + t:SetPoint("BOTTOMRIGHT", f, frameAnchorR, -HALF, dyInner) + end + addHEdge("TOPLEFT", "TOPRIGHT", HALF, -HALF, false) + addHEdge("BOTTOMLEFT", "BOTTOMRIGHT", HALF, -HALF, true) + + local function addVEdge(frameAnchorT, frameAnchorB, dxOuter, dxInner, flipX) + local t = glow:CreateTexture(nil, "BORDER") + t:SetTexture(TEX_EDGE_V) + if flipX then t:SetTexCoord(1, 0, 0, 1) else t:SetTexCoord(0, 1, 0, 1) end + t:SetVertexColor(BR, BG, BB) + t:SetPoint("TOPLEFT", f, frameAnchorT, dxOuter, -HALF) + t:SetPoint("BOTTOMRIGHT", f, frameAnchorB, dxInner, HALF) + end + addVEdge("TOPLEFT", "BOTTOMLEFT", -HALF, HALF, false) + addVEdge("TOPRIGHT", "BOTTOMRIGHT", -HALF, HALF, true) + + + -- Neon pulse: bright to slightly dim and back. Floor stays + -- high enough that the outer glow is always visible. + local pulseAG = glow:CreateAnimationGroup() + pulseAG:SetLooping("BOUNCE") + local pulseAnim = pulseAG:CreateAnimation("Alpha") + pulseAnim:SetFromAlpha(0.65) + pulseAnim:SetToAlpha(1.0) + pulseAnim:SetDuration(0.9) + pulseAnim:SetSmoothing("IN_OUT") + pulseAG:Play() + f:HookScript("OnShow", function() pulseAG:Restart() end) + + -- Re-sizes the frame to fit the current text plus padding. + f.SetAutoSized = function(self, maxWidth) + maxWidth = maxWidth or 380 + self.fs:ClearAllPoints() + self.fs:SetPoint("CENTER", self, "CENTER", 0, 0) + self.fs:SetWidth(maxWidth - 32) + local w = mmin(self.fs:GetStringWidth() + 32, maxWidth) + local h = self.fs:GetStringHeight() + 24 + self:SetSize(w, h) + end + + return f + end + -- Transition banner shown at the end of Fast Mode, hidden when -- Guide Mode begins. Anchored near the top-center of the screen -- on the TOOLTIP strata so it floats above every menu and frame. - -- The backdrop is flat-dark with a gold tooltip border (matching - -- the demo panel's aesthetic) plus a soft radial glow behind the - -- text so it reads clearly without looking like a stock dialog. -- A fade-in/out animation group softens the transition. - local transitionFrame = CreateFrame("Frame", nil, UIParent) - transitionFrame:SetFrameStrata("TOOLTIP") - transitionFrame:SetFrameLevel(1000) + local transitionFrame = createTutorialBox("GameFontNormalLarge") transitionFrame:SetPoint("TOP", UIParent, "TOP", 0, -160) - transitionFrame:SetSize(400, 72) - transitionFrame:SetIgnoreParentAlpha(true) - - local transitionFS = transitionFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge") - transitionFS:SetPoint("CENTER", transitionFrame, "CENTER", 0, 0) - transitionFS:SetWidth(370) - transitionFS:SetJustifyH("CENTER") - transitionFS:SetTextColor(1.0, 0.82, 0.0, 1.0) - transitionFS:SetShadowColor(0, 0, 0, 1) - transitionFS:SetShadowOffset(2, -2) + transitionFrame:SetSize(460, 80) + local transitionFS = transitionFrame.fs transitionFS:SetText("Now let's take a look at what that process looks like in Guide Mode.") transitionFrame:Hide() @@ -1088,61 +1203,44 @@ function Demo.Start(ctx) function transitionText:Show() transitionFrame:Show() end function transitionText:Hide() transitionFrame:Hide() end - -- Minimap callout: floating gold text anchored to the minimap, - -- used to draw attention to minimap changes (e.g. "your target - -- is now tracked"). Same visual style as transitionText. - local minimapCalloutFrame = CreateFrame("Frame", nil, UIParent) - minimapCalloutFrame:SetFrameStrata("TOOLTIP") - minimapCalloutFrame:SetFrameLevel(1000) - minimapCalloutFrame:SetSize(300, 60) - minimapCalloutFrame:SetIgnoreParentAlpha(true) - -- Anchor just below the minimap so the callout points at it. - -- Falls back to top-right of the screen if Minimap isn't present. + -- Minimap callout: tutorial-style hint box anchored to the + -- minimap, used to draw attention to minimap changes (e.g. + -- "your target is now tracked"). + local minimapCalloutFrame = createTutorialBox("GameFontNormalLarge") + minimapCalloutFrame:SetSize(320, 68) if _G["Minimap"] then minimapCalloutFrame:SetPoint("RIGHT", _G["Minimap"], "LEFT", -20, 40) else minimapCalloutFrame:SetPoint("TOPRIGHT", UIParent, "TOPRIGHT", -240, -80) end - - local minimapCalloutFS = minimapCalloutFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") - minimapCalloutFS:SetPoint("CENTER", minimapCalloutFrame, "CENTER", 0, 0) - minimapCalloutFS:SetWidth(280) - minimapCalloutFS:SetJustifyH("CENTER") - minimapCalloutFS:SetTextColor(1.0, 0.82, 0.0, 1.0) - minimapCalloutFS:SetShadowColor(0, 0, 0, 1) - minimapCalloutFS:SetShadowOffset(2, -2) + local minimapCalloutFS = minimapCalloutFrame.fs minimapCalloutFS:SetText("Note how your target is now tracked!") minimapCalloutFrame:Hide() local minimapCallout = {} - function minimapCallout:SetText(text) minimapCalloutFS:SetText(text or "") end + function minimapCallout:SetText(text) + minimapCalloutFS:SetText(text or "") + minimapCalloutFrame:SetAutoSized(360) + end function minimapCallout:Show() minimapCalloutFrame:Show() end function minimapCallout:Hide() minimapCalloutFrame:Hide() end -- Floating narration anchored next to the local map search frame. - -- Used by mapSearchCurrent's "browse what's around" step. Frame - -- and font string live as fields on the table so this consumes - -- only one local in the parent function. + -- Used by mapSearchCurrent's "browse what's around" step. local mapSearchCallout = {} - mapSearchCallout.frame = CreateFrame("Frame", nil, UIParent) - mapSearchCallout.frame:SetFrameStrata("TOOLTIP") - mapSearchCallout.frame:SetFrameLevel(1000) + mapSearchCallout.frame = createTutorialBox("GameFontNormalLarge") mapSearchCallout.frame:SetSize(280, 60) - mapSearchCallout.frame:SetIgnoreParentAlpha(true) mapSearchCallout.frame:Hide() - mapSearchCallout.fs = mapSearchCallout.frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") - mapSearchCallout.fs:SetPoint("CENTER", mapSearchCallout.frame, "CENTER", 0, 0) - mapSearchCallout.fs:SetWidth(260) - mapSearchCallout.fs:SetJustifyH("CENTER") - mapSearchCallout.fs:SetTextColor(1.0, 0.82, 0.0, 1.0) - mapSearchCallout.fs:SetShadowColor(0, 0, 0, 1) - mapSearchCallout.fs:SetShadowOffset(2, -2) - function mapSearchCallout:SetText(text) self.fs:SetText(text or "") end + mapSearchCallout.fs = mapSearchCallout.frame.fs + function mapSearchCallout:SetText(text) + self.fs:SetText(text or "") + self.frame:SetAutoSized(340) + end function mapSearchCallout:Show() local lsf = _G["EasyFindMapSearchFrame"] if lsf then self.frame:ClearAllPoints() - self.frame:SetPoint("LEFT", lsf, "RIGHT", 24, 0) + self.frame:SetPoint("LEFT", lsf, "RIGHT", 56, 0) end self.frame:Show() end @@ -1159,6 +1257,9 @@ function Demo.Start(ctx) local function endDemo() active = false paused = false + if searchFrame.filterDropdown then + searchFrame.filterDropdown._demoSuspend = nil + end wipe(pendingTimers) tickFrame:SetScript("OnUpdate", nil) cursor:SetScript("OnUpdate", nil) @@ -1167,8 +1268,11 @@ function Demo.Start(ctx) setHoveredRow(nil) clearButtonHover() transitionText:Hide() + mapSearchCallout:Hide() minimapCallout:Hide() minimapArrow:Hide() + hideMapCaret() + if ns.MapSearch then ns.MapSearch._demoHoverLock = nil end -- Wipe any demo-modified search bar state so the bar returns -- to its clean default (placeholder visible, no stale text). searchFrame.editBox:SetText("") @@ -1181,8 +1285,7 @@ function Demo.Start(ctx) if searchFrame.filterDropdown:IsShown() then searchFrame.filterDropdown:Hide() end - -- Restore the original OnUpdate handler (auto-close). - searchFrame.filterDropdown:SetScript("OnUpdate", savedFilterDropdownOnUpdate) + searchFrame.filterDropdown._demoSuspend = nil end if ns.Highlight and ns.Highlight.ClearAll then pcall(ns.Highlight.ClearAll, ns.Highlight) @@ -1247,31 +1350,61 @@ function Demo.Start(ctx) rowHoverTex:SetVertexColor(1, 1, 1, 0.6) rowHoverTex:Hide() local hoveredResultRow + -- Separate hover overlay for the filter dropdown (its CheckButton + -- rows don't have a highlight texture set). + local dropdownHoverTex + local function getDropdownHoverTex() + if not dropdownHoverTex then + local dd = searchFrame.filterDropdown + if not dd then return nil end + dropdownHoverTex = dd:CreateTexture(nil, "OVERLAY") + dropdownHoverTex:SetColorTexture(1, 1, 1, 0.1) + dropdownHoverTex:Hide() + end + return dropdownHoverTex + end setHoveredRow = function(row) if row == hoveredResultRow then return end - -- Unlock previous row's highlight (for map result buttons - -- which use LockHighlight instead of the overlay texture). - if hoveredResultRow then - if hoveredResultRow.UnlockHighlight then - hoveredResultRow:UnlockHighlight() + -- Fire OnLeave on the departing map result row so the map + -- preview clears at the exact same frame as the highlight. + local prev = hoveredResultRow + if prev then + if prev.UnlockHighlight then + prev:UnlockHighlight() + end + if prev._demoMapHover then + prev._demoMapHover = nil + if ns.MapSearch then ns.MapSearch._demoHoverLock = nil end + local onLeave = prev:GetScript("OnLeave") + if onLeave then pcall(onLeave, prev) end end end hoveredResultRow = row + rowHoverTex:Hide() + local ddTex = getDropdownHoverTex() + if ddTex then ddTex:Hide() end if row then - -- Map result buttons (EasyFindMapResultButton*) use - -- SetHighlightTexture natively, so LockHighlight works. - -- UI search buttons use the rowHoverTex overlay instead. - local isMapBtn = row:GetName() and sfind(row:GetName(), "MapResult") - if isMapBtn then - if row.LockHighlight then row:LockHighlight() end + if row.GetHighlightTexture and row:GetHighlightTexture() then + row:LockHighlight() + elseif row._isDropdownRow then + if ddTex then + ddTex:ClearAllPoints() + ddTex:SetAllPoints(row) + ddTex:Show() + end else rowHoverTex:ClearAllPoints() rowHoverTex:SetAllPoints(row) rowHoverTex:Show() end - else - rowHoverTex:Hide() + -- Fire OnEnter on map result rows so the map preview + -- appears at the exact same frame as the highlight. + if row._demoMapHover then + if ns.MapSearch then ns.MapSearch._demoHoverLock = true end + local onEnter = row:GetScript("OnEnter") + if onEnter then pcall(onEnter, row) end + end end end @@ -1285,11 +1418,20 @@ function Demo.Start(ctx) local ct = cursor:GetTop() if not cl then setHoveredRow(nil); return end local cx, cy = cl + 4, ct - 4 + -- Pin popup + local pinPop = _G["EasyFindPinPopup"] + if pinPop and pinPop:IsShown() then + local rl, rr, rt, rb = pinPop:GetLeft(), pinPop:GetRight(), pinPop:GetTop(), pinPop:GetBottom() + if rl and cx >= rl and cx <= rr and cy <= rt and cy >= rb then + setHoveredRow(pinPop) + return + end + end -- UI search results if resultsFrame:IsShown() then for i = 1, #resultButtons do local row = resultButtons[i] - if row and row:IsShown() and row.data then + if row and row:IsShown() then local rl, rr, rt, rb = row:GetLeft(), row:GetRight(), row:GetTop(), row:GetBottom() if rl and cx >= rl and cx <= rr and cy <= rt and cy >= rb then setHoveredRow(row) @@ -1298,6 +1440,25 @@ function Demo.Start(ctx) end end end + -- Filter dropdown rows (dropdown has custom scaling, so + -- convert the cursor tip to the dropdown's coordinate space) + local dd = searchFrame.filterDropdown + if dd and dd:IsShown() and dd.checkRows then + local ds = dd:GetEffectiveScale() + local cs = cursor:GetEffectiveScale() + local dcx = cx * cs / ds + local dcy = cy * cs / ds + for _, row in pairs(dd.checkRows) do + if row and row:IsShown() then + local rl, rr, rt, rb = row:GetLeft(), row:GetRight(), row:GetTop(), row:GetBottom() + if rl and dcx >= rl and dcx <= rr and dcy <= rt and dcy >= rb then + row._isDropdownRow = true + setHoveredRow(row) + return + end + end + end + end -- Map search results local mapResults = _G["EasyFindMapResultsFrame"] if mapResults and mapResults:IsShown() then @@ -1413,7 +1574,13 @@ function Demo.Start(ctx) if elapsed >= total then self:SetSize(startSize, startSize) self:SetScript("OnUpdate", nil) - if onComplete then pcall(onComplete) end + if onComplete then + local ok, err = pcall(onComplete) + if not ok and err then + local handler = geterrorhandler() + if handler then handler(err) end + end + end end end) end @@ -1847,10 +2014,150 @@ function Demo.Start(ctx) -- Zone/Instance Map Search demo -------------------------------------------------------------------- - -- Type text into an arbitrary editbox (map search bars need real - -- focus for OnTextChanged to fire and trigger the search). The - -- editbox is given programmatic focus, which bypasses click - -- blockers. Characters are typed via SetText one at a time. + -- Blinking text caret for map search editboxes. A hidden + -- FontString mirrors the editbox font to measure text width. + local mapCaret = CreateFrame("Frame", nil, UIParent) + mapCaret:SetFrameStrata("TOOLTIP") + mapCaret:SetFrameLevel(10002) + mapCaret:SetSize(1, 1) + mapCaret:Hide() + local mapCaretTex = mapCaret:CreateTexture(nil, "OVERLAY") + mapCaretTex:SetTexture("Interface\\Buttons\\WHITE8x8") + mapCaretTex:SetVertexColor(1, 1, 1, 1) + mapCaretTex:SetWidth(1) + mapCaretTex:SetAllPoints() + local mapCaretMeasure = mapCaret:CreateFontString(nil, "ARTWORK", "GameFontNormal") + mapCaretMeasure:Hide() + local mapCaretTicker + local mapCaretLastEditBox + + local function positionMapCaret(editBox) + mapCaret:ClearAllPoints() + local text = editBox:GetText() or "" + local inL = editBox:GetTextInsets() + local xOff = inL or 2 + if #text > 0 then + if editBox ~= mapCaretLastEditBox then + local ok, fontPath, fontSize, fontFlags = pcall(editBox.GetFont, editBox) + if ok and fontPath and fontSize and fontSize > 0 then + pcall(mapCaretMeasure.SetFont, mapCaretMeasure, fontPath, fontSize, fontFlags) + end + mapCaretLastEditBox = editBox + end + mapCaretMeasure:SetText(text) + xOff = xOff + (mapCaretMeasure:GetStringWidth() or 0) + end + mapCaret:SetPoint("LEFT", editBox, "LEFT", xOff, 0) + mapCaret:SetPoint("TOP", editBox, "TOP", 0, -2) + mapCaret:SetPoint("BOTTOM", editBox, "BOTTOM", 0, 2) + end + + local function showMapCaret(editBox) + positionMapCaret(editBox) + mapCaret:Show() + mapCaretTex:SetAlpha(1) + if mapCaretTicker then mapCaretTicker:Cancel() end + local vis = true + mapCaretTicker = C_Timer.NewTicker(0.5, function() + vis = not vis + mapCaretTex:SetAlpha(vis and 1 or 0) + end) + end + hideMapCaret = function() + if mapCaretTicker then mapCaretTicker:Cancel(); mapCaretTicker = nil end + mapCaret:Hide() + end + local function repositionMapCaret(editBox) + positionMapCaret(editBox) + mapCaretTex:SetAlpha(1) + end + + -- Simulate refocusing then backspacing the current editbox text + -- one character at a time. Shows a blinking caret at the end of + -- the text for a beat, then deletes character by character. + local function backspaceMapText(editBox, charDelay, onComplete) + local gsf = _G["EasyFindMapGlobalSearchFrame"] + local isGlobal = gsf and gsf.editBox == editBox + local runFn + if isGlobal and ns.MapSearch and ns.MapSearch.RunGlobalSearch then + runFn = function(t) ns.MapSearch:RunGlobalSearch(t) end + elseif ns.MapSearch and ns.MapSearch.RunLocalSearch then + runFn = function(t) ns.MapSearch:RunLocalSearch(t) end + else + runFn = function(t) editBox:SetText(t) end + end + local cur = editBox:GetText() or "" + local len = #cur + if len == 0 then + if onComplete then onComplete() end + return + end + showMapCaret(editBox) + safeAfter(0.6, function() + local myGen = stepGen + local function nextDel() + if not active or myGen ~= stepGen then return end + len = len - 1 + if len <= 0 then + runFn("") + repositionMapCaret(editBox) + safeAfter(0.4, function() + hideMapCaret() + if onComplete then onComplete() end + end) + return + end + runFn(cur:sub(1, len)) + repositionMapCaret(editBox) + safeAfter(charDelay, nextDel) + end + nextDel() + end) + end + + -- Variant that leaves the caret visible after the last deletion + -- so the caret persists across backspace→type transitions (matching + -- real editbox behavior where focus isn't lost between edits). + local function backspaceMapTextKeepCaret(editBox, charDelay, onComplete) + local cur = editBox:GetText() or "" + if #cur == 0 then + showMapCaret(editBox) + if onComplete then onComplete() end + return + end + local gsf = _G["EasyFindMapGlobalSearchFrame"] + local isGlobal = gsf and gsf.editBox == editBox + local runFn + if isGlobal and ns.MapSearch and ns.MapSearch.RunGlobalSearch then + runFn = function(t) ns.MapSearch:RunGlobalSearch(t) end + elseif ns.MapSearch and ns.MapSearch.RunLocalSearch then + runFn = function(t) ns.MapSearch:RunLocalSearch(t) end + else + runFn = function(t) editBox:SetText(t) end + end + local len = #cur + showMapCaret(editBox) + safeAfter(0.6, function() + local myGen = stepGen + local function nextDel() + if not active or myGen ~= stepGen then return end + len = len - 1 + if len <= 0 then + runFn("") + repositionMapCaret(editBox) + safeAfter(0.4, function() + if onComplete then onComplete() end + end) + return + end + runFn(cur:sub(1, len)) + repositionMapCaret(editBox) + safeAfter(charDelay, nextDel) + end + nextDel() + end) + end + local function typeMapText(editBox, text, charDelay, onComplete) -- NEVER focus the real editbox: if it had keyboard focus, -- the real user could type into it alongside the demo. @@ -1870,21 +2177,35 @@ function Demo.Start(ctx) runFn = function(t) editBox:SetText(t) end end runFn("") + showMapCaret(editBox) local i = 0 local myGen = stepGen local function nextChar() if not active or myGen ~= stepGen then return end i = i + 1 if i > #text then + hideMapCaret() if onComplete then onComplete() end return end runFn(text:sub(1, i)) + repositionMapCaret(editBox) safeAfter(charDelay, nextChar) end nextChar() end + -- Variant that re-shows the caret after typing finishes, matching + -- real editbox behavior where the caret persists until the user + -- clicks away. Used in browse flows where backspace→type cycles + -- repeat without defocusing. + local function typeMapTextKeepCaret(editBox, text, charDelay, onComplete) + typeMapText(editBox, text, charDelay, function() + showMapCaret(editBox) + if onComplete then onComplete() end + end) + end + -- Find a map search result button by partial name match. local function findMapResultByName(pattern) for i = 1, 20 do @@ -1952,6 +2273,7 @@ function Demo.Start(ctx) -- activePinState (otherwise WorldMapFrame's OnShow hook would -- restore a stale pin the next time the map opens). resetMapSearchState = function() + hideMapCaret() local lsf = _G["EasyFindMapSearchFrame"] if lsf and lsf.editBox then lsf.editBox:SetText("") @@ -2087,6 +2409,7 @@ function Demo.Start(ctx) or _G["EasyFindMapResultButton1"] if not target or not target:IsShown() then done(); return end moveCursorTo(target, CURSOR_MOVE, function() + hideMapCaret() clickAnim(function() if target.data and ns.MapSearch then pcall(ns.MapSearch.SelectResult, ns.MapSearch, target.data) @@ -2245,6 +2568,49 @@ function Demo.Start(ctx) end end + -- Hover the local map search mode button, show tooltip, click + -- to toggle if needed. Mirrors hoverModeButton for the UI bar. + local function hoverLocalMapModeBtn(wantFast, done) + local lsf = _G["EasyFindMapSearchFrame"] + local mb = lsf and lsf.modeBtn + if not mb then ensureLocalMapMode(wantFast); done(); return end + moveCursorTo(mb, CURSOR_MOVE, function() + if mb.btnBg then mb.btnBg:Show() end + if mb.LockHighlight then mb:LockHighlight() end + GameTooltip:SetOwner(mb, "ANCHOR_BOTTOM") + local currentFast = EasyFind.db.localMapDirectOpen + local function showTargetTooltip() + if wantFast then + GameTooltip:SetText("Fast Mode") + GameTooltip:AddLine("Clicking a result auto-tracks on the minimap.", 1, 1, 1, true) + else + GameTooltip:SetText("Guide Mode") + GameTooltip:AddLine("Clicking a result shows a pin you can click to track.", 1, 1, 1, true) + end + GameTooltip:Show() + end + if currentFast ~= wantFast then + -- Wrong mode: show current, pause, click to toggle + if currentFast then + GameTooltip:SetText("Fast Mode") + else + GameTooltip:SetText("Guide Mode") + end + GameTooltip:Show() + safeAfter(1.0, function() + clickAnim(function() + ensureLocalMapMode(wantFast) + showTargetTooltip() + safeAfter(1.0, done) + end) + end) + else + showTargetTooltip() + safeAfter(1.2, done) + end + end) + end + -- Click the waypoint pin to place a SuperTrack user waypoint. -- This triggers the real minimap glow / guide-circle behavior. -- Uses MapSearch helpers that resolve the pin location whether @@ -2355,6 +2721,14 @@ function Demo.Start(ctx) minimapArrowAG:Stop() minimapArrowFrame:Hide() end + function minimapArrow:SetPaused(isPaused) + if not minimapArrowFrame:IsShown() then return end + if isPaused then + minimapArrowAG:Pause() + else + minimapArrowAG:Play() + end + end -- Show the minimap callout text and a bouncing arrow pointing -- at the minimap. No cursor movement: the arrow does the @@ -2392,15 +2766,16 @@ function Demo.Start(ctx) safeAfter(0.8, done) end - function msc.typeFlight(done) + function msc.retypeLastBrowse(done) local lsf = _G["EasyFindMapSearchFrame"] local editBox = lsf and lsf.editBox if not editBox then done(); return end + local lastPOI = msc.lastBrowsePOI + local query = lastPOI and slower(lastPOI.name):sub(1, 3) or "ban" moveCursorTo(editBox, CURSOR_MOVE, function() clickAnim(function() - editBox:SetText("") - safeAfter(STEP_PAUSE, function() - typeMapText(editBox, "fli", TYPE_DELAY, function() + backspaceMapTextKeepCaret(editBox, TYPE_DELAY, function() + typeMapTextKeepCaret(editBox, query, TYPE_DELAY, function() safeAfter(SETTLE_PAUSE, done) end) end) @@ -2408,52 +2783,70 @@ function Demo.Start(ctx) end) end + local function closeMapAndFinish(done) + local closeBtn = getMapCloseBtn() + if closeBtn and closeBtn:IsShown() then + moveCursorTo(closeBtn, CURSOR_MOVE, function() + clickAnim(function() + clearDemoWaypoint() + resetMapSearchState() + closeWorldMap() + safeAfter(0.5, function() + cursor:Hide() + done() + end) + end) + end) + else + clearDemoWaypoint() + resetMapSearchState() + closeWorldMap() + safeAfter(0.5, function() + cursor:Hide() + done() + end) + end + end + function msc.finishHintAndClose(done) showMinimapHint(function() minimapCallout:Hide() minimapArrow:Hide() - local closeBtn = getMapCloseBtn() - if closeBtn and closeBtn:IsShown() then - moveCursorTo(closeBtn, CURSOR_MOVE, function() - clickAnim(function() - clearDemoWaypoint() - resetMapSearchState() - closeWorldMap() - safeAfter(0.5, function() - cursor:Hide() - done() - end) - end) - end) - else - clearDemoWaypoint() - resetMapSearchState() - closeWorldMap() - safeAfter(0.5, function() - cursor:Hide() - done() - end) - end + closeMapAndFinish(done) end) end function msc.clickResult(wantFast, done) ensureLocalMapMode(wantFast) - local target = findMapResultByName("Flight") + local lastPOI = msc.lastBrowsePOI + local target = (lastPOI and findMapResultByName(lastPOI.name)) or findFirstVisibleMapResult() if not target then safeAfter(0.5, done) return end moveCursorTo(target, CURSOR_MOVE, function() + hideMapCaret() clickAnim(function() + -- Clear the hover tag so setHoveredRow fires OnLeave + -- when SelectResult hides the results frame. + if target._demoMapHover then target._demoMapHover = nil end + setHoveredRow(nil) if target.data and ns.MapSearch then pcall(ns.MapSearch.SelectResult, ns.MapSearch, target.data) end if wantFast then - minimapCallout:SetText("Notice how your target is now automatically tracked!") safeAfter(SETTLE_PAUSE, function() - msc.finishHintAndClose(done) + minimapCallout:SetText("Notice how your target is now automatically tracked!") + minimapCallout:Show() + minimapArrow:Show() + moveCursorTo(minimapCalloutFrame, CURSOR_MOVE, function() + safeAfter(2.5, function() + minimapCallout:Hide() + minimapArrow:Hide() + closeMapAndFinish(done) + end) + end, 0, -45) end) else safeAfter(SETTLE_PAUSE, done) @@ -2463,33 +2856,66 @@ function Demo.Start(ctx) end function msc.clickPinTrackContinue(done) + mapSearchCallout:SetText("Click the pin to place a waypoint and start tracking.") + mapSearchCallout:Show() clickPinAndTrack(function() - minimapCallout:SetText("Tracked. There's an even easier way...") - safeAfter(2.0, function() - minimapCallout:Hide() - clearDemoWaypoint() - resetMapSearchState() - local lsf = _G["EasyFindMapSearchFrame"] - local editBox = lsf and lsf.editBox - if editBox then editBox:SetText("") end - safeAfter(0.4, done) + safeAfter(1.5, function() + mapSearchCallout:Hide() + minimapCallout:SetText("Tracked. There's an even easier way...") + minimapCallout:Show() + minimapArrow:Show() + moveCursorTo(minimapCalloutFrame, CURSOR_MOVE, function() + safeAfter(2.5, function() + minimapCallout:Hide() + minimapArrow:Hide() + -- Move cursor to the map search clear button and click it + -- so the user sees how to dismiss the pin, rather than + -- having it magically disappear. + local lsf = _G["EasyFindMapSearchFrame"] + local clearBtn = lsf and lsf.clearBtn + if clearBtn and clearBtn:IsShown() then + moveCursorTo(clearBtn, CURSOR_MOVE, function() + clickAnim(function() + local handler = clearBtn:GetScript("OnClick") + if handler then pcall(handler, clearBtn) end + clearDemoWaypoint() + safeAfter(0.4, done) + end) + end) + else + clearDemoWaypoint() + resetMapSearchState() + if lsf and lsf.editBox then lsf.editBox:SetText("") end + safeAfter(0.4, done) + end + end) + end, 0, -45) end) end) end function msc.clickNavBtnAutoTrack(done) - local row = findMapResultByName("Flight") or findFirstVisibleMapResult() + local lastPOI = msc.lastBrowsePOI + local row = (lastPOI and findMapResultByName(lastPOI.name)) + or findFirstVisibleMapResult() if not row or not row.navBtn or not row.navBtn:IsShown() then safeAfter(0.5, done) return end moveCursorTo(row.navBtn, CURSOR_MOVE, function() - clickAnim(function() - if row.data and ns.MapSearch then - ns.MapSearch.autoTrackNextPin = true - pcall(ns.MapSearch.SelectResult, ns.MapSearch, row.data) - end - safeAfter(SETTLE_PAUSE, done) + hideMapCaret() + -- Briefly show the nav button tooltip before clicking + local onEnter = row.navBtn:GetScript("OnEnter") + if onEnter then pcall(onEnter, row.navBtn) end + safeAfter(1.0, function() + GameTooltip_Hide() + clickAnim(function() + if row.data and ns.MapSearch then + ns.MapSearch.autoTrackNextPin = true + pcall(ns.MapSearch.SelectResult, ns.MapSearch, row.data) + end + safeAfter(SETTLE_PAUSE, done) + end) end) end) end @@ -2515,36 +2941,48 @@ function Demo.Start(ctx) return picks end - function msc.hoverSearchPreview(query, displayName, narration, done) + function msc.hoverSearchPreview(query, displayName, narration, isFirstVisit, keepPreview, done) local lsf = _G["EasyFindMapSearchFrame"] local editBox = lsf and lsf.editBox if not editBox then done(); return end - moveCursorTo(editBox, CURSOR_MOVE, function() - clickAnim(function() - editBox:SetText("") - safeAfter(STEP_PAUSE, function() - typeMapText(editBox, query, TYPE_DELAY, function() - safeAfter(SETTLE_PAUSE, function() - local row = findMapResultByName(displayName) or findFirstVisibleMapResult() - if not row then done(); return end - moveCursorTo(row, CURSOR_MOVE, function() - mapSearchCallout:SetText(narration) - mapSearchCallout:Show() - local onEnter = row:GetScript("OnEnter") - if onEnter then pcall(onEnter, row) end - safeAfter(2.2, function() - local onLeave = row:GetScript("OnLeave") - if onLeave then pcall(onLeave, row) end + local function afterFocus() + backspaceMapTextKeepCaret(editBox, TYPE_DELAY, function() + typeMapTextKeepCaret(editBox, query, TYPE_DELAY, function() + safeAfter(SETTLE_PAUSE, function() + local row = findMapResultByName(displayName) or findFirstVisibleMapResult() + if not row then done(); return end + -- Tag the row so setHoveredRow fires OnEnter/OnLeave + -- in sync with the highlight (same placeCursorAt frame). + row._demoMapHover = true + moveCursorTo(row, CURSOR_MOVE, function() + mapSearchCallout:SetText(narration) + mapSearchCallout:Show() + safeAfter(2.2, function() + if keepPreview then + -- Leave _demoMapHover set so the preview + -- persists until clickResult clears it. done() - end) + else + -- Clear the tag; setHoveredRow will fire + -- OnLeave when the cursor moves away. + done() + end end) end) end) end) end) - end) + end + if isFirstVisit then + moveCursorTo(editBox, CURSOR_MOVE, function() + clickAnim(afterFocus) + end) + else + moveCursorTo(editBox, CURSOR_MOVE, afterFocus) + end end + msc.lastBrowsePOI = nil function msc.browseWhatsAround(done) local picks = msc.pickDiversePOIs() if #picks == 0 then done(); return end @@ -2552,27 +2990,21 @@ function Demo.Start(ctx) local function nextOne() idx = idx + 1 if idx > #picks then - mapSearchCallout:SetText("Now let's see what happens when we click a flight master.") - safeAfter(2.5, function() - mapSearchCallout:Hide() - local lsf = _G["EasyFindMapSearchFrame"] - local editBox = lsf and lsf.editBox - if editBox then editBox:SetText("") end - if ns.MapSearch and ns.MapSearch.ClearHighlight then - pcall(ns.MapSearch.ClearHighlight, ns.MapSearch) - end - done() - end) + msc.lastBrowsePOI = picks[#picks] + mapSearchCallout:Hide() + done() return end local poi = picks[idx] local query = slower(poi.name):sub(1, 3) - msc.hoverSearchPreview(query, poi.name, "Hovering over a result shows it on the map.", nextOne) + local isLast = idx == #picks + msc.hoverSearchPreview(query, poi.name, "Hovering over a result shows it on the map.", idx == 1, isLast, nextOne) end nextOne() end DEMOS.mapSearchCurrent.rebuild = function(def) + msc.lastBrowsePOI = nil -- Capture the user's CURRENT mode as the snapshot so -- restoreUserSettings (which runs after rebuild) reverts -- any flips the demo's run/setupAfter make to the saved @@ -2580,73 +3012,78 @@ function Demo.Start(ctx) -- demoModeFast, NOT the user's saved setting. savedLocalMapDirectOpen = EasyFind.db.localMapDirectOpen local isFast = demoModeFast["mapSearchCurrent"] ~= false - if isFast then - def.stepDefs = { - { text = "Open the world map", section = 1 }, - { text = "Browse what's around", section = 1 }, - { text = 'Start typing "Flight Master"', section = 1 }, - { text = "Click the Flight Master result", section = 1 }, - } - def.sections = { - { header = "", section = 1, firstStep = 1, lastStep = 4 }, - } - def.run = { - function(done) msc.openMap(true, done) end, - msc.browseWhatsAround, - msc.typeFlight, - function(done) msc.clickResult(true, done) end, - } - def.setupAfter = { - function() ensureLocalMapMode(true); openWorldMapToPlayerZone() end, - function() ensureLocalMapMode(true); openWorldMapToPlayerZone() end, - function() - ensureLocalMapMode(true); openWorldMapToPlayerZone() - if ns.MapSearch and ns.MapSearch.RunLocalSearch then - pcall(ns.MapSearch.RunLocalSearch, ns.MapSearch, "fli") - end - end, - function() clearDemoWaypoint(); resetMapSearchState(); closeWorldMap() end, - } - else - def.stepDefs = { - { text = "Open the world map", section = 1 }, - { text = "Browse what's around", section = 1 }, - { text = 'Start typing "Flight Master"', section = 1 }, - { text = "Click the Flight Master result", section = 1 }, - { text = "Click the pin to start tracking", section = 1 }, - { text = 'Now type "Flight Master" again', section = 1 }, - { text = "Click the nav pin to auto-track instead", section = 1 }, - { text = "Now you're tracking it on the minimap!", section = 1 }, - } - def.sections = { - { header = "", section = 1, firstStep = 1, lastStep = 8 }, - } - def.run = { - function(done) msc.openMap(false, done) end, - msc.browseWhatsAround, - msc.typeFlight, - function(done) msc.clickResult(false, done) end, - msc.clickPinTrackContinue, - msc.typeFlight, - msc.clickNavBtnAutoTrack, - msc.minimapHintAndClose, - } - local snapMapSearch = function() - ensureLocalMapMode(false); openWorldMapToPlayerZone() + local hasBrowse = #msc.pickDiversePOIs() > 0 + local openSnap = function() + ensureLocalMapMode(isFast); openWorldMapToPlayerZone() + end + -- Snap to end-of-browse: map open with the last POI query + -- typed and results visible, ready for "Click a result". + local browseSnap = function() + ensureLocalMapMode(isFast); openWorldMapToPlayerZone() + local picks = msc.pickDiversePOIs() + if #picks > 0 then + msc.lastBrowsePOI = picks[#picks] + local query = slower(picks[#picks].name):sub(1, 3) if ns.MapSearch and ns.MapSearch.RunLocalSearch then - pcall(ns.MapSearch.RunLocalSearch, ns.MapSearch, "fli") + ns.MapSearch:RunLocalSearch(query) end end - def.setupAfter = { - function() ensureLocalMapMode(false); openWorldMapToPlayerZone() end, - function() ensureLocalMapMode(false); openWorldMapToPlayerZone() end, - snapMapSearch, - snapMapSearch, - function() ensureLocalMapMode(false); openWorldMapToPlayerZone() end, - snapMapSearch, - snapMapSearch, - function() clearDemoWaypoint(); resetMapSearchState(); closeWorldMap() end, - } + end + local doneSnap = function() + clearDemoWaypoint(); resetMapSearchState(); closeWorldMap() + end + local modeLabel = isFast + and "Make sure Fast Mode is enabled" + or "Make sure Guide Mode is enabled" + local modeStep = function(done) hoverLocalMapModeBtn(isFast, done) end + if isFast then + if hasBrowse then + def.stepDefs = { + { text = "Open the world map", section = 1 }, + { text = modeLabel, section = 1 }, + { text = "Browse what's around", section = 1 }, + { text = "Click a result", section = 1 }, + } + def.sections = { { header = "", section = 1, firstStep = 1, lastStep = 4 } } + def.run = { + function(done) msc.openMap(true, done) end, + modeStep, + msc.browseWhatsAround, + function(done) msc.clickResult(true, done) end, + } + def.setupAfter = { openSnap, openSnap, browseSnap, doneSnap } + else + def.disabled = true + def.disabledMessage = "No searchable POIs in this zone." + end + else + if hasBrowse then + def.stepDefs = { + { text = "Open the world map", section = 1 }, + { text = modeLabel, section = 1 }, + { text = "Browse what's around", section = 1 }, + { text = "Click a result", section = 1 }, + { text = "Click the pin to start tracking", section = 1 }, + { text = "Search again", section = 1 }, + { text = "Click the nav pin to auto-track instead", section = 1 }, + { text = "Now you're tracking it on the minimap!", section = 1 }, + } + def.sections = { { header = "", section = 1, firstStep = 1, lastStep = 8 } } + def.run = { + function(done) msc.openMap(false, done) end, + modeStep, + msc.browseWhatsAround, + function(done) msc.clickResult(false, done) end, + msc.clickPinTrackContinue, + msc.retypeLastBrowse, + msc.clickNavBtnAutoTrack, + msc.minimapHintAndClose, + } + def.setupAfter = { openSnap, openSnap, browseSnap, openSnap, openSnap, browseSnap, openSnap, doneSnap } + else + def.disabled = true + def.disabledMessage = "No searchable POIs in this zone." + end end end @@ -3260,6 +3697,391 @@ function Demo.Start(ctx) end end + -------------------------------------------------------------------- + -- Disabled-demo overlay: shown over the step list when a demo + -- can't run (e.g. no saved outfits). Created once, toggled by + -- loadDemo based on def.disabled / def.disabledMessage. + -------------------------------------------------------------------- + local disabledOverlay = CreateFrame("Frame", nil, stepScrollFrame) + disabledOverlay:SetAllPoints() + disabledOverlay:SetFrameLevel(stepScrollFrame:GetFrameLevel() + 10) + local disabledText = disabledOverlay:CreateFontString(nil, "OVERLAY", "GameFontNormal") + disabledText:SetPoint("CENTER", 0, 10) + disabledText:SetWidth(180) + disabledText:SetTextColor(0.7, 0.7, 0.7) + disabledOverlay:Hide() + + -------------------------------------------------------------------- + -- Outfits demo + -------------------------------------------------------------------- + local od = {} + + function od.findOutfitEntry() + if not ns.Database or not ns.Database.uiSearchData then return nil end + local activeID = C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetActiveOutfitID + and C_TransmogOutfitInfo.GetActiveOutfitID() + for _, e in ipairs(ns.Database.uiSearchData) do + if e.outfitID and e.outfitID ~= activeID then return e end + end + return nil + end + + od.entry = nil + + function od.enableFilter(done) + local dd = searchFrame.filterDropdown + local outfitRow = dd and dd.checkRows and dd.checkRows.outfits + msui.stepFilterBtnClick(function() + msui.openFilterDropdown() + safeAfter(STEP_PAUSE, function() + if not outfitRow then + EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} + EasyFind.db.uiSearchFilters.outfits = true + safeAfter(0.3, done) + return + end + moveCursorTo(outfitRow, CURSOR_MOVE, function() + if not outfitRow:GetChecked() then + clickAnim(function() + outfitRow:SetChecked(true) + EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} + EasyFind.db.uiSearchFilters.outfits = true + local h = outfitRow:GetScript("OnClick") + if h then pcall(h, outfitRow) end + safeAfter(STEP_PAUSE, done) + end) + else + safeAfter(0.8, done) + end + end) + end) + end) + end + + function od.typeQuery(query, done) + moveCursorTo(searchFrame.editBox, CURSOR_MOVE, function() + clickAnim(function() + msui.closeFilterDropdown() + startBlinkCursor() + safeAfter(STEP_PAUSE, function() + typeText(query, TYPE_DELAY, function() + safeAfter(STEP_PAUSE, done) + end) + end) + end) + end) + end + + -- Bouncing red arrow (same style as the tutorial mode-toggle arrow) + -- that sits outside the results window pointing at a target row. + local OD_ARROW_SIZE = 28 + local odArrowFrame = CreateFrame("Frame", nil, UIParent) + odArrowFrame:SetFrameStrata("TOOLTIP") + odArrowFrame:SetFrameLevel(1002) + odArrowFrame:SetSize(OD_ARROW_SIZE, OD_ARROW_SIZE) + odArrowFrame:SetIgnoreParentAlpha(true) + local odArrowTex = odArrowFrame:CreateTexture(nil, "ARTWORK") + odArrowTex:SetSize(OD_ARROW_SIZE, OD_ARROW_SIZE) + odArrowTex:SetPoint("CENTER") + odArrowTex:SetTexture(1121272) + odArrowTex:SetTexCoord(0.6078, 0.6402, 0.9381, 0.9688) + odArrowTex:SetRotation(math.pi / 2) -- point left (toward results) + local odArrowLabel = odArrowFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge") + odArrowLabel:SetPoint("LEFT", odArrowFrame, "RIGHT", 4, 0) + odArrowLabel:SetTextColor(1.0, 0.82, 0.0) + odArrowLabel:SetShadowColor(0, 0, 0, 1) + odArrowLabel:SetShadowOffset(1, -1) + odArrowFrame:Hide() + + local odPokeElapsed = 0 + local OD_POKE_AMOUNT = 8 + local OD_POKE_PERIOD = 1.4 + odArrowFrame:SetScript("OnUpdate", function(_, dt) + if not odArrowFrame:IsShown() then return end + odPokeElapsed = odPokeElapsed + dt + local offset = math.sin(odPokeElapsed / OD_POKE_PERIOD * math.pi * 2) * OD_POKE_AMOUNT + odArrowTex:SetPoint("CENTER", odArrowFrame, "CENTER", -offset, 0) + end) + + local function odShowArrow(target, labelText) + odArrowFrame:ClearAllPoints() + odArrowFrame:SetPoint("LEFT", target, "RIGHT", 4, 0) + odArrowLabel:SetText(labelText) + odPokeElapsed = 0 + odArrowFrame:Show() + end + + local function odHideArrow() + odArrowFrame:Hide() + end + + -- Wait for the user to actually equip the target outfit + function od.waitForEquip(targetOutfitID, done) + local row = findResultRowByName(od.entry and od.entry.name) or findFirstResultRow() + if not row then done(); return end + stopBlinkCursor() + cursor:Hide() + odShowArrow(row, "Click here to equip outfit") + -- Suppress the lock system and release locks so the user's + -- hardware click reaches the SecureActionButton. + locksSuppressed = true + releaseRunningLocks() + -- Keep the editbox locked so the user can't type during the wait + searchFrame.editBox:EnableMouse(false) + searchFrame.editBox:ClearFocus() + local ticker + ticker = C_Timer.NewTicker(0.2, function() + local activeID = C_TransmogOutfitInfo + and C_TransmogOutfitInfo.GetActiveOutfitID + and C_TransmogOutfitInfo.GetActiveOutfitID() + if activeID == targetOutfitID then + ticker:Cancel() + locksSuppressed = false + applyRunningLocks() + odHideArrow() + od.clearSearch() + transitionFS:SetText("You now have the outfit equipped.") + transitionText:Show() + safeAfter(2.5, function() + transitionFS:SetText("Now let's see how we can pin this for quick access later.") + beginSectionTransition(2) + safeAfter(3.5, function() + transitionText:Hide() + done() + end) + end) + end + end) + od.equipTicker = ticker + end + + function od.pinOutfit(done) + local e = od.entry + if not e then done(); return end + od.typeQuery(slower(e.name):sub(1, 4), function() + local row = findResultRowByName(e.name) or findFirstResultRow() + if not row then done(); return end + moveCursorTo(row, CURSOR_MOVE, function() + mapSearchCallout:SetText("Right-click to pin for quick access.") + mapSearchCallout.frame:ClearAllPoints() + mapSearchCallout.frame:SetPoint("TOP", resultsFrame, "BOTTOM", 0, -8) + mapSearchCallout.frame:Show() + -- Show the right-click mouse indicator next to the cursor + rightClickIcon:Show() + safeAfter(1.0, function() + -- Right-click animation + clickAnim(function() + -- Trigger the real right-click handler to show pin popup + local postClick = row:GetScript("PostClick") + if postClick then postClick(row, "RightButton") end + local popup = _G["EasyFindPinPopup"] + if not popup or not popup:IsShown() then + rightClickIcon:Hide() + if row.data then + UI.PinUIItem(row.data) + od.pinnedByDemo = true + end + safeAfter(1.5, function() + mapSearchCallout:Hide() + od.clearSearch() + safeAfter(0.5, done) + end) + return + end + -- Anchor popup near the row (ShowPinPopup used + -- the real cursor position which is wrong here) + popup:ClearAllPoints() + popup:SetPoint("LEFT", row, "RIGHT", 4, 0) + -- Let the user see the popup for a moment + safeAfter(1.2, function() + rightClickIcon:Hide() + moveCursorTo(popup, CURSOR_MOVE * 0.6, function() + clickAnim(function() + popup:Click() + od.pinnedByDemo = true + safeAfter(1.5, function() + mapSearchCallout:Hide() + safeAfter(0.5, done) + end) + end) + end) + end) + end) + end) + end) + end) + end + + function od.showPinnedAccess(done) + local clearBtn = searchFrame.clearTextBtn + if not clearBtn or not clearBtn:IsShown() then + -- No text to clear, just focus the bar + moveCursorTo(searchFrame.editBox, CURSOR_MOVE, function() + clickAnim(function() + if searchFrame.editBox then + searchFrame.editBox:SetText("") + end + if UI.ShowPinnedItems then + pcall(UI.ShowPinnedItems, UI) + end + safeAfter(1.5, function() + transitionFS:SetText("Now you can easily get to it again later without typing.") + transitionText:Show() + safeAfter(3.0, function() + transitionText:Hide() + od.clearSearch() + cursor:Hide() + safeAfter(0.3, done) + end) + end) + end) + end) + return + end + -- Move to the clear (X) button and click it + moveCursorTo(clearBtn, CURSOR_MOVE, function() + clickAnim(function() + local onClick = clearBtn:GetScript("OnClick") + if onClick then pcall(onClick, clearBtn) end + safeAfter(0.6, function() + -- Now focus the empty search bar to show pinned items + moveCursorTo(searchFrame.editBox, CURSOR_MOVE, function() + clickAnim(function() + if UI.ShowPinnedItems then + pcall(UI.ShowPinnedItems, UI) + end + safeAfter(1.5, function() + transitionFS:SetText("Now you can easily get to it again later without typing.") + transitionText:Show() + safeAfter(3.0, function() + transitionText:Hide() + od.clearSearch() + cursor:Hide() + safeAfter(0.3, done) + end) + end) + end) + end) + end) + end) + end) + end + + function od.clearSearch() + if searchFrame and searchFrame.editBox then + searchFrame.editBox:SetText("") + searchFrame.editBox:ClearFocus() + if UI.OnSearchTextChanged then + pcall(UI.OnSearchTextChanged, UI, "") + end + end + setHoveredRow(nil) + stopBlinkCursor() + end + + function od.cleanup() + odHideArrow() + rightClickIcon:Hide() + mapSearchCallout:Hide() + transitionText:Hide() + if od.equipTicker then od.equipTicker:Cancel(); od.equipTicker = nil end + locksSuppressed = false + if od.pinnedByDemo and od.entry then + pcall(UI.UnpinUIItem, od.entry) + od.pinnedByDemo = false + end + od.clearSearch() + end + + + DEMOS.outfits.supportsModeToggle = false + + DEMOS.outfits.rebuild = function(def) + local outfitEntry = od.findOutfitEntry() + + if not outfitEntry then + def.disabled = true + def.disabledMessage = "Please save an outfit before trying this demo." + def.sections = {} + def.stepDefs = {} + def.run = {} + def.setupAfter = {} + return + end + + def.disabled = false + + od.entry = outfitEntry + od.pinnedByDemo = false + local savedDirectOpen = EasyFind.db.directOpen + local savedFilters = EasyFind.db.uiSearchFilters + and EasyFind.db.uiSearchFilters.outfits + + def.sections = { + { header = "Equip a saved outfit", section = 1, firstStep = 1, lastStep = 3 }, + { header = "Pin for quick access", section = 2, firstStep = 4, lastStep = 5 }, + } + def.stepDefs = { + { text = "Enable Outfits search filter", section = 1 }, + { text = "Start typing an outfit name", section = 1 }, + { text = "Click the outfit to equip it", section = 1 }, + { text = "Right-click to pin it", section = 2 }, + { text = "Focus the search bar to see pins", section = 2 }, + } + + def.run = { + -- 1: Enable filter + function(done) + od.enableFilter(function() + od.entry = od.findOutfitEntry() + done() + end) + end, + -- 2: Type outfit name + function(done) + local e = od.entry + if not e then done(); return end + od.typeQuery(slower(e.name):sub(1, 4), done) + end, + -- 3: Show arrow, wait for user to equip + function(done) + local e = od.entry + if not e then done(); return end + od.waitForEquip(e.outfitID, done) + end, + -- 4: Re-search + right-click to pin + function(done) + transitionText:Hide() + od.pinOutfit(done) + end, + -- 5: Focus bar to show pinned items + function(done) + od.showPinnedAccess(done) + end, + } + + local function snap() + od.cleanup() + EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} + EasyFind.db.uiSearchFilters.outfits = true + end + def.setupAfter = { + function() snap() end, + function() snap() end, + function() snap() end, + function() snap() end, + function() + od.cleanup() + EasyFind.db.directOpen = savedDirectOpen + if EasyFind.db.uiSearchFilters then + EasyFind.db.uiSearchFilters.outfits = savedFilters + end + if od.entry then pcall(UI.UnpinUIItem, od.entry) end + cursor:Hide() + end, + } + end + -------------------------------------------------------------------- -- Appearance Sets demo -------------------------------------------------------------------- @@ -3735,7 +4557,7 @@ function Demo.Start(ctx) end end - local function applyRunningLocks() + applyRunningLocks = function() lockFrame(searchFrame) lockFrame(resultsFrame) -- Demo-specific frames (PlayerSpellsFrame, WorldMapFrame, @@ -3750,23 +4572,32 @@ function Demo.Start(ctx) searchFrame:SetScript("OnDragStart", nil) searchFrame.setupMode = true searchFrame.editBox:EnableMouse(false) + if searchFrame.filterDropdown then + searchFrame.filterDropdown._demoSuspend = true + end lockMapSearchEditBoxes() end - local function releaseRunningLocks() + releaseRunningLocks = function() unlockAllFrames() searchFrame:SetScript("OnDragStart", savedDragStart) searchFrame.setupMode = nil searchFrame.editBox:EnableMouse(true) + if searchFrame.filterDropdown then + searchFrame.filterDropdown._demoSuspend = nil + end unlockMapSearchEditBoxes() end + locksSuppressed = false updateLockState = function() if not active then releaseRunningLocks(); return end + if locksSuppressed then return end local isRunning = (not paused) and (animatingIdx > 0 or autoPlay) if isRunning then applyRunningLocks() else releaseRunningLocks() end + minimapArrow:SetPaused(not isRunning) end local function updateButtons() @@ -3982,6 +4813,8 @@ function Demo.Start(ctx) if minimapCallout then minimapCallout:Hide() end if minimapArrow then minimapArrow:Hide() end if mapSearchCallout then mapSearchCallout:Hide() end + -- Outfit demo cleanup (arrow, ticker, right-click icon, pin) + if od then pcall(od.cleanup) end if clearDemoWaypoint then pcall(clearDemoWaypoint) end if resetMapSearchState then pcall(resetMapSearchState) end if closeWorldMap then pcall(closeWorldMap) end @@ -4144,6 +4977,13 @@ function Demo.Start(ctx) refreshStepList() updateButtons() refreshModeToggle() + -- Disabled demo overlay + if def.disabled then + disabledText:SetText(def.disabledMessage or "This demo is not available.") + disabledOverlay:Show() + else + disabledOverlay:Hide() + end if refreshDemoMenuActive then refreshDemoMenuActive() end end @@ -4312,24 +5152,24 @@ function Demo.Start(ctx) end end) + local function startAutoPlay() + autoPlay = true + updateButtons() + if animatingIdx == 0 then + runStep(completedUpTo + 1) + end + end + playBtn:SetScript("OnClick", function() if not active then return end if paused then - -- Resume from the frozen state. Cursor OnUpdates and the - -- safeAfter queue start advancing again. paused = false updateButtons() elseif autoPlay then - -- Currently running: freeze everything in place. paused = true updateButtons() else - -- Not running: start auto-play from current position. - autoPlay = true - updateButtons() - if animatingIdx == 0 then - runStep(completedUpTo + 1) - end + startAutoPlay() end end) diff --git a/textures/glow-corner.tga b/textures/glow-corner.tga new file mode 100644 index 0000000000000000000000000000000000000000..2d593579dacad7a10ea067141bb447f67906fb93 GIT binary patch literal 1042 zcmZQzU}As)0R{mE1&#mz|1(e-a|6wjCjledFACIWK_Z6f2kDI@1B3K~+zhf8q`#F! z4AL(T#K`)00QtmXT>61V5CsV7KTp6EeELCp_fw!BrWIrdJ}eKEM-M-kUJ#8>FFyCf K%ox>=BMbq%vsA80`U$YMg|}?_^>=sen{#E01$`MM*si- literal 0 HcmV?d00001 diff --git a/tools/gen_glow_tgas.py b/tools/gen_glow_tgas.py new file mode 100644 index 0000000..6ea6ed8 --- /dev/null +++ b/tools/gen_glow_tgas.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Generate multi-layer chamfered glow TGAs for the tutorial box. + +Each layer is a rectangle ring at a specific signed distance from the +frame edge. Each layer's corner is chamfered (diagonally cut) at its +own outer boundary, so the layers stack inside-out and the corners form +a chamfered stair-step instead of hard 90 degree angles. + +Produces three 32-bit BGRA textures (white color, alpha-encoded shape): + + textures/glow-corner.tga (16x16) full chamfered multi-layer corner + textures/glow-edge-h.tga (4x16) horizontal-edge cross-section + textures/glow-edge-v.tga (16x4) vertical-edge cross-section + +Tune the look by editing LAYERS and CHAMFER below, then rerun this +script. No Lua changes are needed unless SIZE changes. +""" + +import os + +# Each entry: (offset, thickness, base_alpha). Offset is signed distance +# from the frame edge (negative = inside the frame, positive = outside). +# Alpha is 0..1 and becomes the layer's on-texture alpha (later further +# multiplied by the pulse animation at runtime). +LAYERS = [ + # Bright band sits OUTSIDE the frame (starting right at the edge), + # so when the pulse dims it, the dark interior gradient isn't + # revealed behind it. Outer glow then fades outward from there. + ( 0, 3, 1.00), # 3 px bright band, starts at frame edge + ( 3, 1, 0.72), + ( 4, 1, 0.52), + ( 5, 1, 0.35), + ( 6, 1, 0.22), + ( 7, 1, 0.12), # outermost, dimmest +] +# Chamfer sizes (each one is a single triangle cut across whichever +# layers it reaches). Separate knobs because the inner bright band is +# much thinner than the outer glow stack, so it needs a smaller cut +# to stay visible. Outer is 5 (not 4) so the cut also takes the +# corner pixel of each outer layer -- otherwise Layer 4's (5,5) and +# Layer 5's (6,4)/(4,6) stick out past the chamfer line as visible +# single-pixel triangles. +CHAMFER_OUTER = 6 # cut at the outermost corner of the whole stack +CHAMFER_INNER = 2 # cut at the innermost corner (inside the frame) +SIZE = 16 # texture dimension (power of two) +FRAME_POS = 8 # position of the frame edge inside the texture +OUTER_MAX = LAYERS[-1][0] + LAYERS[-1][1] # outermost layer boundary, px +INNER_MIN = LAYERS[0][0] # innermost layer offset, px + + +def _alpha_at(dx, dy): + """Float-valued alpha at signed distances (dx, dy) from the frame + edges. Used as the sampling kernel for anti-aliasing.""" + # Outer chamfer triangle + if (OUTER_MAX - CHAMFER_OUTER + 1 <= dx <= OUTER_MAX + and OUTER_MAX - CHAMFER_OUTER + 1 <= dy <= OUTER_MAX + and dx + dy >= 2 * OUTER_MAX - CHAMFER_OUTER + 1): + return 0.0 + + # Inner chamfer triangle + if (INNER_MIN <= dx <= INNER_MIN + CHAMFER_INNER - 1 + and INNER_MIN <= dy <= INNER_MIN + CHAMFER_INNER - 1 + and dx + dy <= 2 * INNER_MIN + CHAMFER_INNER - 1): + return 0.0 + + d = max(dx, dy) + for off, th, alpha in LAYERS: + if off <= d < off + th: + return alpha * 255.0 + return 0.0 + + +_AA_OFFSETS = (-0.375, -0.125, 0.125, 0.375) # 4x4 sub-pixel grid + + +def _is_chamfer_cut(dx, dy): + """True if the pixel at (dx, dy) is removed by either chamfer.""" + if (OUTER_MAX - CHAMFER_OUTER + 1 <= dx <= OUTER_MAX + and OUTER_MAX - CHAMFER_OUTER + 1 <= dy <= OUTER_MAX + and dx + dy >= 2 * OUTER_MAX - CHAMFER_OUTER + 1): + return True + if (INNER_MIN <= dx <= INNER_MIN + CHAMFER_INNER - 1 + and INNER_MIN <= dy <= INNER_MIN + CHAMFER_INNER - 1 + and dx + dy <= 2 * INNER_MIN + CHAMFER_INNER - 1): + return True + return False + + +def corner_alpha(x, y): + """Alpha for pixel (x, y) in the TL corner texture. + + Layer assignment uses the integer Chebyshev distance (so layer-to- + layer and layer-to-transparent boundaries stay SHARP -- no fuzzy + yellow edge on the frame where the bright band meets the dark + interior). AA only runs on the chamfer cut, where we want smoothing. + """ + dx_int = FRAME_POS - x + dy_int = FRAME_POS - y + d_int = max(dx_int, dy_int) + + base_alpha = 0.0 + for off, th, alpha in LAYERS: + if off <= d_int < off + th: + base_alpha = alpha * 255.0 + break + + if base_alpha == 0.0: + return 0 + + # Sub-sample only the chamfer cut decision. Each sub-sample either + # contributes base_alpha (not cut) or 0 (cut); averaging gives a + # soft chamfer diagonal without blurring layer interiors. + cut_count = 0 + for sx in _AA_OFFSETS: + for sy in _AA_OFFSETS: + dx = FRAME_POS - (x + sx) + dy = FRAME_POS - (y + sy) + if _is_chamfer_cut(dx, dy): + cut_count += 1 + kept = 16 - cut_count + return int(round(base_alpha * kept / 16.0)) + + +def _edge_alpha_at(d): + """Float-valued edge alpha at signed distance d.""" + for off, th, alpha in LAYERS: + if off <= d < off + th: + return alpha * 255.0 + return 0.0 + + +def edge_alpha(d): + """Edge cross-section alpha. No AA: we want sharp layer bands and, + importantly, a sharp boundary between Layer 0 and the transparent + frame interior so the bright band doesn't bleed a faint strip into + the frame.""" + return int(round(_edge_alpha_at(d))) + + +def write_tga(path, width, height, pixels): + header = bytes([ + 0, 0, 2, # no id, no colormap, uncompressed truecolor + 0, 0, 0, 0, 0, # colormap spec (unused) + 0, 0, 0, 0, # x/y origin + width & 0xFF, (width >> 8) & 0xFF, # width + height & 0xFF, (height >> 8) & 0xFF, # height + 32, # 32 bits/pixel (BGRA) + 0x28, # 8 alpha bits + top-to-bottom order + ]) + with open(path, "wb") as f: + f.write(header) + f.write(pixels) + + +def gen_corner(path): + data = bytearray() + for y in range(SIZE): + for x in range(SIZE): + a = corner_alpha(x, y) + data.extend([255, 255, 255, a]) + write_tga(path, SIZE, SIZE, bytes(data)) + + +def gen_edge_h(path, width=4): + data = bytearray() + for y in range(SIZE): + a = edge_alpha(FRAME_POS - y) + for _ in range(width): + data.extend([255, 255, 255, a]) + write_tga(path, width, SIZE, bytes(data)) + + +def gen_edge_v(path, height=4): + data = bytearray() + for _ in range(height): + for x in range(SIZE): + a = edge_alpha(FRAME_POS - x) + data.extend([255, 255, 255, a]) + write_tga(path, SIZE, height, bytes(data)) + + +def main(): + out_dir = os.path.join(os.path.dirname(__file__), "..", "textures") + out_dir = os.path.abspath(out_dir) + os.makedirs(out_dir, exist_ok=True) + + gen_corner(os.path.join(out_dir, "glow-corner.tga")) + gen_edge_h(os.path.join(out_dir, "glow-edge-h.tga")) + gen_edge_v(os.path.join(out_dir, "glow-edge-v.tga")) + + print("Wrote:") + for name in ("glow-corner.tga", "glow-edge-h.tga", "glow-edge-v.tga"): + p = os.path.join(out_dir, name) + print(f" {p} ({os.path.getsize(p)} bytes)") + + +if __name__ == "__main__": + main() From a290336c10971fcd297f791cb70c7b53423fd8d0 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 16 Apr 2026 19:19:54 -0400 Subject: [PATCH 014/103] Add chevron tutorial pointer system and extract TutorialBox module Pull the chamfered glow-border tutorial box from Demo into its own TutorialBox module and add TutorialBox.AttachPointer for animated chevron arrows next to any tutorial box. The pointer handles direction-based rotation, trail-back-flush origin anchoring (invariant to box size and TGA tunables), count-staggered cascades, easing, fadeStart, a fixed-endpoint startOffset, onPhase sync callback, and an additive chevron-shaped glow layer. Replaces the bespoke mode-button chevron block in UI.lua with a single AttachPointer call. --- ChevronConfig.lua | 3 + Demo.lua | 140 ++----------------- EasyFind.toc | 2 + TutorialBox.lua | 287 ++++++++++++++++++++++++++++++++++++++ UI.lua | 82 +++++------ textures/chevron-glow.tga | Bin 0 -> 16402 bytes textures/chevron.tga | Bin 0 -> 16402 bytes tools/gen_glow_tgas.py | 198 -------------------------- 8 files changed, 343 insertions(+), 369 deletions(-) create mode 100644 ChevronConfig.lua create mode 100644 TutorialBox.lua create mode 100644 textures/chevron-glow.tga create mode 100644 textures/chevron.tga delete mode 100644 tools/gen_glow_tgas.py diff --git a/ChevronConfig.lua b/ChevronConfig.lua new file mode 100644 index 0000000..ecd75e5 --- /dev/null +++ b/ChevronConfig.lua @@ -0,0 +1,3 @@ +local _, ns = ... +ns.CHEVRON_TEX_SIZE = 64 +ns.CHEVRON_APEX_INSET = 18.5813 diff --git a/Demo.lua b/Demo.lua index 22cef19..346b1c6 100644 --- a/Demo.lua +++ b/Demo.lua @@ -1064,132 +1064,11 @@ function Demo.Start(ctx) end end - -- Builds a tutorial-style floating text box: vertical black-to- - -- dim-yellow interior plus a chamfered glow border rendered - -- from three custom TGAs (corner, horizontal edge, vertical - -- edge) tinted to the neon yellow. The alpha profile -- bright - -- band, soft falloff, diagonal corner chamfer -- is baked into - -- the texture files (see tools/gen_glow_tgas.py), so scaling - -- the frame just re-stretches the edge strips between the - -- fixed-size corner pieces. The glow container pulses via a - -- single alpha animation. - local function createTutorialBox(textFont) - local f = CreateFrame("Frame", nil, UIParent) - f:SetFrameStrata("TOOLTIP") - f:SetFrameLevel(1000) - f:SetIgnoreParentAlpha(true) - - local BR, BG, BB = 1.0, 0.98, 0.45 - local TR, TG, TB = 1.0, 0.96, 0.15 - - -- Interior: black at top fading to a dim yellow-brown at bottom. - local interior = f:CreateTexture(nil, "BACKGROUND", nil, 2) - interior:SetAllPoints(f) - interior:SetColorTexture(1, 1, 1, 1) - interior:SetGradient("VERTICAL", - CreateColor(0.32, 0.26, 0.02, 0.94), - CreateColor(0.00, 0.00, 0.00, 0.96)) - - -- Text (on f so the pulse doesn't dim it). - f.fs = f:CreateFontString(nil, "OVERLAY", textFont or "GameFontNormalLarge") - f.fs:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -12) - f.fs:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -16, 12) - f.fs:SetJustifyH("CENTER") - f.fs:SetJustifyV("MIDDLE") - f.fs:SetTextColor(TR, TG, TB, 1.0) - f.fs:SetShadowColor(0, 0, 0, 1) - f.fs:SetShadowOffset(1, -1) - - -- Chamfered glow border via custom TGA textures. The three - -- files encode the exact alpha profile we want (bright - -- band in the middle, soft falloff on both sides, diagonal - -- chamfer cut at the outer corner), so there's no rect- - -- math stacking. The shape is real pixels in an alpha - -- channel, tinted to the neon yellow via SetVertexColor. - local glow = CreateFrame("Frame", nil, f) - glow:SetAllPoints(f) - - local TEX_CORNER = "Interface\\AddOns\\EasyFind\\textures\\glow-corner" - local TEX_EDGE_H = "Interface\\AddOns\\EasyFind\\textures\\glow-edge-h" - local TEX_EDGE_V = "Interface\\AddOns\\EasyFind\\textures\\glow-edge-v" - local CORNER_SZ = 16 -- native texture dimension - local HALF = CORNER_SZ / 2 - - -- The corner TGA is designed TL-style (outermost pixel at - -- its own top-left). For the other three corners we flip - -- the texture via SetTexCoord's left/right and top/bottom - -- being swapped. - local function addCorner(frameAnchor, dx, dy, texL, texR, texT, texB) - local t = glow:CreateTexture(nil, "BORDER") - t:SetTexture(TEX_CORNER) - t:SetTexCoord(texL, texR, texT, texB) - t:SetVertexColor(BR, BG, BB) - t:SetSize(CORNER_SZ, CORNER_SZ) - t:SetPoint(frameAnchor, f, frameAnchor, dx, dy) - end - addCorner("TOPLEFT", -HALF, HALF, 0, 1, 0, 1) -- TL - addCorner("TOPRIGHT", HALF, HALF, 1, 0, 0, 1) -- TR - addCorner("BOTTOMLEFT", -HALF, -HALF, 0, 1, 1, 0) -- BL - addCorner("BOTTOMRIGHT", HALF, -HALF, 1, 0, 1, 0) -- BR - - -- Edges stretch between the corners. The horizontal edge - -- texture has its cross-section gradient along Y; we flip - -- Y for the bottom edge. Vertical edge texture gradient is - -- along X; we flip X for the right edge. - local function addHEdge(frameAnchorL, frameAnchorR, dyOuter, dyInner, flipY) - local t = glow:CreateTexture(nil, "BORDER") - t:SetTexture(TEX_EDGE_H) - if flipY then t:SetTexCoord(0, 1, 1, 0) else t:SetTexCoord(0, 1, 0, 1) end - t:SetVertexColor(BR, BG, BB) - t:SetPoint("TOPLEFT", f, frameAnchorL, HALF, dyOuter) - t:SetPoint("BOTTOMRIGHT", f, frameAnchorR, -HALF, dyInner) - end - addHEdge("TOPLEFT", "TOPRIGHT", HALF, -HALF, false) - addHEdge("BOTTOMLEFT", "BOTTOMRIGHT", HALF, -HALF, true) - - local function addVEdge(frameAnchorT, frameAnchorB, dxOuter, dxInner, flipX) - local t = glow:CreateTexture(nil, "BORDER") - t:SetTexture(TEX_EDGE_V) - if flipX then t:SetTexCoord(1, 0, 0, 1) else t:SetTexCoord(0, 1, 0, 1) end - t:SetVertexColor(BR, BG, BB) - t:SetPoint("TOPLEFT", f, frameAnchorT, dxOuter, -HALF) - t:SetPoint("BOTTOMRIGHT", f, frameAnchorB, dxInner, HALF) - end - addVEdge("TOPLEFT", "BOTTOMLEFT", -HALF, HALF, false) - addVEdge("TOPRIGHT", "BOTTOMRIGHT", -HALF, HALF, true) - - - -- Neon pulse: bright to slightly dim and back. Floor stays - -- high enough that the outer glow is always visible. - local pulseAG = glow:CreateAnimationGroup() - pulseAG:SetLooping("BOUNCE") - local pulseAnim = pulseAG:CreateAnimation("Alpha") - pulseAnim:SetFromAlpha(0.65) - pulseAnim:SetToAlpha(1.0) - pulseAnim:SetDuration(0.9) - pulseAnim:SetSmoothing("IN_OUT") - pulseAG:Play() - f:HookScript("OnShow", function() pulseAG:Restart() end) - - -- Re-sizes the frame to fit the current text plus padding. - f.SetAutoSized = function(self, maxWidth) - maxWidth = maxWidth or 380 - self.fs:ClearAllPoints() - self.fs:SetPoint("CENTER", self, "CENTER", 0, 0) - self.fs:SetWidth(maxWidth - 32) - local w = mmin(self.fs:GetStringWidth() + 32, maxWidth) - local h = self.fs:GetStringHeight() + 24 - self:SetSize(w, h) - end - - return f - end - -- Transition banner shown at the end of Fast Mode, hidden when -- Guide Mode begins. Anchored near the top-center of the screen -- on the TOOLTIP strata so it floats above every menu and frame. -- A fade-in/out animation group softens the transition. - local transitionFrame = createTutorialBox("GameFontNormalLarge") + local transitionFrame = ns.TutorialBox.Create(UIParent, "GameFontNormalLarge") transitionFrame:SetPoint("TOP", UIParent, "TOP", 0, -160) transitionFrame:SetSize(460, 80) local transitionFS = transitionFrame.fs @@ -1206,7 +1085,7 @@ function Demo.Start(ctx) -- Minimap callout: tutorial-style hint box anchored to the -- minimap, used to draw attention to minimap changes (e.g. -- "your target is now tracked"). - local minimapCalloutFrame = createTutorialBox("GameFontNormalLarge") + local minimapCalloutFrame = ns.TutorialBox.Create(UIParent, "GameFontNormalLarge") minimapCalloutFrame:SetSize(320, 68) if _G["Minimap"] then minimapCalloutFrame:SetPoint("RIGHT", _G["Minimap"], "LEFT", -20, 40) @@ -1228,7 +1107,7 @@ function Demo.Start(ctx) -- Floating narration anchored next to the local map search frame. -- Used by mapSearchCurrent's "browse what's around" step. local mapSearchCallout = {} - mapSearchCallout.frame = createTutorialBox("GameFontNormalLarge") + mapSearchCallout.frame = ns.TutorialBox.Create(UIParent, "GameFontNormalLarge") mapSearchCallout.frame:SetSize(280, 60) mapSearchCallout.frame:Hide() mapSearchCallout.fs = mapSearchCallout.frame.fs @@ -3786,11 +3665,9 @@ function Demo.Start(ctx) odArrowTex:SetTexture(1121272) odArrowTex:SetTexCoord(0.6078, 0.6402, 0.9381, 0.9688) odArrowTex:SetRotation(math.pi / 2) -- point left (toward results) - local odArrowLabel = odArrowFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge") - odArrowLabel:SetPoint("LEFT", odArrowFrame, "RIGHT", 4, 0) - odArrowLabel:SetTextColor(1.0, 0.82, 0.0) - odArrowLabel:SetShadowColor(0, 0, 0, 1) - odArrowLabel:SetShadowOffset(1, -1) + local odArrowBox = ns.TutorialBox.Create(UIParent, "GameFontNormalLarge") + odArrowBox:SetPoint("LEFT", odArrowFrame, "RIGHT", 4, 0) + odArrowBox:Hide() odArrowFrame:Hide() local odPokeElapsed = 0 @@ -3806,13 +3683,16 @@ function Demo.Start(ctx) local function odShowArrow(target, labelText) odArrowFrame:ClearAllPoints() odArrowFrame:SetPoint("LEFT", target, "RIGHT", 4, 0) - odArrowLabel:SetText(labelText) + odArrowBox.fs:SetText(labelText or "") + odArrowBox:SetAutoSized(360) odPokeElapsed = 0 odArrowFrame:Show() + odArrowBox:Show() end local function odHideArrow() odArrowFrame:Hide() + odArrowBox:Hide() end -- Wait for the user to actually equip the target outfit diff --git a/EasyFind.toc b/EasyFind.toc index 68574b9..f248f1b 100644 --- a/EasyFind.toc +++ b/EasyFind.toc @@ -13,6 +13,8 @@ Utils.lua Core.lua Database.lua StaticLocations.lua +TutorialBox.lua +ChevronConfig.lua UI.lua Highlight.lua MapSearch.lua diff --git a/TutorialBox.lua b/TutorialBox.lua new file mode 100644 index 0000000..a96517c --- /dev/null +++ b/TutorialBox.lua @@ -0,0 +1,287 @@ +local _, ns = ... + +local TutorialBox = {} +ns.TutorialBox = TutorialBox + +local BORDER_R, BORDER_G, BORDER_B = 1.0, 0.98, 0.45 +local TEXT_R, TEXT_G, TEXT_B = 1.0, 0.96, 0.15 +local TEX_CORNER = "Interface\\AddOns\\EasyFind\\textures\\glow-corner" +local TEX_EDGE_H = "Interface\\AddOns\\EasyFind\\textures\\glow-edge-h" +local TEX_EDGE_V = "Interface\\AddOns\\EasyFind\\textures\\glow-edge-v" +local CORNER_SZ = 16 +local HALF = 8 + +local mmin = math.min + +function TutorialBox.ApplyStyle(target, opts) + opts = opts or {} + target:SetIgnoreParentAlpha(true) + + local interior = target:CreateTexture(nil, "BACKGROUND", nil, 2) + interior:SetAllPoints(target) + interior:SetColorTexture(1, 1, 1, 1) + interior:SetGradient("VERTICAL", + CreateColor(0.32, 0.26, 0.02, 0.94), + CreateColor(0.00, 0.00, 0.00, 0.96)) + + local glow = CreateFrame("Frame", nil, target) + glow:SetAllPoints(target) + + local function addCorner(anchor, dx, dy, texL, texR, texT, texB) + local t = glow:CreateTexture(nil, "BORDER") + t:SetTexture(TEX_CORNER) + t:SetTexCoord(texL, texR, texT, texB) + t:SetVertexColor(BORDER_R, BORDER_G, BORDER_B) + t:SetSize(CORNER_SZ, CORNER_SZ) + t:SetPoint(anchor, target, anchor, dx, dy) + end + addCorner("TOPLEFT", -HALF, HALF, 0, 1, 0, 1) + addCorner("TOPRIGHT", HALF, HALF, 1, 0, 0, 1) + addCorner("BOTTOMLEFT", -HALF, -HALF, 0, 1, 1, 0) + addCorner("BOTTOMRIGHT", HALF, -HALF, 1, 0, 1, 0) + + local function addHEdge(anchorL, anchorR, flipY) + local t = glow:CreateTexture(nil, "BORDER") + t:SetTexture(TEX_EDGE_H) + if flipY then t:SetTexCoord(0, 1, 1, 0) else t:SetTexCoord(0, 1, 0, 1) end + t:SetVertexColor(BORDER_R, BORDER_G, BORDER_B) + t:SetPoint("TOPLEFT", target, anchorL, HALF, HALF) + t:SetPoint("BOTTOMRIGHT", target, anchorR, -HALF, -HALF) + end + addHEdge("TOPLEFT", "TOPRIGHT", false) + addHEdge("BOTTOMLEFT", "BOTTOMRIGHT", true) + + local function addVEdge(anchorT, anchorB, flipX) + local t = glow:CreateTexture(nil, "BORDER") + t:SetTexture(TEX_EDGE_V) + if flipX then t:SetTexCoord(1, 0, 0, 1) else t:SetTexCoord(0, 1, 0, 1) end + t:SetVertexColor(BORDER_R, BORDER_G, BORDER_B) + t:SetPoint("TOPLEFT", target, anchorT, -HALF, -HALF) + t:SetPoint("BOTTOMRIGHT", target, anchorB, HALF, HALF) + end + addVEdge("TOPLEFT", "BOTTOMLEFT", false) + addVEdge("TOPRIGHT", "BOTTOMRIGHT", true) + + if not opts.noPulse then + local pulseAG = glow:CreateAnimationGroup() + pulseAG:SetLooping("BOUNCE") + local pulseAnim = pulseAG:CreateAnimation("Alpha") + pulseAnim:SetFromAlpha(0.65) + pulseAnim:SetToAlpha(1.0) + pulseAnim:SetDuration(0.9) + pulseAnim:SetSmoothing("IN_OUT") + pulseAG:Play() + target:HookScript("OnShow", function() pulseAG:Restart() end) + end + + return glow +end + +local CHEVRON_TEXTURE = "Interface\\AddOns\\EasyFind\\textures\\chevron" +local CHEVRON_GLOW_TEXTURE = "Interface\\AddOns\\EasyFind\\textures\\chevron-glow" +local DEFAULT_POINTER_TRAVEL = 32 +local DEFAULT_POINTER_DURATION = 1.5 +local DEFAULT_GLOW_ALPHA = 0.35 +-- Slightly more orange than GOLD_COLOR so the halo reads as a warm +-- ember behind the gold core instead of the same hue twice. +local DEFAULT_GLOW_COLOR = { 1.0, 0.60, 0.10 } + +-- Per-direction geometry for AttachPointer: +-- rotation : SetRotation angle so the TGA's downward apex faces `direction` +-- texEdge : which side of the texture rect sits flush with the box border +-- (the "back" edge, opposite the apex direction) +-- boxEdge : the box edge the chevron anchors to (target-side) +-- axisX/Y : signed unit vector of the travel direction (WoW y is +up) +local DIRECTION_DATA = { + right = { rotation = math.pi / 2, texEdge = "LEFT", boxEdge = "RIGHT", axisX = 1, axisY = 0 }, + left = { rotation = -math.pi / 2, texEdge = "RIGHT", boxEdge = "LEFT", axisX = -1, axisY = 0 }, + up = { rotation = math.pi, texEdge = "BOTTOM", boxEdge = "TOP", axisX = 0, axisY = 1 }, + down = { rotation = 0, texEdge = "TOP", boxEdge = "BOTTOM", axisX = 0, axisY = -1 }, +} + +-- Attach an animated chevron pointer to `box`, pointing in `opts.direction` +-- ("right" | "left" | "up" | "down"). At phase 0 a chevron group's +-- trail-back sits flush with the box's direction-side border, so the +-- origin is invariant to box size and to chevron-size tuning in the +-- TGA. Over each cycle a group translates `opts.travel` pixels toward +-- the target, reaching the final position after `opts.duration` seconds, +-- with alpha fading from 1 to 0. +-- +-- opts.count (default 1) spawns multiple chevron groups evenly +-- phase-staggered by 1/count of a cycle, so a fresh one is born every +-- duration/count seconds while the previous one is still fading out. +-- Creates the appearance of an infinite cascade instead of a single +-- bouncing group. +-- +-- opts.fadeStart (default 0) is the phase [0, 1) at which alpha begins +-- dropping from 1 toward 0; alpha is held at 1 before that. End-of-cycle +-- is always fully transparent regardless of fadeStart. 0.75 means the +-- group stays bright for the first 75% of its travel and fades over the +-- final 25%. +-- +-- opts.startOffset (default 0) shifts the origin (phase-0 trail-back +-- position) by that many pixels in the travel direction. Positive +-- pushes the starting point further toward the target; negative pulls +-- it back into the box. The end point is held fixed: the effective +-- per-cycle slide becomes (travel - startOffset), so the apex still +-- ends where it would with startOffset = 0. +-- +-- opts.easing (default 1) is the exponent applied to phase when mapping +-- to slide distance (slide = travel * phase^easing). 1 = linear (constant +-- speed). >1 accelerates: the group moves slowly at first, then faster +-- toward the end. <1 decelerates. The alpha fade curve stays on the +-- unmapped phase, so fade timing is unaffected. +-- +-- opts.glow controls an optional additive halo built from the pre- +-- rendered chevron-glow.tga (trail-apex position, trail angle, legs +-- trimmed to end just past the lead's leg-ends via GLOW_OVERHANG, and +-- softened with a box blur). Pass `true` for the default alpha, a +-- number in (0, 1] to set alpha directly, or false/nil to disable. +-- opts.glowColor (default warm orange) tints the halo. +-- +-- opts.onPhase(phase) is called each tick with the base cycle phase +-- [0, 1), so callers can sync other animations (e.g. a pulsing glow). +-- A fresh group is born whenever (phase * count) crosses an integer. +-- +-- The chevron renders above the tutorial box's own strata, so it stays +-- visible if `travel` pushes it partially over the box's border. +-- +-- Returns { frame, textures, texture = textures[1], Stop(), Show() }. +function TutorialBox.AttachPointer(box, opts) + opts = opts or {} + local dir = DIRECTION_DATA[opts.direction or "right"] + if not dir then + error("TutorialBox.AttachPointer: direction must be 'right', 'left', 'up', or 'down'", 2) + end + + local travel = opts.travel or DEFAULT_POINTER_TRAVEL + local duration = opts.duration or DEFAULT_POINTER_DURATION + local count = opts.count or 1 + local fadeStart = opts.fadeStart or 0 + local startOffset = opts.startOffset or 0 + local easing = opts.easing or 1 + local color = opts.color or ns.GOLD_COLOR or { 1.0, 0.82, 0.0 } + local fadeRange = 1 - fadeStart + + local glowAlpha + if opts.glow == true then + glowAlpha = DEFAULT_GLOW_ALPHA + elseif type(opts.glow) == "number" then + glowAlpha = opts.glow + end + local glowColor = opts.glowColor or DEFAULT_GLOW_COLOR + + local texSize = ns.CHEVRON_TEX_SIZE or 64 + local apexInset = ns.CHEVRON_APEX_INSET or 0 + + local driver = CreateFrame("Frame", nil, box) + driver:SetSize(1, 1) + driver:SetPoint("CENTER", box, "CENTER", 0, 0) + driver:SetIgnoreParentAlpha(true) + driver:SetFrameStrata(box:GetFrameStrata()) + driver:SetFrameLevel(box:GetFrameLevel() + 5) + + local groups = {} + local chevrons = {} + for i = 1, count do + local group = {} + + local core = driver:CreateTexture(nil, "OVERLAY", nil, 0) + core:SetSize(texSize, texSize) + core:SetTexture(CHEVRON_TEXTURE) + core:SetVertexColor(color[1], color[2], color[3]) + core:SetRotation(dir.rotation) + group.core = core + chevrons[i] = core + + if glowAlpha then + local glow = driver:CreateTexture(nil, "OVERLAY", nil, -1) + glow:SetSize(texSize, texSize) + glow:SetTexture(CHEVRON_GLOW_TEXTURE) + glow:SetVertexColor(glowColor[1], glowColor[2], glowColor[3]) + glow:SetRotation(dir.rotation) + glow:SetBlendMode("ADD") + group.glow = glow + end + + groups[i] = group + end + + -- At phase 0 the trail-back sits `apexInset` pixels inside the + -- texture's back edge, so we offset that edge by -apexInset toward + -- the target to make the trail-back flush with the box border. + -- Adding `travel * phase` slides the group further in-direction. + -- Group i runs at phase = (basePhase + (i-1)/count) % 1. + local onPhase = opts.onPhase + local elapsed = 0 + driver:SetScript("OnUpdate", function(_, dt) + elapsed = elapsed + dt + local basePhase = (elapsed / duration) % 1 + local slideRange = travel - startOffset + for i = 1, count do + local phase = (basePhase + (i - 1) / count) % 1 + local slide = startOffset + slideRange * phase ^ easing + local offsetX = dir.axisX * (-apexInset + slide) + local offsetY = dir.axisY * (-apexInset + slide) + local alpha = 1 + if fadeRange > 0 and phase > fadeStart then + alpha = (1 - phase) / fadeRange + end + + local group = groups[i] + group.core:ClearAllPoints() + group.core:SetPoint(dir.texEdge, box, dir.boxEdge, offsetX, offsetY) + group.core:SetAlpha(alpha) + + if group.glow then + -- Glow TGA has the trail apex at the same pixel coord as + -- the core TGA, so the same anchor offset places it + -- correctly; the glow's longer legs simply extend past + -- the core's silhouette on either side. + group.glow:ClearAllPoints() + group.glow:SetPoint(dir.texEdge, box, dir.boxEdge, offsetX, offsetY) + group.glow:SetAlpha(alpha * glowAlpha) + end + end + if onPhase then onPhase(basePhase) end + end) + + local pointer = { frame = driver, textures = chevrons, texture = chevrons[1] } + function pointer:Stop() + driver:SetScript("OnUpdate", nil) + driver:Hide() + end + function pointer:Show() + driver:Show() + end + return pointer +end + +function TutorialBox.Create(parent, textFont) + local f = CreateFrame("Frame", nil, parent or UIParent) + f:SetFrameStrata("TOOLTIP") + f:SetFrameLevel(1000) + + TutorialBox.ApplyStyle(f) + + f.fs = f:CreateFontString(nil, "OVERLAY", textFont or "GameFontNormalLarge") + f.fs:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -12) + f.fs:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -16, 12) + f.fs:SetJustifyH("CENTER") + f.fs:SetJustifyV("MIDDLE") + f.fs:SetTextColor(TEXT_R, TEXT_G, TEXT_B, 1.0) + f.fs:SetShadowColor(0, 0, 0, 1) + f.fs:SetShadowOffset(1, -1) + + f.SetAutoSized = function(self, maxWidth) + maxWidth = maxWidth or 380 + self.fs:ClearAllPoints() + self.fs:SetPoint("CENTER", self, "CENTER", 0, 0) + self.fs:SetWidth(maxWidth - 32) + local w = mmin(self.fs:GetStringWidth() + 32, maxWidth) + local h = self.fs:GetStringHeight() + 24 + self:SetSize(w, h) + end + + return f +end diff --git a/UI.lua b/UI.lua index 50eaa20..fdca57b 100644 --- a/UI.lua +++ b/UI.lua @@ -177,6 +177,8 @@ local function UnpinUIItem(data) end end end +UI.PinUIItem = PinUIItem +UI.UnpinUIItem = UnpinUIItem -- Apply a full transmog set to the DressUpFrame using Blizzard's own -- DressUpTransmogSet. The function's first parameter is misleadingly named @@ -2879,6 +2881,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- Close when clicking outside (but not when interacting with sub-filter popups) dropdown:SetScript("OnUpdate", function(self) + if self._demoSuspend then return end if self:IsShown() and IsMouseButtonDown("LeftButton") then if not self:IsMouseOver() and not toggleBtn:IsMouseOver() then for _, guard in ipairs(dropdownGuardFrames) do @@ -6802,8 +6805,9 @@ function UI:ShowFirstTimeSetup() -- Horizontal separator between the reset button and Smart Show section local sep = panel:CreateTexture(nil, "ARTWORK") sep:SetHeight(1) - sep:SetPoint("TOPLEFT", resetDefaultsBtn, "BOTTOMLEFT", -70, -8) - sep:SetPoint("TOPRIGHT", resetDefaultsBtn, "BOTTOMRIGHT", 70, -8) + sep:SetPoint("LEFT", panel, "LEFT", 12, 0) + sep:SetPoint("RIGHT", panel, "RIGHT", -12, 0) + sep:SetPoint("TOP", resetDefaultsBtn, "BOTTOM", 0, -8) sep:SetColorTexture(0.4, 0.4, 0.4, 0.6) -- Smart Show checkbox (default checked - matches DB_DEFAULTS.smartShow = true) @@ -6871,34 +6875,18 @@ function UI:ShowFirstTimeSetup() -- The frame itself stays fixed next to the mode button; the TEXTURE -- inside the frame slides via a sin wave so only the arrow bounces, -- leaving the label and See Demo button rock-steady. - local ARROW_SIZE = 28 - local modePointerFrame = CreateFrame("Frame", nil, searchFrame) - modePointerFrame:SetSize(ARROW_SIZE, ARROW_SIZE) - modePointerFrame:SetPoint("RIGHT", searchFrame.modeBtn, "LEFT", -4, 0) - modePointerFrame:SetIgnoreParentAlpha(true) - local modePointer = modePointerFrame:CreateTexture(nil, "OVERLAY") - modePointer:SetSize(ARROW_SIZE, ARROW_SIZE) - modePointer:SetPoint("CENTER", modePointerFrame, "CENTER", 0, 0) - modePointer:SetTexture(1121272) - modePointer:SetTexCoord(0.6078, 0.6402, 0.9381, 0.9688) - modePointer:SetRotation(-1.5708) -- -90deg: up -> right - - local POKE_AMOUNT = 10 - local POKE_PERIOD = 1.4 -- seconds per full cycle; shared by both the - -- arrow poke and the mode-button flash so - -- the animations stay phase-locked - local pokeElapsed = 0 - - -- Label and See Demo button anchor to the mode button directly, NOT - -- to the arrow frame, so they don't inherit the poke animation. - local modePointerLabel = searchFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") - modePointerLabel:SetPoint("RIGHT", searchFrame.modeBtn, "LEFT", -4 - ARROW_SIZE - 4, 0) - modePointerLabel:SetText("|cffFFD100Mode toggle|r") - modePointerLabel:SetIgnoreParentAlpha(true) + -- Tutorial label + animated chevron pointing at the mode button. + -- AttachPointer handles rotation, origin (trail-back flush with the + -- label's RIGHT border), and per-cycle translation toward the target. + local modePointerLabel = ns.TutorialBox.Create(searchFrame, "GameFontNormalLarge") + modePointerLabel.fs:SetText("Mode toggle") + modePointerLabel:SetAutoSized(200) + modePointerLabel:ClearAllPoints() + modePointerLabel:SetPoint("RIGHT", searchFrame.modeBtn, "LEFT", -72, 0) local seeDemoBtn = CreateFrame("Button", nil, searchFrame, "UIPanelButtonTemplate") seeDemoBtn:SetSize(120, 24) - seeDemoBtn:SetPoint("TOPRIGHT", modePointerLabel, "BOTTOMRIGHT", 0, -8) + seeDemoBtn:SetPoint("TOP", modePointerLabel, "BOTTOM", 0, -8) seeDemoBtn:SetText("See demo") seeDemoBtn:SetIgnoreParentAlpha(true) @@ -6922,16 +6910,26 @@ function UI:ShowFirstTimeSetup() local FLASH_MIN, FLASH_MAX = 0.15, 0.55 modeFlashFrame:SetAlpha(FLASH_MIN) - -- Shared ticker drives both the arrow poke and the mode-button flash - -- so they're perfectly in sync: arrow closest to button == brightest - -- flash, arrow farthest == dimmest flash. - modePointerFrame:SetScript("OnUpdate", function(_, dt) - pokeElapsed = pokeElapsed + dt - local phase = (1 - math.cos(pokeElapsed * 2 * math.pi / POKE_PERIOD)) * 0.5 - modePointer:ClearAllPoints() - modePointer:SetPoint("CENTER", modePointerFrame, "CENTER", phase * POKE_AMOUNT, 0) - modeFlashFrame:SetAlpha(FLASH_MIN + (FLASH_MAX - FLASH_MIN) * phase) - end) + -- Chevron pointer; its onPhase callback syncs the mode-button flash + -- so the brightest moment of the flash matches a fresh arrow being + -- born at the start position. + local POINTER_COUNT = 2 + local modePointer = ns.TutorialBox.AttachPointer(modePointerLabel, { + direction = "right", + travel = 48, + duration = 1.25, + count = POINTER_COUNT, + fadeStart = 0.75, + startOffset = -10, + easing = 0.5, + glow = 0.7, + -- Flash pulses once per fresh-arrow birth, so it cycles + -- `count` times per animation period. + onPhase = function(phase) + local flashPhase = (phase * POINTER_COUNT) % 1 + modeFlashFrame:SetAlpha(FLASH_MIN + (FLASH_MAX - FLASH_MIN) * (1 - flashPhase)) + end, + }) -- During setup: allow drag without holding Shift searchFrame:SetScript("OnDragStart", function(self) @@ -6957,12 +6955,15 @@ function UI:ShowFirstTimeSetup() resizer:SetScript("OnUpdate", nil) resizer:Hide() panel:Hide() - modePointerFrame:SetScript("OnUpdate", nil) - modePointerFrame:Hide() + modePointer:Stop() modePointerLabel:Hide() seeDemoBtn:Hide() modeFlashFrame:Hide() if demoFrame then demoFrame:Hide() end + -- Clear the demo suspend flag so the dropdown auto-close works + if searchFrame.filterDropdown then + searchFrame.filterDropdown._demoSuspend = nil + end -- Restore shift-only drag searchFrame:SetScript("OnDragStart", function(self) @@ -7016,8 +7017,7 @@ function UI:ShowFirstTimeSetup() -- Hide the positioning UI before the demo runs. panel:Hide() resizer:Hide() - modePointerFrame:SetScript("OnUpdate", nil) - modePointerFrame:Hide() + modePointer:Stop() modePointerLabel:Hide() seeDemoBtn:Hide() modeFlashFrame:Hide() diff --git a/textures/chevron-glow.tga b/textures/chevron-glow.tga new file mode 100644 index 0000000000000000000000000000000000000000..3b28ac7dee844eadcc4485aec1342afe9b16933d GIT binary patch literal 16402 zcmeI0*=to-6o;=#jAKpW7`1wXL8*!<4yY)a-~ctKr6@%aav=^64rm1j5J8-;D)N=w% zftfG|=EEXb2yTey^V_nu~oz^=LzrJ$R^LbB( zEa<-i*28A_6gI(HSOW9FnrguJ(wFYL*3C0t9re^zUmyMS)!#a-CkxhRouxc~Bl^o= zBYX~f;Sd~zcGw2%U@y z3-BeJhZC?Hwt(-(x+Xy(@1-J%Gu}M&)lpAf_4UzDU;VAadWnzb@z=k-aPZwQBU2}LqC1>w~nDYzrA=FzJc%IXZR8B!4>eG z?SvLs0MlVSR6+hTaPGSM#u;y(`Rb^ruKN1ur?39yIKQ=4LoIj*`%QiV_T^dVgh%i@ zynt@_0XpC`dL0$Fr=>&cCw+`#EF6*;S>n#QO zwJySAJ+#3oxCOqO-@sb`fS=$toP#~E3B1R%FcCbrV-{R@-#FvVGhZF`)Ky;}{q)t} zI`W@sDbAg*S%sgyS`YJ~8Fs*NxC#&8DOmF}_yz8O_tt*c3M;`r_d9oV?z;QN8E>BX z>Zqr#`ugmEX3)PLdi$pGYaZ;sSK>Jqrh;c!1>4{VdFZAxvubAIdr8Zx1KdoDd&p<@;G_4RMKz5N|{kG+Jy;5EESM(3`(Z=CUk z{&py|zH-EQ59QyBtC?>H@3ya@3x0*a;U9RLjLuzm-#FvVGk>V>xniGN1vQTW@25ss z0scli2$$h5_@4iSSIOwyb@z=k-aPZwQBU1sYZzT_$KWhnOh)H9aNjuN%`?AP&*2vDz4!sl0RNw& z89swHXirAx&EURq#+x_X&N9$?DmlwIm+Tz8{6M{iYhM1n=sUK@ zQ=Gq#_gt~h+Iz7Y{N4IrTz9`%&4`a@7CjI>5Iqn*5Iqn*5Iqn*5Iqn*5Iqn*5Iqn* O5Iqn*5IxXK5Bv*fyrSy> literal 0 HcmV?d00001 diff --git a/textures/chevron.tga b/textures/chevron.tga new file mode 100644 index 0000000000000000000000000000000000000000..a631a26bd8a2340a0774aa45d839e9267c015fa1 GIT binary patch literal 16402 zcmeI1?`^^`6vU%W&=L69j~O8&FhWLP1UBdhZv;kQ1V+dRj6m;KV#!n{BA}EI?xd^z z;`i6SN|jnwA5}tAHP!0tbUIb0@4yVi^V=ab8y^i1!idihM3QD-Z~HZrLPs9(zUpWj;bkosZIThQw@ z;M<6KkJu?kDUN*PB|pHIeV*f)d)_YfAAMh}LrDFz4k2{NY^;Df)O+T64&BU90UA`Bc41U#5vEN-~Tra3(QgX?6ls3 zPEIL?ctE~9|10d<-_FlJ*z|1HeIevd*Ut!)-kGy04hRhaX389ai^VWbk;!Am7 z>Dx*(@U9?4X55N7u=BgYpSWrvWVW3Mm<_uXyMDz^cZ47IQfANR2+akKVl8sUiVZ@{ z{g=3J8B}2A=vOVX`g?BbdvCJ2xd-g2YtAh{x(_k^9?Y#j_w#<5^qc$5tpRJm8n6be W0c*e-um-FFYrq= 2 * OUTER_MAX - CHAMFER_OUTER + 1): - return 0.0 - - # Inner chamfer triangle - if (INNER_MIN <= dx <= INNER_MIN + CHAMFER_INNER - 1 - and INNER_MIN <= dy <= INNER_MIN + CHAMFER_INNER - 1 - and dx + dy <= 2 * INNER_MIN + CHAMFER_INNER - 1): - return 0.0 - - d = max(dx, dy) - for off, th, alpha in LAYERS: - if off <= d < off + th: - return alpha * 255.0 - return 0.0 - - -_AA_OFFSETS = (-0.375, -0.125, 0.125, 0.375) # 4x4 sub-pixel grid - - -def _is_chamfer_cut(dx, dy): - """True if the pixel at (dx, dy) is removed by either chamfer.""" - if (OUTER_MAX - CHAMFER_OUTER + 1 <= dx <= OUTER_MAX - and OUTER_MAX - CHAMFER_OUTER + 1 <= dy <= OUTER_MAX - and dx + dy >= 2 * OUTER_MAX - CHAMFER_OUTER + 1): - return True - if (INNER_MIN <= dx <= INNER_MIN + CHAMFER_INNER - 1 - and INNER_MIN <= dy <= INNER_MIN + CHAMFER_INNER - 1 - and dx + dy <= 2 * INNER_MIN + CHAMFER_INNER - 1): - return True - return False - - -def corner_alpha(x, y): - """Alpha for pixel (x, y) in the TL corner texture. - - Layer assignment uses the integer Chebyshev distance (so layer-to- - layer and layer-to-transparent boundaries stay SHARP -- no fuzzy - yellow edge on the frame where the bright band meets the dark - interior). AA only runs on the chamfer cut, where we want smoothing. - """ - dx_int = FRAME_POS - x - dy_int = FRAME_POS - y - d_int = max(dx_int, dy_int) - - base_alpha = 0.0 - for off, th, alpha in LAYERS: - if off <= d_int < off + th: - base_alpha = alpha * 255.0 - break - - if base_alpha == 0.0: - return 0 - - # Sub-sample only the chamfer cut decision. Each sub-sample either - # contributes base_alpha (not cut) or 0 (cut); averaging gives a - # soft chamfer diagonal without blurring layer interiors. - cut_count = 0 - for sx in _AA_OFFSETS: - for sy in _AA_OFFSETS: - dx = FRAME_POS - (x + sx) - dy = FRAME_POS - (y + sy) - if _is_chamfer_cut(dx, dy): - cut_count += 1 - kept = 16 - cut_count - return int(round(base_alpha * kept / 16.0)) - - -def _edge_alpha_at(d): - """Float-valued edge alpha at signed distance d.""" - for off, th, alpha in LAYERS: - if off <= d < off + th: - return alpha * 255.0 - return 0.0 - - -def edge_alpha(d): - """Edge cross-section alpha. No AA: we want sharp layer bands and, - importantly, a sharp boundary between Layer 0 and the transparent - frame interior so the bright band doesn't bleed a faint strip into - the frame.""" - return int(round(_edge_alpha_at(d))) - - -def write_tga(path, width, height, pixels): - header = bytes([ - 0, 0, 2, # no id, no colormap, uncompressed truecolor - 0, 0, 0, 0, 0, # colormap spec (unused) - 0, 0, 0, 0, # x/y origin - width & 0xFF, (width >> 8) & 0xFF, # width - height & 0xFF, (height >> 8) & 0xFF, # height - 32, # 32 bits/pixel (BGRA) - 0x28, # 8 alpha bits + top-to-bottom order - ]) - with open(path, "wb") as f: - f.write(header) - f.write(pixels) - - -def gen_corner(path): - data = bytearray() - for y in range(SIZE): - for x in range(SIZE): - a = corner_alpha(x, y) - data.extend([255, 255, 255, a]) - write_tga(path, SIZE, SIZE, bytes(data)) - - -def gen_edge_h(path, width=4): - data = bytearray() - for y in range(SIZE): - a = edge_alpha(FRAME_POS - y) - for _ in range(width): - data.extend([255, 255, 255, a]) - write_tga(path, width, SIZE, bytes(data)) - - -def gen_edge_v(path, height=4): - data = bytearray() - for _ in range(height): - for x in range(SIZE): - a = edge_alpha(FRAME_POS - x) - data.extend([255, 255, 255, a]) - write_tga(path, SIZE, height, bytes(data)) - - -def main(): - out_dir = os.path.join(os.path.dirname(__file__), "..", "textures") - out_dir = os.path.abspath(out_dir) - os.makedirs(out_dir, exist_ok=True) - - gen_corner(os.path.join(out_dir, "glow-corner.tga")) - gen_edge_h(os.path.join(out_dir, "glow-edge-h.tga")) - gen_edge_v(os.path.join(out_dir, "glow-edge-v.tga")) - - print("Wrote:") - for name in ("glow-corner.tga", "glow-edge-h.tga", "glow-edge-v.tga"): - p = os.path.join(out_dir, name) - print(f" {p} ({os.path.getsize(p)} bytes)") - - -if __name__ == "__main__": - main() From e082461e7848c98da0619a2859f783964a5386f2 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 19 Apr 2026 18:22:18 -0400 Subject: [PATCH 015/103] Expand map search demo with chevrons and scrolling narration Replaces the bouncing minimap arrow with chevron pointers attached to the callout boxes, uses a scrolling cross-fade to transition narration text in place, and snapshots WMF's mapID on demo start so navigating to an instance entrance doesn't leak into later demos. Adds a clear-button hint sequence with per-button chevrons, an adaptive tracking-state narration that keys off near/far mode, and multi-row hovers per typed prefix. Prefers the player's actual zone over WMF when picking diverse POIs so the demo works after a map-switching demo ran. Tutorial-box marker lets the cursor helper anchor its tip at the box's bottom border instead of covering text. --- Demo.lua | 896 ++++++++++++++++++++++++++++++++++++++++-------- MapSearch.lua | 2 +- TutorialBox.lua | 10 + 3 files changed, 766 insertions(+), 142 deletions(-) diff --git a/Demo.lua b/Demo.lua index 346b1c6..e70365a 100644 --- a/Demo.lua +++ b/Demo.lua @@ -194,7 +194,7 @@ function Demo.Start(ctx) demoFrame:SetSize(280, 290) -- Shift down from center so the panel doesn't cover the minimap. -- User can still drag it anywhere they want. - demoFrame:SetPoint("RIGHT", UIParent, "RIGHT", -32, -120) + demoFrame:SetPoint("RIGHT", UIParent, "RIGHT", -32, -60) demoFrame:SetFrameStrata("FULLSCREEN_DIALOG") demoFrame:SetIgnoreParentAlpha(true) -- Make the panel draggable so the user can move it out of the @@ -943,6 +943,25 @@ function Demo.Start(ctx) end local savedUiMapSearchLocal = EasyFind.db.uiMapSearchLocal + -- Snapshot WMF's current mapID so demos that navigate WMF (the + -- zone/instance demo calls SelectResult, which SetMapIDs to the + -- instance entrance) don't leak that state into later demos. + -- WMF may not be loaded yet at this point; openWorldMap will + -- lazy-capture the real pre-demo mapID right after it loads the + -- addon. restoreWmfMapID reverts WMF on every demo switch and + -- on panel close. + local savedWmfMapID + if WorldMapFrame and WorldMapFrame.GetMapID then + savedWmfMapID = WorldMapFrame:GetMapID() + end + local function restoreWmfMapID() + if not savedWmfMapID then return end + if InCombatLockdown() then return end + if not (WorldMapFrame and WorldMapFrame.SetMapID) then return end + if WorldMapFrame:GetMapID() == savedWmfMapID then return end + pcall(WorldMapFrame.SetMapID, WorldMapFrame, savedWmfMapID) + end + -- The UI filter dropdown has an OnUpdate handler that auto-hides -- when the user's mouse button is down outside the dropdown. The -- demo's click blockers eat real clicks but the button-down @@ -1084,11 +1103,14 @@ function Demo.Start(ctx) -- Minimap callout: tutorial-style hint box anchored to the -- minimap, used to draw attention to minimap changes (e.g. - -- "your target is now tracked"). + -- "your target is now tracked"). Box sits to the LEFT of the + -- minimap with its vertical center aligned to the minimap's + -- vertical center, with a 65 px gap on the right for the + -- chevron pointer's full visible sweep (see AttachPointer below). local minimapCalloutFrame = ns.TutorialBox.Create(UIParent, "GameFontNormalLarge") - minimapCalloutFrame:SetSize(320, 68) + minimapCalloutFrame:SetSize(220, 60) if _G["Minimap"] then - minimapCalloutFrame:SetPoint("RIGHT", _G["Minimap"], "LEFT", -20, 40) + minimapCalloutFrame:SetPoint("RIGHT", _G["Minimap"], "LEFT", -65, 0) else minimapCalloutFrame:SetPoint("TOPRIGHT", UIParent, "TOPRIGHT", -240, -80) end @@ -1099,31 +1121,207 @@ function Demo.Start(ctx) local minimapCallout = {} function minimapCallout:SetText(text) minimapCalloutFS:SetText(text or "") - minimapCalloutFrame:SetAutoSized(360) + -- Narrower cap than the map-search callout (which has ~300 px + -- of horizontal room to the right of the results frame). + -- Over the minimap area there's less room and the phrases + -- here are short, so 220 keeps the box compact without + -- triggering awkward 3+ line wraps. + minimapCalloutFrame:SetAutoSized(220) end function minimapCallout:Show() minimapCalloutFrame:Show() end function minimapCallout:Hide() minimapCalloutFrame:Hide() end -- Floating narration anchored next to the local map search frame. - -- Used by mapSearchCurrent's "browse what's around" step. + -- Used by mapSearchCurrent's "browse what's around" step. Has an + -- attached left-pointing arrow that is only shown via + -- :ShowWithArrow() for the hover-preview step; other callers use + -- plain :Show() and get the box without the arrow. local mapSearchCallout = {} mapSearchCallout.frame = ns.TutorialBox.Create(UIParent, "GameFontNormalLarge") mapSearchCallout.frame:SetSize(280, 60) mapSearchCallout.frame:Hide() mapSearchCallout.fs = mapSearchCallout.frame.fs + -- Persistent left-pointing chevron. Travel = gap between the box's + -- left edge and the right edge of the results frame, so the apex + -- lands right on the results. Parented to the tutorial box, so it + -- inherits strata/visibility and hides automatically when the box + -- hides. Created once and toggled via the arrow driver's own + -- Show/Hide (preserving the animation's elapsed state). + -- Cadence copied from modePointer (UI.lua) — the canonical + -- tutorial-arrow recipe. Only `direction` and `travel` should + -- vary per caller; the rest stay as-is across every chevron. + mapSearchCallout.arrow = ns.TutorialBox.AttachPointer(mapSearchCallout.frame, { + direction = "left", + travel = 36, + duration = 1.25, + count = 2, + easing = 0.5, + fadeStart = 0.75, + startOffset = -10, + glow = 0.7, + }) + mapSearchCallout.arrow.frame:Hide() function mapSearchCallout:SetText(text) self.fs:SetText(text or "") self.frame:SetAutoSized(340) end function mapSearchCallout:Show() - local lsf = _G["EasyFindMapSearchFrame"] - if lsf then + -- Anchor once per show-session to the results frame (so the + -- gap is right for current scrollbar / mapSearchWidth state), + -- then freeze the position by re-anchoring to UIParent with + -- the absolute coords. Within a session the results frame + -- resizes as the user types different prefixes; without the + -- freeze the callout would jump around with each resize. + -- Hide() resets anchorLocked so the next show recomputes. + if not self.anchorLocked then + local rf = _G["EasyFindMapResultsFrame"] + local lsf = _G["EasyFindMapSearchFrame"] self.frame:ClearAllPoints() - self.frame:SetPoint("LEFT", lsf, "RIGHT", 56, 0) + if rf and rf:IsShown() then + -- 65 px leaves room for the chevron's full ~63 px + -- visible sweep (texSize 64 minus 2 * apexInset of + -- 18.58 = 26.84 visible shape, plus 36 travel) so + -- the apex lands just past the results edge at peak + -- travel without cutting into the result rows. + self.frame:SetPoint("TOPLEFT", rf, "TOPRIGHT", 65, -4) + elseif lsf then + -- Fallback for callsites that run before results open. + self.frame:SetPoint("LEFT", lsf, "RIGHT", 120, -40) + end + -- Show before reading GetLeft/GetTop so the layout pass + -- resolves the anchor to actual screen coords. + self.frame:Show() + local l, t = self.frame:GetLeft(), self.frame:GetTop() + if l and t then + self.frame:ClearAllPoints() + self.frame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", l, t) + self.anchorLocked = true + end end + self.arrow.frame:Hide() self.frame:Show() end - function mapSearchCallout:Hide() self.frame:Hide() end + function mapSearchCallout:ShowWithArrow() + self:Show() + self.arrow.frame:Show() + end + -- Cross-fade the old text up and out while the new text rises up + -- from below to take its place. Box size, anchor, and arrow all + -- stay put. Falls through to a plain Show when the callout isn't + -- already visible (no "old text" to scroll away). Same ease/timing + -- family as setScrollTarget's OnUpdate tween, just applied per + -- fontstring. + mapSearchCallout.textAnim = CreateFrame("Frame") + mapSearchCallout.textAnim:Hide() + function mapSearchCallout:SetTextScrolling(newText) + local isShown = self.frame:IsShown() + local oldText = self.fs:GetText() or "" + if not isShown or oldText == "" then + self:SetText(newText) + self:ShowWithArrow() + return + end + if oldText == newText then return end + -- Lazy-create the secondary fontstring the first time we swap. + -- Shares fs's current color/shadow/justify so the transition + -- reads as the same text just changing words. + if not self.fs2 then + local fs2 = self.frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + fs2:SetJustifyH("CENTER") + fs2:SetJustifyV("MIDDLE") + fs2:SetTextColor(self.fs:GetTextColor()) + fs2:SetShadowColor(0, 0, 0, 1) + fs2:SetShadowOffset(1, -1) + self.fs2 = fs2 + end + -- fs2 carries the OLD text (scrolls up and out). fs takes the + -- NEW text and scrolls up from below into center. + self.fs2:SetWidth(self.fs:GetWidth()) + self.fs2:SetText(oldText) + self.fs2:ClearAllPoints() + self.fs2:SetPoint("CENTER", self.frame, "CENTER", 0, 0) + self.fs2:SetAlpha(1) + self.fs:SetText(newText) + self.fs:ClearAllPoints() + self.fs:SetPoint("CENTER", self.frame, "CENTER", 0, -20) + self.fs:SetAlpha(0) + local duration = 0.35 + local elapsed = 0 + self.textAnim:SetScript("OnUpdate", function(_, dt) + elapsed = elapsed + dt + local t = elapsed / duration + if t > 1 then t = 1 end + local eased = t * t * (3 - 2 * t) + self.fs2:ClearAllPoints() + self.fs2:SetPoint("CENTER", self.frame, "CENTER", 0, 20 * eased) + self.fs2:SetAlpha(1 - eased) + self.fs:ClearAllPoints() + self.fs:SetPoint("CENTER", self.frame, "CENTER", 0, -20 * (1 - eased)) + self.fs:SetAlpha(eased) + if t >= 1 then + self.textAnim:SetScript("OnUpdate", nil) + self.textAnim:Hide() + self.fs:ClearAllPoints() + self.fs:SetPoint("CENTER", self.frame, "CENTER", 0, 0) + self.fs:SetAlpha(1) + self.fs2:SetAlpha(0) + self.fs2:SetText("") + end + end) + self.textAnim:Show() + end + function mapSearchCallout:Hide() + -- Cancel any in-flight scroll so a later Show() starts fresh. + if self.textAnim then + self.textAnim:SetScript("OnUpdate", nil) + self.textAnim:Hide() + end + if self.moveAnim then + self.moveAnim:SetScript("OnUpdate", nil) + self.moveAnim:Hide() + end + if self.fs2 then + self.fs2:SetAlpha(0) + self.fs2:SetText("") + end + -- Drop the locked absolute anchor so the next Show() + -- re-computes against whatever state the results frame is + -- in at that point (scrollbar present/absent, different + -- mapSearchWidth, etc.). + self.anchorLocked = nil + self.fs:ClearAllPoints() + self.fs:SetPoint("CENTER", self.frame, "CENTER", 0, 0) + self.fs:SetAlpha(1) + self.arrow.frame:Hide() + self.frame:Hide() + end + + -- Emitter frames for the two clear-button chevrons. Each emitter + -- sits ~63 px to the LEFT of its clear button at show time; the + -- attached chevron emerges through its right border and its + -- phase-1 apex lands at the button's left edge. Emitters are 1 + -- px so they don't render anything themselves. Cadence copied + -- from modePointer (the canonical tutorial-arrow recipe). + local function makeClearChevron() + local emitter = CreateFrame("Frame", nil, UIParent) + emitter:SetSize(1, 1) + emitter:SetFrameStrata("TOOLTIP") + emitter:Hide() + local chev = ns.TutorialBox.AttachPointer(emitter, { + direction = "right", + travel = 36, + duration = 1.25, + count = 2, + easing = 0.5, + fadeStart = 0.75, + startOffset = -10, + glow = 0.7, + }) + chev.frame:Hide() + return { emitter = emitter, chev = chev } + end + local localClearPointer = makeClearChevron() + local globalClearPointer = makeClearChevron() -- Forward decl so endDemo / cancelInFlight / resetDemoGameState -- can call minimapArrow:Hide() via upvalue. The methods are @@ -1133,6 +1331,12 @@ function Demo.Start(ctx) function minimapArrow:Show() end function minimapArrow:Hide() end + -- Ticker used by the end-of-demo tracking-state narration to + -- poll EasyFindNearTrack:IsShown() until the player crosses + -- into the other mode. Held at this scope so endDemo / + -- cancelInFlight can cancel it if the demo is aborted. + local trackingStateTicker + local function endDemo() active = false paused = false @@ -1148,6 +1352,11 @@ function Demo.Start(ctx) clearButtonHover() transitionText:Hide() mapSearchCallout:Hide() + localClearPointer.chev.frame:Hide() + localClearPointer.emitter:Hide() + globalClearPointer.chev.frame:Hide() + globalClearPointer.emitter:Hide() + if trackingStateTicker then trackingStateTicker:Cancel(); trackingStateTicker = nil end minimapCallout:Hide() minimapArrow:Hide() hideMapCaret() @@ -1171,6 +1380,11 @@ function Demo.Start(ctx) end -- Clean up map search state if a map demo was running. if resetMapSearchState then pcall(resetMapSearchState) end + -- Revert WMF mapID so the user's map returns to whatever + -- they had open before starting the demo, not wherever a + -- demo step navigated to (e.g. the instance entrance the + -- zone/instance demo jumps to). + restoreWmfMapID() if closeWorldMap then pcall(closeWorldMap) end -- Release every blocker, restore drag handler and editbox. if updateLockState then updateLockState() end @@ -1383,15 +1597,38 @@ function Demo.Start(ctx) end local function moveCursorTo(targetFrame, duration, onArrive, offsetX, offsetY) - -- Clear any lingering hover state from the previous target - -- (tooltip, button outline, highlight) so it doesn't persist - -- while the cursor is in transit to a new frame. - clearButtonHover() + -- Previous target's hover state (tooltip, button outline, + -- highlight) is cleared below once the cursor has visibly + -- moved off it, not at the start. Clearing at the start + -- would make the tooltip dismiss while the cursor is still + -- sitting on the button it's about to leave. + local isTutorialBox = targetFrame and targetFrame._isTutorialBox local tx, ty = centerInUIParent(targetFrame) if not tx then if onArrive then onArrive() end return end + -- Default landing placements (skipped if caller passed explicit + -- offsets). Two cases: + -- * Tutorial boxes: tip at the bottom-border center, sprite + -- extends below the frame so it never covers the text. + -- * Small icon buttons (<=50x50 Button): tip in the lower- + -- right quadrant so the icon below stays visible. + if not offsetX and not offsetY then + if isTutorialBox then + local bottom = targetFrame:GetBottom() + if bottom then + ty = bottom - 4 + end + elseif targetFrame.IsObjectType and targetFrame:IsObjectType("Button") then + local w = targetFrame:GetWidth() or 0 + local h = targetFrame:GetHeight() or 0 + if w > 0 and h > 0 and w <= 50 and h <= 50 then + offsetX = w * 0.3 + offsetY = -h * 0.3 + end + end + end if offsetX then tx = tx + offsetX end if offsetY then ty = ty + offsetY end -- If the cursor isn't visible yet, start it at the middle @@ -1407,6 +1644,7 @@ function Demo.Start(ctx) local sy = cursor:GetTop() - 4 local elapsed = 0 local myGen = stepGen + local hoverCleared = false cursor:SetScript("OnUpdate", function(self, dt) if not active or myGen ~= stepGen then self:SetScript("OnUpdate", nil) @@ -1419,9 +1657,30 @@ function Demo.Start(ctx) local eased = t * t * (3 - 2 * t) -- smoothstep local nx = sx + (tx - sx) * eased local ny = sy + (ty - sy) * eased + -- Release the previous target's tooltip/highlight once + -- the cursor has traveled ~20 px from the start — far + -- enough to be clearly off a small icon button. This + -- lets the tooltip linger while the cursor is still + -- visibly on the button it just left. + if not hoverCleared then + local dx, dy = nx - sx, ny - sy + if dx * dx + dy * dy >= 400 then + hoverCleared = true + clearButtonHover() + end + end placeCursorAt(nx, ny) if t >= 1 then self:SetScript("OnUpdate", nil) + -- For a tutorial box, re-anchor the cursor's TOPLEFT + -- to the frame's BOTTOM so it follows if the box + -- resizes later (e.g. SetText grows the height for + -- a longer message). The -4/0 offset puts the tip + -- at bottom-center with no overlap into the frame. + if isTutorialBox and not offsetX and not offsetY then + cursor:ClearAllPoints() + cursor:SetPoint("TOPLEFT", targetFrame, "BOTTOM", -4, 0) + end if onArrive then pcall(onArrive) end end end) @@ -2115,6 +2374,14 @@ function Demo.Start(ctx) if C_AddOns and C_AddOns.LoadAddOn then pcall(C_AddOns.LoadAddOn, "Blizzard_WorldMap") end + -- Lazy-capture the pre-demo WMF mapID: if WMF wasn't loaded + -- at Demo.Start, this is the first moment it exists with a + -- valid mapID, and we need that value to restore on demo + -- switch / panel close. Capture BEFORE ToggleWorldMap since + -- opening the map may trigger a SetMapID of its own. + if savedWmfMapID == nil and WorldMapFrame and WorldMapFrame.GetMapID then + savedWmfMapID = WorldMapFrame:GetMapID() + end if not WorldMapFrame or not WorldMapFrame:IsShown() then pcall(ToggleWorldMap) end @@ -2524,89 +2791,42 @@ function Demo.Start(ctx) end end - -- Bouncing tutorial-style arrow anchored under the callout text, - -- pointing up at the text so the user notices it. Uses the same - -- indicator texture as the tutorial Highlight engine, following - -- the user's selected style and color. - local minimapArrowFrame = CreateFrame("Frame", nil, UIParent) - minimapArrowFrame:SetFrameStrata("TOOLTIP") - minimapArrowFrame:SetFrameLevel(1001) - local ARROW_SIZE = ns.ICON_SIZE or 48 - minimapArrowFrame:SetSize(ARROW_SIZE, ARROW_SIZE) - minimapArrowFrame:SetIgnoreParentAlpha(true) - -- Sits directly below the callout text. - -- Sit directly against the minimap's left edge, just below the - -- callout text vertically, so the arrow is visibly next to what - -- it's pointing at. Falls back to the callout anchor if the - -- minimap isn't available. - if _G["Minimap"] then - minimapArrowFrame:SetPoint("RIGHT", _G["Minimap"], "LEFT", -4, -20) - else - minimapArrowFrame:SetPoint("TOP", minimapCalloutFrame, "BOTTOM", 0, -4) - end - - local minimapArrowTex = minimapArrowFrame:CreateTexture(nil, "ARTWORK") - minimapArrowTex:SetAllPoints() - - -- Apply current indicator style/color on show, and rotate so - -- the arrow points RIGHT toward the minimap (the minimap is to - -- the right of the callout text, and this arrow sits under the - -- text). CreateIndicatorTextures' `style.rotation` field (or - -- the preRotated vs not-preRotated default) is the rotation - -- that makes the texture point DOWN in tutorial mode; adding - -- pi/2 rotates CCW 90 degrees, taking "down" to "right". - local function refreshMinimapArrowStyle() - local style = ns.GetIndicatorTexture and ns.GetIndicatorTexture() - or { texture = "Interface\\AddOns\\EasyFind\\Images\\arrow-hq", preRotated = true } - local color = ns.GetIndicatorColor and ns.GetIndicatorColor() - or { 1.0, 0.82, 0.0 } - minimapArrowTex:SetTexture(style.texture) - if style.texCoord then - minimapArrowTex:SetTexCoord(unpack(style.texCoord)) - else - minimapArrowTex:SetTexCoord(0, 1, 0, 1) - end - minimapArrowTex:SetVertexColor(color[1], color[2], color[3], 1) - local pointsDown - if style.rotation then - pointsDown = style.rotation - elseif style.preRotated then - pointsDown = 0 - else - pointsDown = math.pi - end - minimapArrowTex:SetRotation(pointsDown + math.pi / 2) - end - minimapArrowFrame:HookScript("OnShow", refreshMinimapArrowStyle) - minimapArrowFrame:Hide() - - -- Bounce horizontally toward the minimap. Same BOUNCE pattern as - -- Highlight.lua's tutorial indicator, just sideways so the - -- motion reinforces the pointing direction. - local minimapArrowAG = minimapArrowFrame:CreateAnimationGroup() - minimapArrowAG:SetLooping("BOUNCE") - local arrowBounce = minimapArrowAG:CreateAnimation("Translation") - arrowBounce:SetOffset(10, 0) - arrowBounce:SetDuration(0.4) + -- Tutorial chevron pointer attached to the minimap callout box, + -- animating rightward into the minimap. Gap between the box's + -- right edge and the minimap's left edge (set on the callout's + -- SetPoint above) is sized so the chevron's full visible sweep + -- (~27 px shape + 36 px travel) lands the apex just shy of the + -- minimap edge at peak travel, without intruding. + -- Cadence copied from modePointer (canonical recipe). + local minimapChevron = ns.TutorialBox.AttachPointer(minimapCalloutFrame, { + direction = "right", + travel = 36, + duration = 1.25, + count = 2, + easing = 0.5, + fadeStart = 0.75, + startOffset = -10, + glow = 0.7, + }) + minimapChevron.frame:Hide() -- Attach real methods to the forward-declared minimapArrow table -- so upvalues in endDemo / cancelInFlight / resetDemoGameState - -- see the live implementation. + -- see the live implementation. The old bouncing indicator + -- texture was replaced with chevrons attached to the callout + -- box, so Show/Hide now just toggle the chevron driver frame's + -- visibility (OnUpdate pauses automatically while hidden). function minimapArrow:Show() - minimapArrowFrame:Show() - minimapArrowAG:Play() + minimapChevron.frame:Show() end function minimapArrow:Hide() - minimapArrowAG:Stop() - minimapArrowFrame:Hide() + minimapChevron.frame:Hide() end - function minimapArrow:SetPaused(isPaused) - if not minimapArrowFrame:IsShown() then return end - if isPaused then - minimapArrowAG:Pause() - else - minimapArrowAG:Play() - end + function minimapArrow:SetPaused(_) + -- No-op: chevron OnUpdate is bound to parent visibility, and + -- the callout itself stays shown during a pause, so the + -- chevron keeps cycling. Acceptable tradeoff for how rarely + -- users pause mid-callout. end -- Show the minimap callout text and a bouncing arrow pointing @@ -2695,6 +2915,229 @@ function Demo.Start(ctx) end) end + -- Inline hint shown right after a result is clicked and the + -- highlights activate: scroll mapSearchCallout's text from the + -- "Now let's click this result" narration to a short sentence + -- calling out the clear buttons, point a chevron at each, and + -- fire the local button's real tooltip so the user sees what + -- they do. No separate step in the demo list; runs between the + -- click-animation settle and the minimap callout. + function msc.clearBtnHint(done) + local lsf = _G["EasyFindMapSearchFrame"] + local gsf = _G["EasyFindMapGlobalSearchFrame"] + local localBtn = lsf and lsf.clearBtn + local globalBtn = gsf and gsf.clearBtn + local haveLocal = localBtn and localBtn:IsShown() + local haveGlobal = globalBtn and globalBtn:IsShown() + if not haveLocal and not haveGlobal then + done(); return + end + -- Hide the callout's own left-pointing chevron: the two + -- clear-button chevrons below already point at their real + -- targets, so a third arrow from the callout is noise. + mapSearchCallout.arrow.frame:Hide() + -- Two sequential messages. The first sets context; the + -- second is the call-to-action that the chevrons + cursor + -- tooltip will reinforce. Pre-measure the box size required + -- for each, take the max, then tween the box to that size + -- alongside the slide below. Staying at one size across + -- both scrolls avoids a second resize mid-sequence. + local hintContext = "Clear icons will be visible anytime a highlight is active." + local hintAction = "Click any to clear all highlights." + local startW = mapSearchCallout.frame:GetWidth() + local startH = mapSearchCallout.frame:GetHeight() + local savedText = mapSearchCallout.fs:GetText() or "" + mapSearchCallout.fs:SetText(hintContext) + mapSearchCallout.frame:SetAutoSized(340) + local wCtx = mapSearchCallout.frame:GetWidth() + local hCtx = mapSearchCallout.frame:GetHeight() + mapSearchCallout.fs:SetText(hintAction) + mapSearchCallout.frame:SetAutoSized(340) + local wAct = mapSearchCallout.frame:GetWidth() + local hAct = mapSearchCallout.frame:GetHeight() + local targetW = wCtx > wAct and wCtx or wAct + local targetH = hCtx > hAct and hCtx or hAct + mapSearchCallout.fs:SetText(savedText) + mapSearchCallout.frame:SetSize(startW, startH) + mapSearchCallout:SetTextScrolling(hintContext) + -- Slide the callout from its anchored spot next to the + -- results frame down and to the left, so it lands centered + -- below the two map search bars. Tween position and size + -- together so the box grows to fit the two-line text in + -- sync with the slide. + local startL = mapSearchCallout.frame:GetLeft() + local startT = mapSearchCallout.frame:GetTop() + local leftEdge = lsf and lsf:GetLeft() + local rightEdge = gsf and gsf:GetRight() + local lsfBot = lsf and lsf:GetBottom() + local gsfBot = gsf and gsf:GetBottom() + if startL and startT and leftEdge and rightEdge and lsfBot and gsfBot then + local targetL = (leftEdge + rightEdge) * 0.5 - targetW * 0.5 + local targetT = (lsfBot < gsfBot and lsfBot or gsfBot) - 28 + if not mapSearchCallout.moveAnim then + mapSearchCallout.moveAnim = CreateFrame("Frame") + mapSearchCallout.moveAnim:Hide() + end + local moveAnim = mapSearchCallout.moveAnim + moveAnim:SetScript("OnUpdate", nil) + local dur = 0.5 + local el = 0 + moveAnim:SetScript("OnUpdate", function(_, dt) + el = el + dt + local t = el / dur + if t > 1 then t = 1 end + local eased = t * t * (3 - 2 * t) + local nl = startL + (targetL - startL) * eased + local nt = startT + (targetT - startT) * eased + local nw = startW + (targetW - startW) * eased + local nh = startH + (targetH - startH) * eased + mapSearchCallout.frame:SetSize(nw, nh) + mapSearchCallout.frame:ClearAllPoints() + mapSearchCallout.frame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", nl, nt) + if t >= 1 then + moveAnim:SetScript("OnUpdate", nil) + moveAnim:Hide() + end + end) + moveAnim:Show() + end + -- Chevron phase-1 apex lands ~63 px right of emitter.RIGHT + -- (visible 27 + travel 36). Anchoring emitter.RIGHT to the + -- button's LEFT with a -63 offset puts the apex right at + -- the button's left edge at peak travel. + local function showChevronAt(pointer, btn) + if not btn then return end + pointer.emitter:ClearAllPoints() + pointer.emitter:SetPoint("RIGHT", btn, "LEFT", -63, 0) + pointer.emitter:Show() + pointer.chev.frame:Show() + end + local hoverBtn = haveLocal and localBtn or globalBtn + -- Fires once the cursor has travelled to the clear button. + -- Triggers the real OnEnter tooltip, waits, then runs the + -- "Let's try it." -> click -> retype -> reclick sequence + -- before handing off to done() for the minimap callout. + local function clearHintOnArrive() + local onEnter = hoverBtn:GetScript("OnEnter") + if onEnter then pcall(onEnter, hoverBtn) end + safeAfter(2.8, function() + GameTooltip_Hide() + localClearPointer.chev.frame:Hide() + localClearPointer.emitter:Hide() + globalClearPointer.chev.frame:Hide() + globalClearPointer.emitter:Hide() + mapSearchCallout:SetTextScrolling("Let's try it.") + safeAfter(2.0, function() + clickAnim(function() + local onClick = hoverBtn:GetScript("OnClick") + if onClick then + pcall(onClick, hoverBtn, "LeftButton", true) + end + safeAfter(0.9, function() + mapSearchCallout:SetTextScrolling("Now let's quickly get tracking again.") + safeAfter(1.8, function() + -- Keep the callout visible through + -- retype + cursor travel so the user + -- reads the narration the whole way; + -- hide it right at the click. + msc.retypeLastBrowse(function() + local lastPOI = msc.lastBrowsePOI + local target = (lastPOI and findMapResultByName(lastPOI.name)) + or findFirstVisibleMapResult() + if not target then + mapSearchCallout:Hide() + done(); return + end + moveCursorTo(target, CURSOR_MOVE, function() + hideMapCaret() + clickAnim(function() + if target._demoMapHover then target._demoMapHover = nil end + setHoveredRow(nil) + mapSearchCallout:Hide() + if target.data and ns.MapSearch then + pcall(ns.MapSearch.SelectResult, ns.MapSearch, target.data) + end + safeAfter(SETTLE_PAUSE, done) + end) + end) + end) + end) + end) + end) + end) + end) + end + -- Chevrons show immediately so the callout text and the + -- buttons it refers to are pointed at from the first + -- word. Cursor travel + tooltip wait until the text rolls + -- to the action sentence so the visual call-to-action + -- aligns with the imperative. + if haveLocal then showChevronAt(localClearPointer, localBtn) end + if haveGlobal then showChevronAt(globalClearPointer, globalBtn) end + safeAfter(2.2, function() + mapSearchCallout:SetTextScrolling(hintAction) + moveCursorTo(hoverBtn, CURSOR_MOVE, clearHintOnArrive) + end) + end + + -- After the "point is now being tracked" callout, detect + -- whether the player is currently in NEAR mode (circle+arrow + -- visible) or FAR mode (perimeter glow). Then narrate a + -- "move to see it flip" flow tailored to their starting + -- state. EasyFindNearTrack is the frame that only appears + -- when the POI is within 75% of the minimap's radar radius + -- (see MapSearch.lua:769), so its visibility is the mode + -- test. + function msc.trackingStateNarration(done) + local nearTrack = _G["EasyFindNearTrack"] + local glow = _G["EasyFindMinimapGlow"] + local function inNearMode() + return nearTrack and nearTrack:IsShown() + end + local function trackingActive() + local nearShown = nearTrack and nearTrack:IsShown() + local glowShown = glow and glow:IsShown() + return nearShown or glowShown + end + local function cancelTicker() + if trackingStateTicker then + trackingStateTicker:Cancel() + trackingStateTicker = nil + end + end + local function pollUntil(predicate, onArrive) + cancelTicker() + trackingStateTicker = C_Timer.NewTicker(0.25, function() + if predicate() then + cancelTicker() + onArrive() + end + end) + end + -- Final wait: don't end the demo until the player has + -- actually reached the POI and super-tracking has cleared + -- (both the near-track frame and the perimeter glow are + -- hidden). The user asked for this explicitly; no timeout. + local function waitForArrival(onArrive) + pollUntil(function() return not trackingActive() end, onArrive) + end + if inNearMode() then + minimapCallout:SetText("Try moving further to see the perimeter arrow glow.\n(Glow can be turned off in settings.)") + pollUntil(function() return not inNearMode() end, function() + minimapCallout:SetText("Venture all the way to the point to see the circle shrink.") + pollUntil(inNearMode, function() + waitForArrival(done) + end) + end) + else + minimapCallout:SetText("Try moving closer to place the guiding circle around you.\n(Glow can be turned off in settings.)") + pollUntil(inNearMode, function() + minimapCallout:SetText("Venture all the way to the point to see the circle shrink.") + waitForArrival(done) + end) + end + end + function msc.clickResult(wantFast, done) ensureLocalMapMode(wantFast) local lastPOI = msc.lastBrowsePOI @@ -2704,9 +3147,21 @@ function Demo.Start(ctx) safeAfter(0.5, done) return end - moveCursorTo(target, CURSOR_MOVE, function() - hideMapCaret() - clickAnim(function() + -- Announce the next action. Scroll-transition the text so + -- the callout (already showing "Hovering over a result..." + -- at the end of browseWhatsAround) stays put and its text + -- ticker-rolls to the new narration instead of popping out + -- and back. Falls back to Show if the callout wasn't already + -- open (e.g. the browse step was skipped). + mapSearchCallout:SetTextScrolling("Now let's click this result.") + -- Extra beat before the click so the user has time to read + -- the freshly-scrolled narration (CURSOR_MOVE below adds + -- another 0.8s of reading while the cursor travels to the + -- row). + safeAfter(2.8, function() + moveCursorTo(target, CURSOR_MOVE, function() + hideMapCaret() + clickAnim(function() -- Clear the hover tag so setHoveredRow fires OnLeave -- when SelectResult hides the results frame. if target._demoMapHover then target._demoMapHover = nil end @@ -2716,27 +3171,45 @@ function Demo.Start(ctx) end if wantFast then safeAfter(SETTLE_PAUSE, function() - minimapCallout:SetText("Notice how your target is now automatically tracked!") - minimapCallout:Show() - minimapArrow:Show() - moveCursorTo(minimapCalloutFrame, CURSOR_MOVE, function() - safeAfter(2.5, function() - minimapCallout:Hide() - minimapArrow:Hide() - closeMapAndFinish(done) + -- Scroll the callout's text from "Now let's + -- click this result." to the clear-button + -- hint, point chevrons at both clear + -- buttons, fire the local one's tooltip. + -- Then hand off to the minimap tracking + -- callout. + msc.clearBtnHint(function() + mapSearchCallout:Hide() + minimapCallout:SetText("The point is now being tracked.") + minimapCallout:Show() + minimapArrow:Show() + moveCursorTo(minimapCalloutFrame, CURSOR_MOVE, function() + safeAfter(2.5, function() + msc.trackingStateNarration(function() + minimapCallout:Hide() + minimapArrow:Hide() + closeMapAndFinish(done) + end) + end) end) - end, 0, -45) + end) end) else + -- Guide mode: leave the callout visible so the + -- next step (clickPinTrackContinue) can scroll + -- its text from "Now let's click..." to the pin + -- instructions without a pop-out / pop-back. safeAfter(SETTLE_PAUSE, done) end + end) end) end) end function msc.clickPinTrackContinue(done) - mapSearchCallout:SetText("Click the pin to place a waypoint and start tracking.") - mapSearchCallout:Show() + -- Scroll-transition from the previous step's "Now let's click + -- this result." callout so the box doesn't pop out and back. + -- Falls through to a normal Show if the callout is hidden. + mapSearchCallout:SetTextScrolling("Click the pin to place a waypoint and start tracking.") clickPinAndTrack(function() safeAfter(1.5, function() mapSearchCallout:Hide() @@ -2768,7 +3241,7 @@ function Demo.Start(ctx) safeAfter(0.4, done) end end) - end, 0, -45) + end) end) end) end @@ -2806,8 +3279,31 @@ function Demo.Start(ctx) function msc.pickDiversePOIs() if not (ns.MapSearch and ns.MapSearch.GetStaticLocations) then return {} end - local pois = ns.MapSearch:GetStaticLocations() - if not pois then return {} end + -- This demo is about what's around the player right now, so + -- prefer the player's actual zone. WMF's mapID is only a + -- fallback because the zone/instance demo navigates WMF to + -- an instance entrance map (no POIs) via SelectResult and + -- stays there after the demo closes. Using WMF as the + -- primary source would incorrectly disable this demo on the + -- very next switch. GetBestMapForUnit can return nil + -- transiently (loading screens, very early init), so cache + -- a last-good mapID as a final fallback. Same priority as + -- MapSearch:SearchForUI's local search. + local getBest = _G["GetBestMapForUnit"] + local playerMapID = getBest and getBest("player") + local wmfMapID = WorldMapFrame and WorldMapFrame:GetMapID() + local pois = playerMapID and ns.MapSearch:GetStaticLocations(playerMapID) + if pois and #pois > 0 then + msc.lastGoodMapID = playerMapID + else + pois = wmfMapID and ns.MapSearch:GetStaticLocations(wmfMapID) + if pois and #pois > 0 then + msc.lastGoodMapID = wmfMapID + elseif msc.lastGoodMapID then + pois = ns.MapSearch:GetStaticLocations(msc.lastGoodMapID) + end + end + if not pois or #pois == 0 then return {} end local picks, seenCat = {}, {} for _, poi in ipairs(pois) do local cat = poi.category or "unknown" @@ -2820,35 +3316,83 @@ function Demo.Start(ctx) return picks end - function msc.hoverSearchPreview(query, displayName, narration, isFirstVisit, keepPreview, done) + -- Max rows to hover per typed prefix. Capped so a prefix that + -- yields many results (e.g. a very common first 3 chars) doesn't + -- stretch the browse step into a long slog. + local MAX_BROWSE_HOVERS = 3 + -- Pause on each row when hovering multiple; a full narration pause + -- when there's only one row so the single preview has time to land. + local BROWSE_MULTI_HOVER_PAUSE = 1.0 + local BROWSE_SINGLE_HOVER_PAUSE = 2.2 + + -- Type `query` into the local map search bar, then walk the cursor + -- through each visible result row (up to MAX_BROWSE_HOVERS), + -- pausing at each so the map preview can update. Each row is + -- tagged with _demoMapHover so setHoveredRow fires real + -- OnEnter/OnLeave as the cursor crosses rows, making the preview + -- track the cursor in real time. + function msc.hoverRowsForPrefix(query, isFirstVisit, done) local lsf = _G["EasyFindMapSearchFrame"] local editBox = lsf and lsf.editBox if not editBox then done(); return end + local function hoverAllRows() + local rows = {} + for i = 1, 20 do + local btn = _G["EasyFindMapResultButton" .. i] + if btn and btn:IsShown() and btn.data and btn.data.name + and not btn.data.isHeader then + rows[#rows + 1] = btn + if #rows >= MAX_BROWSE_HOVERS then break end + end + end + if #rows == 0 then done(); return end + -- Tag every row up front so the preview fires even on rows + -- the cursor passes through between deliberate stops. + for i = 1, #rows do rows[i]._demoMapHover = true end + local pause = (#rows > 1) and BROWSE_MULTI_HOVER_PAUSE or BROWSE_SINGLE_HOVER_PAUSE + local idx = 0 + local function nextRow() + idx = idx + 1 + if idx > #rows then + -- Remember the last visible row so later steps + -- (clickResult, retypeLastBrowse) re-target it. + local lastRow = rows[#rows] + if lastRow and lastRow.data and lastRow.data.name then + msc.lastBrowsePOI = { name = lastRow.data.name } + end + -- Untag the rows we already visited so when the + -- cursor travels back to the editbox for the next + -- prefix it doesn't re-trigger previews on each + -- row it passes over. The last row stays tagged + -- so its preview clears via OnLeave when the + -- cursor finally departs. + for i = 1, #rows - 1 do + rows[i]._demoMapHover = nil + end + done() + return + end + local row = rows[idx] + moveCursorTo(row, CURSOR_MOVE, function() + safeAfter(pause, nextRow) + end) + end + nextRow() + end local function afterFocus() backspaceMapTextKeepCaret(editBox, TYPE_DELAY, function() typeMapTextKeepCaret(editBox, query, TYPE_DELAY, function() - safeAfter(SETTLE_PAUSE, function() - local row = findMapResultByName(displayName) or findFirstVisibleMapResult() - if not row then done(); return end - -- Tag the row so setHoveredRow fires OnEnter/OnLeave - -- in sync with the highlight (same placeCursorAt frame). - row._demoMapHover = true - moveCursorTo(row, CURSOR_MOVE, function() - mapSearchCallout:SetText(narration) - mapSearchCallout:Show() - safeAfter(2.2, function() - if keepPreview then - -- Leave _demoMapHover set so the preview - -- persists until clickResult clears it. - done() - else - -- Clear the tag; setHoveredRow will fire - -- OnLeave when the cursor moves away. - done() - end - end) - end) - end) + -- Show the narration as soon as typing finishes + -- (only on the first prefix; later prefixes reuse + -- the callout that's already up). Raising it here + -- instead of waiting for cursor arrival at the + -- first row lets the user read the instruction + -- while the cursor is still travelling. + if isFirstVisit then + mapSearchCallout:SetText("Hovering over a result shows it on the map.") + mapSearchCallout:ShowWithArrow() + end + safeAfter(SETTLE_PAUSE, hoverAllRows) end) end) end @@ -2866,24 +3410,48 @@ function Demo.Start(ctx) local picks = msc.pickDiversePOIs() if #picks == 0 then done(); return end local idx = 0 - local function nextOne() + local function nextPick() idx = idx + 1 if idx > #picks then - msc.lastBrowsePOI = picks[#picks] - mapSearchCallout:Hide() + -- Leave the callout visible so clickResult can + -- scroll its text from "Hovering over a result..." + -- to "Now let's click this result." without the box + -- popping out and back. done() return end local poi = picks[idx] - local query = slower(poi.name):sub(1, 3) - local isLast = idx == #picks - msc.hoverSearchPreview(query, poi.name, "Hovering over a result shows it on the map.", idx == 1, isLast, nextOne) + -- Per-category overrides: typing "ah" or "fp" reads as + -- the natural in-game shorthand and avoids spelling out + -- "auc"/"fli" which look awkward in the demo. + local cat = poi.category + local query + if cat == "auctionhouse" then + query = "ah" + elseif cat == "flightmaster" then + query = "fp" + else + query = slower(poi.name):sub(1, 3) + end + msc.hoverRowsForPrefix(query, idx == 1, nextPick) end - nextOne() + nextPick() end DEMOS.mapSearchCurrent.rebuild = function(def) msc.lastBrowsePOI = nil + -- Fully wipe the demo payload each rebuild. Both the + -- disabled state and the step list live on the shared `def` + -- table across rebuilds, so a previous run that populated + -- stepDefs (hasBrowse=true) and a later run that sets + -- disabled=true (hasBrowse=false) would otherwise layer the + -- disabled overlay on top of a still-populated step list. + def.disabled = false + def.disabledMessage = nil + def.stepDefs = nil + def.sections = nil + def.run = nil + def.setupAfter = nil -- Capture the user's CURRENT mode as the snapshot so -- restoreUserSettings (which runs after rebuild) reverts -- any flips the demo's run/setupAfter make to the saved @@ -2896,16 +3464,40 @@ function Demo.Start(ctx) ensureLocalMapMode(isFast); openWorldMapToPlayerZone() end -- Snap to end-of-browse: map open with the last POI query - -- typed and results visible, ready for "Click a result". + -- typed and results visible, ready for "Click a result". The + -- animation's last hover lands on the last visible row of the + -- final prefix (not necessarily picks[#picks] itself, since + -- the prefix can match siblings). Mirror that here so jumping + -- to the end state and playing through produce the same + -- lastBrowsePOI for clickResult to re-target. local browseSnap = function() ensureLocalMapMode(isFast); openWorldMapToPlayerZone() local picks = msc.pickDiversePOIs() if #picks > 0 then - msc.lastBrowsePOI = picks[#picks] - local query = slower(picks[#picks].name):sub(1, 3) + local lastPick = picks[#picks] + msc.lastBrowsePOI = { name = lastPick.name } + local query = slower(lastPick.name):sub(1, 3) if ns.MapSearch and ns.MapSearch.RunLocalSearch then ns.MapSearch:RunLocalSearch(query) end + -- Walk the visible result list the same way the + -- animation's hoverRowsForPrefix does (first N + -- non-header rows) and pick the last one as the + -- end-of-browse target. + local lastName + local seen = 0 + for i = 1, 20 do + local btn = _G["EasyFindMapResultButton" .. i] + if btn and btn:IsShown() and btn.data and btn.data.name + and not btn.data.isHeader then + lastName = btn.data.name + seen = seen + 1 + if seen >= MAX_BROWSE_HOVERS then break end + end + end + if lastName then + msc.lastBrowsePOI = { name = lastName } + end end end local doneSnap = function() @@ -3218,7 +3810,7 @@ function Demo.Start(ctx) if target.data and ns.MapSearch and ns.MapSearch.HandleUISearchClick then pcall(ns.MapSearch.HandleUISearchClick, ns.MapSearch, target.data) end - minimapCallout:SetText("Notice how your target is now automatically tracked!") + minimapCallout:SetText("The point is now being tracked.") safeAfter(SETTLE_PAUSE, function() showMinimapHint(function() minimapCallout:Hide() @@ -4693,10 +5285,23 @@ function Demo.Start(ctx) if minimapCallout then minimapCallout:Hide() end if minimapArrow then minimapArrow:Hide() end if mapSearchCallout then mapSearchCallout:Hide() end + if localClearPointer then + localClearPointer.chev.frame:Hide() + localClearPointer.emitter:Hide() + end + if globalClearPointer then + globalClearPointer.chev.frame:Hide() + globalClearPointer.emitter:Hide() + end -- Outfit demo cleanup (arrow, ticker, right-click icon, pin) if od then pcall(od.cleanup) end if clearDemoWaypoint then pcall(clearDemoWaypoint) end if resetMapSearchState then pcall(resetMapSearchState) end + -- Revert any WMF mapID change before closing so the next + -- demo's rebuild (e.g. mapSearchCurrent's pickDiversePOIs) + -- sees the player's real pre-demo WMF state instead of + -- wherever the previous demo navigated to. + restoreWmfMapID() if closeWorldMap then pcall(closeWorldMap) end if not InCombatLockdown() then local psf = _G["PlayerSpellsFrame"] @@ -4729,9 +5334,18 @@ function Demo.Start(ctx) setHoveredRow(nil) clearButtonHover() if transitionText then transitionText:Hide() end + if trackingStateTicker then trackingStateTicker:Cancel(); trackingStateTicker = nil end if minimapCallout then minimapCallout:Hide() end if minimapArrow then minimapArrow:Hide() end if mapSearchCallout then mapSearchCallout:Hide() end + if localClearPointer then + localClearPointer.chev.frame:Hide() + localClearPointer.emitter:Hide() + end + if globalClearPointer then + globalClearPointer.chev.frame:Hide() + globalClearPointer.emitter:Hide() + end animatingIdx = 0 end diff --git a/MapSearch.lua b/MapSearch.lua index ac3a506..851ad8d 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -2058,7 +2058,7 @@ function MapSearch:CreateSearchFrame() globalPlaceholder:SetPoint("LEFT", 2, 0) globalPlaceholder:SetJustifyH("LEFT") globalPlaceholder:SetTextColor(0.5, 0.5, 0.5, 1.0) - globalPlaceholder:SetText("Search for zones & instances") + globalPlaceholder:SetText("Search for zones/instances") globalEditBox.placeholder = globalPlaceholder globalEditBox:SetScript("OnEditFocusGained", function(self) diff --git a/TutorialBox.lua b/TutorialBox.lua index a96517c..ddd1fdd 100644 --- a/TutorialBox.lua +++ b/TutorialBox.lua @@ -191,6 +191,11 @@ function TutorialBox.AttachPointer(box, opts) core:SetTexture(CHEVRON_TEXTURE) core:SetVertexColor(color[1], color[2], color[3]) core:SetRotation(dir.rotation) + -- Additive blend so the core reads as luminous (adds light to + -- whatever sits beneath it) instead of a solid painted-on shape. + -- Matches the glow's blend mode, keeping the whole chevron in + -- the same "warm ember" visual family. + core:SetBlendMode("ADD") group.core = core chevrons[i] = core @@ -283,5 +288,10 @@ function TutorialBox.Create(parent, textFont) self:SetSize(w, h) end + -- Marker so cursor-hover helpers can detect tutorial boxes and + -- anchor the cursor sprite outside the frame (tip at the bottom + -- border, body below) instead of covering text in the middle. + f._isTutorialBox = true + return f end From 4419f19fb8924d1ff3e2749d424b39ff35505e84 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 19 Apr 2026 20:02:17 -0400 Subject: [PATCH 016/103] Make Fast Mode default; move Guide to right-click menu Deprecates the per-search-bar Fast/Guide toggle. Search bar icons are now static magnifying glasses; a result row's right-click context menu offers Pin/Unpin plus a Guide row (eye icon) that invokes the step-by-step walkthrough for that specific entry. Core: removes directOpen DB field, flips minimap FX defaults off. Options: removes Open-Panels-Directly and Navigate-Directly checkboxes. UI: rebuilds pin popup as a two-row menu (Guide on top, Pin/Unpin below). MapSearch: static icons, simplified placeholder tooltips. Demo: strips guide-mode branches from every demo, adds a dedicated Guide demo that shows right-click -> Guide on a Valorstones search. Archives the now-unused lightning-bolt.tga under archive/ (gitignored). Adds a new white eye.tga for the Guide menu row. --- .gitignore | 3 + Core.lua | 11 +- Demo.lua | 1485 +++++++---------------------------- MapSearch.lua | 155 +--- Options.lua | 115 +-- UI.lua | 287 +++---- textures/eye.tga | Bin 0 -> 16402 bytes textures/lightning-bolt.tga | Bin 65554 -> 0 bytes 8 files changed, 457 insertions(+), 1599 deletions(-) create mode 100644 textures/eye.tga delete mode 100644 textures/lightning-bolt.tga diff --git a/.gitignore b/.gitignore index 3cae8ca..487c46c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Release artifacts *.zip +# Archived / retired assets (kept on disk for reference, not shipped) +archive/ + # Editor files *.swp *.swo diff --git a/Core.lua b/Core.lua index 5d2c313..5cedf4a 100644 --- a/Core.lua +++ b/Core.lua @@ -52,9 +52,8 @@ local DB_DEFAULTS = { globalSearchPositionMax = nil, -- x offset from map right edge (maximized) mapSearchYOffset = 0, -- y offset for search bars relative to map bottom hideSearchBarsMaximized = true, -- Hide search bars when map is full screen (opt-in fullscreen search) - directOpen = true, -- Open panels directly (Fast Mode) instead of step-by-step (Guide Mode) - localMapDirectOpen = false, -- Zone bar: navigate directly (no zone highlighting) - globalMapDirectOpen = false, -- Global bar: navigate directly (no zone highlighting) + localMapDirectOpen = true, -- Zone bar left-click: direct navigation (breadcrumb/guide reserved for right-click Guide) + globalMapDirectOpen = true, -- Global bar left-click: direct navigation (breadcrumb/guide reserved for right-click Guide) smartShow = false, -- Hide search bar until mouse hovers nearby mapSmartShow = false, -- Hide map search bars until mouse hovers nearby resultsTheme = "Retail", -- "Classic" or "Retail" @@ -74,12 +73,12 @@ local DB_DEFAULTS = { blinkingPins = false, -- Pulse map pins and highlights in sync with indicator bob mapPinHighlight = true, -- Show yellow highlight box around map pins arrivalDistance = 10, -- Yards - auto-clear waypoint when player is this close - minimapArrowGlow = true, -- Pulsing glow on minimap perimeter arrow + minimapArrowGlow = false, -- Pulsing glow on minimap perimeter arrow glowOnlyEasyFind = false, -- Only show glow for waypoints placed by EasyFind - minimapGuideCircle = true, -- Near-track ring + arrow around player on minimap + minimapGuideCircle = false,-- Near-track ring + arrow around player on minimap circleOnlyEasyFind = false, -- Only show guide circle for EasyFind waypoints guideCircleScale = 1.0, -- Scale multiplier for guide circle ring+arrow - minimapPinGlow = true, -- Pulsing glow on map pin when guide circle shrinks onto it + minimapPinGlow = false, -- Pulsing glow on map pin when guide circle shrinks onto it autoPinClear = true, -- Auto-clear map pin when player arrives autoTrackPins = true, -- Auto super-track newly placed map pins uiResultsAbove = false, -- Show UI search results above the search bar diff --git a/Demo.lua b/Demo.lua index e70365a..8b1d1aa 100644 --- a/Demo.lua +++ b/Demo.lua @@ -13,7 +13,6 @@ local GOLD_COLOR = ns.GOLD_COLOR local TOOLTIP_BORDER = ns.TOOLTIP_BORDER local DEFAULT_OPACITY = ns.DEFAULT_OPACITY local DARK_PANEL_BG = ns.DARK_PANEL_BG -local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind\\textures\\lightning-bolt" local CreateFrame = CreateFrame local C_Timer = C_Timer @@ -72,16 +71,6 @@ function Demo.Start(ctx) pcall(C_AddOns.LoadAddOn, "Blizzard_PlayerSpells") end - -- Step list: ONLY real (executable) steps. Section headers are - -- display-only entries in DEMO_SECTIONS below and aren't navigable - -- stops in the state machine - the player shouldn't have to click - -- Per-demo Fast/Guide flag for demos that opt in via - -- supportsModeToggle. Defaults to fast (true). Independent of - -- the user's saved EasyFind.db.directOpen / *MapDirectOpen - -- settings, which the demo restores after each run. - local demoModeFast = {} - - -- Next to get past a header that does nothing. -- Demo registry. Each entry has: -- title - shown at the top of the demo panel -- sections - list of { header, section, firstStep, lastStep } @@ -90,9 +79,6 @@ function Demo.Start(ctx) -- setupAfter - list of function() that snaps the game state to -- the end of step i without animation (used by -- Prev/Next/jumpToStep) - -- supportsModeToggle - true to show the Fast/Guide toggle button - -- on the demo panel; the demo's `rebuild` reads - -- demoModeFast[key] to pick which step set to build -- run and setupAfter for the default UI Search demo are populated -- further down, after the helper functions they reference exist. -- Other demos start empty and get filled in as we build them. @@ -100,67 +86,67 @@ function Demo.Start(ctx) uiSearch = { title = "UI Search", sections = { - { header = "|TInterface\\AddOns\\EasyFind\\textures\\lightning-bolt:14:14|t Fast Mode (for convenience)", section = 1, firstStep = 1, lastStep = 3 }, - { header = "|A:common-search-magnifyingglass:14:14|a Guide Mode (for learning)", section = 2, firstStep = 4, lastStep = 8 }, + { header = "Search and open a panel", section = 1, firstStep = 1, lastStep = 2 }, }, stepDefs = { - { text = "Make sure Fast Mode is enabled", section = 1 }, -- 1 - { text = 'Start typing "Spellbook"', section = 1 }, -- 2 - { text = "Click the Spellbook result", section = 1 }, -- 3 - { text = "Switch to Guide Mode", section = 2 }, -- 4 - { text = 'Start typing "Spellbook"', section = 2 }, -- 5 - { text = "Click the Spellbook result", section = 2 }, -- 6 - { text = "Click Talents & Spellbook button", section = 2 }, -- 7 - { text = "Click the Spellbook tab", section = 2 }, -- 8 + { text = 'Start typing "Spellbook"', section = 1 }, -- 1 + { text = "Click the Spellbook result", section = 1 }, -- 2 }, lockFrames = { "PlayerSpellsFrame" }, run = {}, setupAfter = {}, }, + guide = { + title = "Guide Mode", + sections = { + { header = "Right-click a result to get a guided walkthrough", section = 1, firstStep = 1, lastStep = 4 }, + }, + stepDefs = { + { text = 'Start typing "Valorstones"', section = 1 }, -- 1 + { text = "Right-click the Valorstones row", section = 1 }, -- 2 + { text = "Click Guide in the menu", section = 1 }, -- 3 + { text = "Follow the step-by-step guide", section = 1 }, -- 4 + }, + lockFrames = { "CharacterFrame" }, + run = {}, + setupAfter = {}, + }, mapSearchZone = { title = "Zone/Instance Map Search", - -- sections / stepDefs / run / setupAfter are populated by - -- the `rebuild` function based on demoModeFast["mapSearchZone"]. sections = {}, stepDefs = {}, lockFrames = { "WorldMapFrame" }, run = {}, setupAfter = {}, - supportsModeToggle = true, }, mapSearchCurrent = { title = "Current Zone Map Search", - -- sections / stepDefs / run / setupAfter are populated by - -- the `rebuild` function based on demoModeFast["mapSearchCurrent"]. sections = {}, stepDefs = {}, lockFrames = { "WorldMapFrame" }, run = {}, setupAfter = {}, - supportsModeToggle = true, }, mapSearchUI = { title = "Map search through UI bar", sections = { - { header = "Local Map POI search: Flight Master", section = 1, firstStep = 1, lastStep = 6 }, - { header = "Global zone search: Eastern Plaguelands", section = 2, firstStep = 7, lastStep = 10 }, + { header = "Local Map POI search: Flight Master", section = 1, firstStep = 1, lastStep = 5 }, + { header = "Global zone search: Eastern Plaguelands", section = 2, firstStep = 6, lastStep = 9 }, }, stepDefs = { - { text = "Make sure Fast Mode is enabled", section = 1 }, -- 1 - { text = "Open the filter menu", section = 1 }, -- 2 - { text = "Enable Map Search filter", section = 1 }, -- 3 - { text = 'Confirm "Local" is selected', section = 1 }, -- 4 - { text = 'Start typing "Flight Master"', section = 1 }, -- 5 - { text = "Click the Flight Master result", section = 1 }, -- 6 - { text = "Open the filter menu", section = 2 }, -- 7 - { text = 'Switch to "Global"', section = 2 }, -- 8 - { text = 'Start typing "Eastern Plaguelands"', section = 2 }, -- 9 - { text = "Click the Eastern Plaguelands result", section = 2 }, -- 10 + { text = "Open the filter menu", section = 1 }, -- 1 + { text = "Enable Map Search filter", section = 1 }, -- 2 + { text = 'Confirm "Local" is selected', section = 1 }, -- 3 + { text = 'Start typing "Flight Master"', section = 1 }, -- 4 + { text = "Click the Flight Master result", section = 1 }, -- 5 + { text = "Open the filter menu", section = 2 }, -- 6 + { text = 'Switch to "Global"', section = 2 }, -- 7 + { text = 'Start typing "Eastern Plaguelands"', section = 2 }, -- 8 + { text = "Click the Eastern Plaguelands result", section = 2 }, -- 9 }, lockFrames = { "WorldMapFrame" }, run = {}, setupAfter = {}, - supportsModeToggle = true, }, outfits = { title = "Outfits", sections = {}, stepDefs = {}, lockFrames = {}, run = {}, setupAfter = {} }, appearanceSets = { title = "Appearance Sets", sections = {}, stepDefs = {}, lockFrames = {}, run = {}, setupAfter = {} }, @@ -229,81 +215,6 @@ function Demo.Start(ctx) titleSep:SetPoint("TOPRIGHT", -16, -60) titleSep:SetColorTexture(0.4, 0.4, 0.4, 0.7) - -- Fast/Guide toggle row. Only shown for demos that opt in via - -- def.supportsModeToggle. The "Mode:" prefix sits outside the - -- button so the button itself just displays the icon + the - -- current mode name. All sub-elements live on the modeUI table - -- so they only consume one local in this large function. - local modeUI = {} - modeUI.row = CreateFrame("Frame", nil, demoFrame) - modeUI.row:SetSize(108, 16) - modeUI.row:SetPoint("TOP", title, "BOTTOM", 0, -4) - modeUI.row:Hide() - - modeUI.prefix = modeUI.row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - modeUI.prefix:SetPoint("LEFT", modeUI.row, "LEFT", 0, 0) - modeUI.prefix:SetText("Mode:") - modeUI.prefix:SetTextColor(0.85, 0.85, 0.85) - - modeUI.btn = CreateFrame("Button", nil, modeUI.row) - modeUI.btn:SetSize(64, 16) - modeUI.btn:SetPoint("LEFT", modeUI.prefix, "RIGHT", 4, 0) - local _modeTex = modeUI.btn:CreateTexture(nil, "BACKGROUND") - _modeTex:SetAllPoints() - _modeTex:SetColorTexture(0.4, 0.4, 0.4, 0.6) - _modeTex = modeUI.btn:CreateTexture(nil, "ARTWORK") - _modeTex:SetPoint("TOPLEFT", 1, -1) - _modeTex:SetPoint("BOTTOMRIGHT", -1, 1) - _modeTex:SetColorTexture(0.12, 0.12, 0.12, 0.95) - modeUI.icon = modeUI.btn:CreateTexture(nil, "OVERLAY") - modeUI.icon:SetSize(11, 11) - modeUI.icon:SetPoint("LEFT", modeUI.btn, "LEFT", 4, 0) - modeUI.label = modeUI.btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - modeUI.label:SetPoint("LEFT", modeUI.icon, "RIGHT", 3, 0) - modeUI.label:SetPoint("RIGHT", modeUI.btn, "RIGHT", -3, 0) - modeUI.label:SetJustifyH("LEFT") - modeUI.btn:SetHighlightTexture(130757, "ADD") - - local function refreshModeToggle() - local def = DEMOS[currentDemoKey] - if not def or not def.supportsModeToggle then - modeUI.row:Hide() - return - end - local isFast = demoModeFast[currentDemoKey] ~= false - if isFast then - modeUI.icon:SetAtlas(nil) - modeUI.icon:SetTexture(LIGHTNING_BOLT_TEX) - modeUI.label:SetText("Fast") - modeUI.label:SetTextColor(1.0, 0.82, 0.0) - else - modeUI.icon:SetTexture(nil) - modeUI.icon:SetAtlas("common-search-magnifyingglass") - modeUI.label:SetText("Guide") - modeUI.label:SetTextColor(0.6, 0.85, 1.0) - end - modeUI.row:Show() - end - - modeUI.btn:SetScript("OnClick", function() - if not active or not currentDemoKey then return end - demoModeFast[currentDemoKey] = (demoModeFast[currentDemoKey] == false) - if loadDemo then loadDemo(currentDemoKey) end - end) - - modeUI.btn:SetScript("OnEnter", function(self) - GameTooltip:SetOwner(self, "ANCHOR_BOTTOM") - if demoModeFast[currentDemoKey] ~= false then - GameTooltip:SetText("Fast Mode") - GameTooltip:AddLine("Click to switch this demo to Guide Mode.", 1, 1, 1, true) - else - GameTooltip:SetText("Guide Mode") - GameTooltip:AddLine("Click to switch this demo to Fast Mode.", 1, 1, 1, true) - end - GameTooltip:Show() - end) - modeUI.btn:SetScript("OnLeave", GameTooltip_Hide) - -- Scroll container for the step list. Sits directly below the -- title separator and above the transport-button row. Rows are -- anchored to stepScrollChild, so when the active step changes @@ -639,6 +550,7 @@ function Demo.Start(ctx) -- it doesn't spill below the demo panel. local DEMO_LIST = { { name = "UI Search", key = "uiSearch" }, + { name = "Guide Mode", key = "guide" }, { name = "Zone/instance map search", key = "mapSearchZone" }, { name = "Current zone map search", key = "mapSearchCurrent" }, { name = "Map search through UI bar",key = "mapSearchUI" }, @@ -919,8 +831,7 @@ function Demo.Start(ctx) -- count toward startDemo's MAXVARS=200 budget AND so they -- aren't upvalues for _runDemo's inner closures (avoids -- MAXUPVAL=60). _runDemo runs immediately at the bottom of - -- startDemo. The forward-declared `active` upvalue is still - -- shared with the modeUI button handlers above. + -- startDemo. local function _runDemo() -- Save originals so the lock toggling system can restore them @@ -929,9 +840,6 @@ function Demo.Start(ctx) -- replaced/disabled while a step is animating, then restored -- whenever the demo is paused, idle, or stopped. local savedDragStart = searchFrame:GetScript("OnDragStart") - local savedDirectOpen = EasyFind.db.directOpen - local savedGlobalMapDirectOpen = EasyFind.db.globalMapDirectOpen - local savedLocalMapDirectOpen = EasyFind.db.localMapDirectOpen -- Also snapshot the UI bar's Map Search filter toggle and -- local/global sub-option. The map-search-through-UI-bar demo -- flips both and must restore the user's actual pre-demo @@ -1053,26 +961,14 @@ function Demo.Start(ctx) }) end - -- Restore the user's pre-demo settings AND refresh the mode - -- button visual (the icon doesn't auto-update when directOpen - -- changes, so without this call it stays stuck on whatever the - -- last demo step left it as). + -- Restore the user's pre-demo filter state. local function restoreUserSettings() -- staticOpacity stays forced true while the demo is open; -- it's restored in endDemo, not here, so loadDemo doesn't -- re-enable movement fade between demo switches. - EasyFind.db.directOpen = savedDirectOpen - EasyFind.db.globalMapDirectOpen = savedGlobalMapDirectOpen - EasyFind.db.localMapDirectOpen = savedLocalMapDirectOpen EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} EasyFind.db.uiSearchFilters.map = savedUiMapFilter EasyFind.db.uiMapSearchLocal = savedUiMapSearchLocal - if ns.UpdateModeButtonVisual then - pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) - end - if ns.MapSearch and ns.MapSearch.UpdateMapModeBtns then - pcall(ns.MapSearch.UpdateMapModeBtns, ns.MapSearch) - end -- Refresh the filter dropdown's visual state to match the -- restored saved values (map checkbox + local/global sub). local dd = searchFrame.filterDropdown @@ -1577,23 +1473,6 @@ function Demo.Start(ctx) clearButtonHover = function() GameTooltip_Hide() - -- UI search mode button - local mb = searchFrame.modeBtn - if mb then - if mb.btnBg then mb.btnBg:Hide() end - if mb.UnlockHighlight then mb:UnlockHighlight() end - end - -- Map search mode buttons - local gsf = _G["EasyFindMapGlobalSearchFrame"] - if gsf and gsf.modeBtn then - if gsf.modeBtn.btnBg then gsf.modeBtn.btnBg:Hide() end - if gsf.modeBtn.UnlockHighlight then gsf.modeBtn:UnlockHighlight() end - end - local lsf = _G["EasyFindMapSearchFrame"] - if lsf and lsf.modeBtn then - if lsf.modeBtn.btnBg then lsf.modeBtn.btnBg:Hide() end - if lsf.modeBtn.UnlockHighlight then lsf.modeBtn:UnlockHighlight() end - end end local function moveCursorTo(targetFrame, duration, onArrive, offsetX, offsetY) @@ -1839,22 +1718,8 @@ function Demo.Start(ctx) local PRE_ACT_GAP = 0.7 local POST_ACT_GAP = 0.4 - -- Defensive precondition helper. Each step calls ensureMode at - -- the start with the directOpen value it expects, so if the user - -- toggled the mode while paused or between steps it gets snapped - -- back to the default the step assumes. - local function ensureMode(wantFast) - if EasyFind.db.directOpen ~= wantFast then - EasyFind.db.directOpen = wantFast - if ns.UpdateModeButtonVisual then - pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) - end - end - end - -- Reset PlayerSpellsFrame to its default tab (Specialization) so - -- the guide mode demo shows the full navigation steps instead - -- of opening directly on the tab we left it on during fast mode. + -- the demo always starts from a clean state. local function resetPlayerSpellsFrame() local frame = _G["PlayerSpellsFrame"] if not frame then return end @@ -1870,89 +1735,10 @@ function Demo.Start(ctx) end end - -- Hover the mode button to show what it does. If the player has - -- it set to Guide Mode, this also performs an animated click - -- to flip it back to Fast Mode (since the demo assumes Fast). - -- The toggle happens INSIDE the click animation so the visual - -- and the state change land at the same moment. - -- Offset the cursor toward the bottom-right of the toggle button - -- so the icon (lightning bolt / magnifying glass) stays visible - -- during the hover and click animations. - local MODE_BTN_OFFSET_X = 9 - local MODE_BTN_OFFSET_Y = -9 - - local function hoverModeButton(done) - local mb = searchFrame.modeBtn - moveCursorTo(mb, CURSOR_MOVE, function() - if mb.btnBg then mb.btnBg:Show() end - if mb.LockHighlight then mb:LockHighlight() end - GameTooltip:SetOwner(mb, "ANCHOR_BOTTOM") - local function showFastTooltip() - GameTooltip:SetText("Fast Mode") - GameTooltip:AddLine("Click to switch to step-by-step Guide Mode.", 1, 1, 1, true) - GameTooltip:Show() - end - if not EasyFind.db.directOpen then - GameTooltip:SetText("Guide Mode") - GameTooltip:AddLine("Click to enable Fast Mode (opens panels directly).", 1, 1, 1, true) - GameTooltip:Show() - safeAfter(1.0, function() - clickAnim(function() - EasyFind.db.directOpen = true - if ns.UpdateModeButtonVisual then - pcall(ns.UpdateModeButtonVisual, mb) - end - showFastTooltip() - safeAfter(1.0, done) - end) - end) - else - showFastTooltip() - safeAfter(1.2, done) - end - end, MODE_BTN_OFFSET_X, MODE_BTN_OFFSET_Y) - end - - -- Mirror of hoverModeButton that switches to Guide Mode instead. - -- Used by mapSearchUI's guide variant in step 1. - local function hoverModeButtonToGuide(done) - local mb = searchFrame.modeBtn - moveCursorTo(mb, CURSOR_MOVE, function() - if mb.btnBg then mb.btnBg:Show() end - if mb.LockHighlight then mb:LockHighlight() end - GameTooltip:SetOwner(mb, "ANCHOR_BOTTOM") - local function showGuideTooltip() - GameTooltip:SetText("Guide Mode") - GameTooltip:AddLine("Click to enable Fast Mode (opens panels directly).", 1, 1, 1, true) - GameTooltip:Show() - end - if EasyFind.db.directOpen then - GameTooltip:SetText("Fast Mode") - GameTooltip:AddLine("Click to switch to step-by-step Guide Mode.", 1, 1, 1, true) - GameTooltip:Show() - safeAfter(1.0, function() - clickAnim(function() - EasyFind.db.directOpen = false - if ns.UpdateModeButtonVisual then - pcall(ns.UpdateModeButtonVisual, mb) - end - showGuideTooltip() - safeAfter(1.0, done) - end) - end) - else - showGuideTooltip() - safeAfter(1.2, done) - end - end, MODE_BTN_OFFSET_X, MODE_BTN_OFFSET_Y) - end - -- Focuses the UI search bar with cursor animation + blink -- cursor, then types the query. Owns the full "search for X" - -- action so there's no standalone focus step. `wantFast` - -- defaults to true; pass false for the Guide Mode sections. - local function uis_stepFocusAndType(query, wantFast, done) - ensureMode(wantFast) + -- action so there's no standalone focus step. + local function uis_stepFocusAndType(query, done) moveCursorTo(searchFrame.editBox, CURSOR_MOVE, function() clickAnim(function() startBlinkCursor() @@ -1966,56 +1752,22 @@ function Demo.Start(ctx) end DEMOS.uiSearch.run = { - -- 1: Make sure Fast Mode is enabled. If the player is in - -- Standard Mode, hoverModeButton click-animates the toggle - -- and flips directOpen during the click visualization. - function(done) - hoverModeButton(done) - end, - -- 2: Start typing "sp" (cursor flies to the bar, focuses + -- 1: Start typing "sp" (cursor flies to the bar, focuses -- it, then types — all in this single step). function(done) - uis_stepFocusAndType("sp", true, done) + uis_stepFocusAndType("sp", done) end, - -- 3: Click the Spellbook result (not just the first one). - -- Final step of Fast Mode. After the result fires and the - -- user sees the panel open, sequence: - -- 1. Settle (see the Spellbook tab open) - -- 2. Clean up: hide cursor, close opened windows, clear - -- search text so the screen is clean - -- 3. Show transition banner - -- 4. Pause so the user can read it - -- 5. Done → step 5 picks up + -- 2: Click the Spellbook result, let PlayerSpellsFrame + -- open, then clean up. function(done) - ensureMode(true) local target = findResultRowByName("Spellbook") or findFirstResultRow() or searchFrame.editBox moveCursorTo(target, CURSOR_MOVE, function() clickAnim(function() UI:SelectResult(spellbookEntry) safeAfter(1.5, function() - -- Move cursor to the close button on PlayerSpellsFrame - -- and fake-click it, then close the window. local psf = _G["PlayerSpellsFrame"] local closeBtn = psf and (psf.ClosePanelButton or psf.CloseButton) - if closeBtn and closeBtn:IsShown() then - moveCursorTo(closeBtn, CURSOR_MOVE, function() - clickAnim(function() - setHoveredRow(nil) - stopBlinkCursor() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if ns.Highlight and ns.Highlight.ClearAll then - pcall(ns.Highlight.ClearAll, ns.Highlight) - end - resetPlayerSpellsFrame() - safeAfter(1.0, function() - transitionText:Show() - beginSectionTransition(2) - safeAfter(3.5, done) - end) - end) - end) - else + local function cleanup() setHoveredRow(nil) stopBlinkCursor() searchFrame.editBox:SetText("") @@ -2024,129 +1776,126 @@ function Demo.Start(ctx) pcall(ns.Highlight.ClearAll, ns.Highlight) end resetPlayerSpellsFrame() - safeAfter(1.0, function() - transitionText:Show() - beginSectionTransition(2) - safeAfter(2.0, done) + safeAfter(0.5, function() + cursor:Hide() + done() end) end - end) - end) - end) - end, - -- 4: Switch to Guide Mode (hover with tooltip + highlight, - -- click, show updated tooltip so the user sees the change). - -- Tooltip/highlight persist until the cursor moves to the - -- next target (clearButtonHover runs at the start of - -- moveCursorTo). - function(done) - transitionText:Hide() - ensureMode(true) - local mb = searchFrame.modeBtn - moveCursorTo(mb, CURSOR_MOVE, function() - if mb.btnBg then mb.btnBg:Show() end - if mb.LockHighlight then mb:LockHighlight() end - GameTooltip:SetOwner(mb, "ANCHOR_BOTTOM") - GameTooltip:SetText("Fast Mode") - GameTooltip:AddLine("Click to switch to step-by-step Guide Mode.", 1, 1, 1, true) - GameTooltip:Show() - safeAfter(1.0, function() - clickAnim(function() - EasyFind.db.directOpen = false - if ns.UpdateModeButtonVisual then - pcall(ns.UpdateModeButtonVisual, mb) + if closeBtn and closeBtn:IsShown() then + moveCursorTo(closeBtn, CURSOR_MOVE, function() + clickAnim(cleanup) + end) + else + cleanup() end - GameTooltip:SetText("Guide Mode") - GameTooltip:AddLine("Click to enable Fast Mode (opens panels directly).", 1, 1, 1, true) - GameTooltip:Show() - safeAfter(0.8, done) end) end) - end, MODE_BTN_OFFSET_X, MODE_BTN_OFFSET_Y) + end) end, - -- 5: Start typing "sp" in Guide Mode + } + demoSteps = DEMOS.uiSearch.run + + -------------------------------------------------------------------- + -- Guide Mode demo: right-click a result, pick Guide from the + -- popup, and let the step-by-step highlight guide play. + -------------------------------------------------------------------- + local function guide_cleanup(done) + setHoveredRow(nil) + stopBlinkCursor() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if searchFrame.editBox.placeholder then + searchFrame.editBox.placeholder:Show() + end + if ns.Highlight and ns.Highlight.ClearAll then + pcall(ns.Highlight.ClearAll, ns.Highlight) + end + if not InCombatLockdown() then + local cf = _G["CharacterFrame"] + if cf and cf.IsShown and cf:IsShown() then + pcall(HideUIPanel, cf) + end + local tf = _G["TokenFrame"] + if tf and tf.IsShown and tf:IsShown() then + pcall(HideUIPanel, tf) + end + end + safeAfter(0.4, function() + cursor:Hide() + rightClickIcon:Hide() + done() + end) + end + + DEMOS.guide.run = { + -- 1: Type "Valorstones" function(done) - uis_stepFocusAndType("sp", false, done) + uis_stepFocusAndType("Valorstones", done) end, - -- 6: Click the Spellbook result (fires real Highlight) + -- 2: Move to the Valorstones result row and right-click, + -- which triggers ShowPinPopup with the Guide row. function(done) - ensureMode(false) - local target = findResultRowByName("Spellbook") or findFirstResultRow() or searchFrame.editBox + local target = findResultRowByName("Valorstones") or findFirstResultRow() + if not target then + guide_cleanup(done) + return + end moveCursorTo(target, CURSOR_MOVE, function() - clickAnim(function() - UI:SelectResult(spellbookEntry) - safeAfter(SETTLE_PAUSE, done) + rightClickIcon:Show() + safeAfter(0.5, function() + clickAnim(function() + rightClickIcon:Hide() + local onClick = target:GetScript("OnClick") + if onClick then + pcall(onClick, target, "RightButton", true) + end + safeAfter(SETTLE_PAUSE, done) + end) end) end) end, - -- 7: Click the Player Spells micro button (real Highlight arrow showing) + -- 3: Click the Guide row in the pin popup. SelectResult + -- with forceGuide=true routes a currency entry through + -- EasyFind:StartGuide. function(done) - ensureMode(false) - local microBtn = _G["PlayerSpellsMicroButton"] - if not microBtn then done(); return end - moveCursorTo(microBtn, CURSOR_MOVE, function() + local popup = _G["EasyFindPinPopup"] + local guideRow = popup and popup.guideRow + if not (popup and popup:IsShown() and guideRow and guideRow:IsShown()) then + local target = findResultRowByName("Valorstones") or findFirstResultRow() + if target and target.data then + UI:SelectResult(target.data, true) + end + safeAfter(SETTLE_PAUSE, done) + return + end + moveCursorTo(guideRow, CURSOR_MOVE, function() clickAnim(function() - if not InCombatLockdown() then - pcall(ShowUIPanel, _G["PlayerSpellsFrame"]) + local onClick = guideRow:GetScript("OnClick") + if onClick then + pcall(onClick, guideRow) end safeAfter(SETTLE_PAUSE, done) end) end) end, - -- 8: Click the Spellbook tab (tab 3), then after a settle - -- move the cursor to the close button, click it, close the - -- window, and hide the cursor to end the demo cleanly. + -- 4: Let the highlight ticker run so the user sees the + -- step-by-step guide, then clean up. function(done) - ensureMode(false) - local tab - local psf = _G["PlayerSpellsFrame"] - if psf and psf.TabSystem and psf.TabSystem.tabs then - tab = psf.TabSystem.tabs[3] - end - if not tab then tab = _G["PlayerSpellsFrameTab3"] end - if not tab then done(); return end - moveCursorTo(tab, CURSOR_MOVE, function() - clickAnim(function() - if tab.Click then pcall(tab.Click, tab) end - safeAfter(SETTLE_PAUSE + 1.0, function() - local closeBtn = psf and (psf.ClosePanelButton or psf.CloseButton) - if closeBtn and closeBtn:IsShown() then - moveCursorTo(closeBtn, CURSOR_MOVE, function() - clickAnim(function() - setHoveredRow(nil) - stopBlinkCursor() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if ns.Highlight and ns.Highlight.ClearAll then - pcall(ns.Highlight.ClearAll, ns.Highlight) - end - resetPlayerSpellsFrame() - safeAfter(0.5, function() - cursor:Hide() - done() - end) - end) - end) - else - setHoveredRow(nil) - stopBlinkCursor() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if ns.Highlight and ns.Highlight.ClearAll then - pcall(ns.Highlight.ClearAll, ns.Highlight) - end - resetPlayerSpellsFrame() - safeAfter(0.5, function() - cursor:Hide() - done() - end) - end - end) - end) + safeAfter(4.0, function() + guide_cleanup(done) end) end, } - demoSteps = DEMOS.uiSearch.run + DEMOS.guide.setupAfter = { + function() end, + function() end, + function() end, + function() + if ns.Highlight and ns.Highlight.ClearAll then + pcall(ns.Highlight.ClearAll, ns.Highlight) + end + end, + } -------------------------------------------------------------------- -- Zone/Instance Map Search demo @@ -2401,17 +2150,6 @@ function Demo.Start(ctx) or nil end - -- Helper: ensure the global map search mode toggle is in the - -- expected state and update its visual. - local function ensureGlobalMapMode(wantFast) - if EasyFind.db.globalMapDirectOpen ~= wantFast then - EasyFind.db.globalMapDirectOpen = wantFast - if ns.MapSearch and ns.MapSearch.UpdateMapModeBtns then - pcall(ns.MapSearch.UpdateMapModeBtns, ns.MapSearch) - end - end - end - -- Helper: clean up map search state (clear editboxes, focus, -- results, pins, zone highlight, activePinState, waypoint). -- Both local and global search bars so nothing bleeds across @@ -2439,69 +2177,6 @@ function Demo.Start(ctx) end end - -- Recursive navigation clicker for Guide Mode: checks if the - -- waypoint pin is visible (meaning we arrived at the final - -- destination). If not, looks for either a breadcrumb button in - -- the nav bar OR a zone indicator on the map canvas, clicks it, - -- and recurses. Caps at 10 iterations to prevent infinite loops. - local function clickBreadcrumbsUntilArrived(done, depth) - depth = (depth or 0) + 1 - if depth > 10 then done(); return end - safeAfter(0.6, function() - -- Have we arrived? Waypoint pin visible = destination. - local pin = _G["EasyFindLocationPin"] - if pin and pin:IsShown() then - done() - return - end - -- Priority 1: breadcrumb highlight in the nav bar. - local bcHL = _G["EasyFindBreadcrumbHighlight"] - if bcHL and bcHL:IsShown() then - moveCursorTo(bcHL, CURSOR_MOVE, function() - clickAnim(function() - local _, relTo = bcHL:GetPoint(1) - if relTo and relTo.Click then - pcall(relTo.Click, relTo) - end - clickBreadcrumbsUntilArrived(done, depth) - end) - end) - return - end - -- Priority 2: zone indicator arrow on the map canvas. - -- This appears when the target zone is highlighted on the - -- current map and the user needs to click it to zoom in. - -- The arrow is anchored next to the zone in one of four - -- directions (down/up/right/left) — read indicatorDirection - -- to push the cursor PAST the arrow into the zone itself, - -- not onto the arrow's tip. - local zoneInd = _G["EasyFindZoneIndicator"] - if zoneInd and zoneInd:IsShown() then - local dx, dy = 0, -75 -- default: arrow above zone, cursor goes down - local dir = zoneInd.indicatorDirection - if dir == "up" then - dx, dy = 0, 75 - elseif dir == "right" then - dx, dy = 75, 0 - elseif dir == "left" then - dx, dy = -75, 0 - end - moveCursorTo(zoneInd, CURSOR_MOVE, function() - clickAnim(function() - if ns.MapSearch and ns.MapSearch.pendingZoneHighlight then - pcall(WorldMapFrame.SetMapID, WorldMapFrame, - ns.MapSearch.pendingZoneHighlight) - end - clickBreadcrumbsUntilArrived(done, depth) - end) - end, dx, dy) - return - end - -- Neither visible yet, wait and retry. - clickBreadcrumbsUntilArrived(done, depth) - end) - end - -- Hover over the waypoint pin to show "found it". When the -- cursor arrives, call ClearHighlight (same as real OnEnter for -- global search pins) which hides the pin, glow, and indicator @@ -2542,15 +2217,13 @@ function Demo.Start(ctx) end) end - function msz.openMap(fast, done) + function msz.openMap(done) if InCombatLockdown() then done(); return end - ensureGlobalMapMode(fast) openWorldMap() safeAfter(0.8, done) end - function msz.clickNexus(fast, done) - ensureGlobalMapMode(fast) + function msz.clickNexus(done) local target = findMapResultByName("The Nexus") or _G["EasyFindMapResultButton1"] if not target or not target:IsShown() then done(); return end @@ -2590,101 +2263,33 @@ function Demo.Start(ctx) end) end - function msz.switchToGuide(done) - ensureGlobalMapMode(true) - local gsf = _G["EasyFindMapGlobalSearchFrame"] - local mb = gsf and gsf.modeBtn - if not mb then done(); return end - moveCursorTo(mb, CURSOR_MOVE, function() - if mb.btnBg then mb.btnBg:Show() end - if mb.LockHighlight then mb:LockHighlight() end - GameTooltip:SetOwner(mb, "ANCHOR_TOP") - GameTooltip:SetText("Fast Mode") - GameTooltip:AddLine("Click to switch to Guide mode.", 1, 1, 1, true) - GameTooltip:AddLine("Hold |cFF00FF00Shift|r and drag to reposition.", 0.7, 0.7, 0.7) - GameTooltip:Show() - safeAfter(1.0, function() - clickAnim(function() - EasyFind.db.globalMapDirectOpen = false - if ns.MapSearch and ns.MapSearch.UpdateMapModeBtns then - pcall(ns.MapSearch.UpdateMapModeBtns, ns.MapSearch) - end - GameTooltip:SetText("Guide Mode") - GameTooltip:AddLine("Click to switch to Fast mode.", 1, 1, 1, true) - GameTooltip:AddLine("Hold |cFF00FF00Shift|r and drag to reposition.", 0.7, 0.7, 0.7) - GameTooltip:Show() - safeAfter(0.8, done) - end) - end) - end, MODE_BTN_OFFSET_X, MODE_BTN_OFFSET_Y) - end - DEMOS.mapSearchZone.rebuild = function(def) - -- Capture the user's saved global mode so restoreUserSettings - -- reverts any flips the run/setupAfter make to the saved value. - savedGlobalMapDirectOpen = EasyFind.db.globalMapDirectOpen - local isFast = demoModeFast["mapSearchZone"] ~= false - if isFast then - def.stepDefs = { - { text = "Open the world map", section = 1 }, - { text = 'Start typing "The Nexus"', section = 1 }, - { text = "Click The Nexus result", section = 1 }, - { text = "Hover over to clear highlight", section = 1 }, - } - def.sections = { - { header = "", section = 1, firstStep = 1, lastStep = 4 }, - } - def.run = { - function(done) msz.openMap(true, done) end, - function(done) ensureGlobalMapMode(true); msz.stepFocusAndType("nex", done) end, - function(done) msz.clickNexus(true, done) end, - msz.finish, - } - def.setupAfter = { - function() ensureGlobalMapMode(true); openWorldMap() end, - function() - ensureGlobalMapMode(true); openWorldMap() - if ns.MapSearch and ns.MapSearch.RunGlobalSearch then - pcall(ns.MapSearch.RunGlobalSearch, ns.MapSearch, "nex") - end - end, - function() ensureGlobalMapMode(true); openWorldMap() end, - function() resetMapSearchState(); closeWorldMap() end, - } - else - def.stepDefs = { - { text = "Open the world map", section = 1 }, - { text = "Switch to Guide Mode", section = 1 }, - { text = 'Start typing "The Nexus"', section = 1 }, - { text = "Click The Nexus result", section = 1 }, - { text = "Follow the breadcrumbs", section = 1 }, - { text = "Hover over to clear highlight", section = 1 }, - } - def.sections = { - { header = "", section = 1, firstStep = 1, lastStep = 6 }, - } - def.run = { - function(done) msz.openMap(true, done) end, - msz.switchToGuide, - function(done) ensureGlobalMapMode(false); msz.stepFocusAndType("nex", done) end, - function(done) msz.clickNexus(false, done) end, - function(done) clickBreadcrumbsUntilArrived(done) end, - msz.finish, - } - def.setupAfter = { - function() ensureGlobalMapMode(true); openWorldMap() end, - function() ensureGlobalMapMode(false); openWorldMap() end, - function() - ensureGlobalMapMode(false); openWorldMap() - if ns.MapSearch and ns.MapSearch.RunGlobalSearch then - pcall(ns.MapSearch.RunGlobalSearch, ns.MapSearch, "nex") - end - end, - function() ensureGlobalMapMode(false); openWorldMap() end, - function() ensureGlobalMapMode(false); openWorldMap() end, - function() resetMapSearchState(); closeWorldMap() end, - } - end + def.stepDefs = { + { text = "Open the world map", section = 1 }, + { text = 'Start typing "The Nexus"', section = 1 }, + { text = "Click The Nexus result", section = 1 }, + { text = "Hover over to clear highlight", section = 1 }, + } + def.sections = { + { header = "", section = 1, firstStep = 1, lastStep = 4 }, + } + def.run = { + msz.openMap, + function(done) msz.stepFocusAndType("nex", done) end, + msz.clickNexus, + msz.finish, + } + def.setupAfter = { + function() openWorldMap() end, + function() + openWorldMap() + if ns.MapSearch and ns.MapSearch.RunGlobalSearch then + pcall(ns.MapSearch.RunGlobalSearch, ns.MapSearch, "nex") + end + end, + function() openWorldMap() end, + function() resetMapSearchState(); closeWorldMap() end, + } end -------------------------------------------------------------------- @@ -2705,58 +2310,6 @@ function Demo.Start(ctx) return true end - local function ensureLocalMapMode(wantFast) - if EasyFind.db.localMapDirectOpen ~= wantFast then - EasyFind.db.localMapDirectOpen = wantFast - if ns.MapSearch and ns.MapSearch.UpdateMapModeBtns then - pcall(ns.MapSearch.UpdateMapModeBtns, ns.MapSearch) - end - end - end - - -- Hover the local map search mode button, show tooltip, click - -- to toggle if needed. Mirrors hoverModeButton for the UI bar. - local function hoverLocalMapModeBtn(wantFast, done) - local lsf = _G["EasyFindMapSearchFrame"] - local mb = lsf and lsf.modeBtn - if not mb then ensureLocalMapMode(wantFast); done(); return end - moveCursorTo(mb, CURSOR_MOVE, function() - if mb.btnBg then mb.btnBg:Show() end - if mb.LockHighlight then mb:LockHighlight() end - GameTooltip:SetOwner(mb, "ANCHOR_BOTTOM") - local currentFast = EasyFind.db.localMapDirectOpen - local function showTargetTooltip() - if wantFast then - GameTooltip:SetText("Fast Mode") - GameTooltip:AddLine("Clicking a result auto-tracks on the minimap.", 1, 1, 1, true) - else - GameTooltip:SetText("Guide Mode") - GameTooltip:AddLine("Clicking a result shows a pin you can click to track.", 1, 1, 1, true) - end - GameTooltip:Show() - end - if currentFast ~= wantFast then - -- Wrong mode: show current, pause, click to toggle - if currentFast then - GameTooltip:SetText("Fast Mode") - else - GameTooltip:SetText("Guide Mode") - end - GameTooltip:Show() - safeAfter(1.0, function() - clickAnim(function() - ensureLocalMapMode(wantFast) - showTargetTooltip() - safeAfter(1.0, done) - end) - end) - else - showTargetTooltip() - safeAfter(1.2, done) - end - end) - end - -- Click the waypoint pin to place a SuperTrack user waypoint. -- This triggers the real minimap glow / guide-circle behavior. -- Uses MapSearch helpers that resolve the pin location whether @@ -2841,22 +2394,14 @@ function Demo.Start(ctx) end ---------------------------------------------------------------- - -- Current Zone Map Search: rebuilds based on demoModeFast so - -- the toggle button on the demo panel can flip between Fast - -- and Guide variants without touching the user's saved - -- localMapDirectOpen setting. + -- Current Zone Map Search: browse POIs around the player. + -- All helpers live on a single table so this huge parent + -- function only consumes one local for the whole bundle. ---------------------------------------------------------------- - -- All current-zone-map-search step functions live on a single - -- table so this huge parent function only consumes one local - -- for the whole bundle. `wantFast` params are the demo's - -- desired mode (from demoModeFast), NOT the user's saved - -- setting; each helper flips ensureLocalMapMode before its - -- action so SelectResult / auto-track behave correctly. local msc = {} - function msc.openMap(wantFast, done) + function msc.openMap(done) if InCombatLockdown() then done(); return end - ensureLocalMapMode(wantFast) local ok = openWorldMapToPlayerZone() if not ok then safeAfter(0.5, done) @@ -3138,8 +2683,7 @@ function Demo.Start(ctx) end end - function msc.clickResult(wantFast, done) - ensureLocalMapMode(wantFast) + function msc.clickResult(done) local lastPOI = msc.lastBrowsePOI local target = (lastPOI and findMapResultByName(lastPOI.name)) or findFirstVisibleMapResult() @@ -3162,14 +2706,13 @@ function Demo.Start(ctx) moveCursorTo(target, CURSOR_MOVE, function() hideMapCaret() clickAnim(function() - -- Clear the hover tag so setHoveredRow fires OnLeave - -- when SelectResult hides the results frame. - if target._demoMapHover then target._demoMapHover = nil end - setHoveredRow(nil) - if target.data and ns.MapSearch then - pcall(ns.MapSearch.SelectResult, ns.MapSearch, target.data) - end - if wantFast then + -- Clear the hover tag so setHoveredRow fires OnLeave + -- when SelectResult hides the results frame. + if target._demoMapHover then target._demoMapHover = nil end + setHoveredRow(nil) + if target.data and ns.MapSearch then + pcall(ns.MapSearch.SelectResult, ns.MapSearch, target.data) + end safeAfter(SETTLE_PAUSE, function() -- Scroll the callout's text from "Now let's -- click this result." to the clear-button @@ -3193,13 +2736,6 @@ function Demo.Start(ctx) end) end) end) - else - -- Guide mode: leave the callout visible so the - -- next step (clickPinTrackContinue) can scroll - -- its text from "Now let's click..." to the pin - -- instructions without a pop-out / pop-back. - safeAfter(SETTLE_PAUSE, done) - end end) end) end) @@ -3442,36 +2978,26 @@ function Demo.Start(ctx) msc.lastBrowsePOI = nil -- Fully wipe the demo payload each rebuild. Both the -- disabled state and the step list live on the shared `def` - -- table across rebuilds, so a previous run that populated - -- stepDefs (hasBrowse=true) and a later run that sets - -- disabled=true (hasBrowse=false) would otherwise layer the - -- disabled overlay on top of a still-populated step list. + -- table across rebuilds. def.disabled = false def.disabledMessage = nil def.stepDefs = nil def.sections = nil def.run = nil def.setupAfter = nil - -- Capture the user's CURRENT mode as the snapshot so - -- restoreUserSettings (which runs after rebuild) reverts - -- any flips the demo's run/setupAfter make to the saved - -- value. Demo's own fast/guide selection comes from - -- demoModeFast, NOT the user's saved setting. - savedLocalMapDirectOpen = EasyFind.db.localMapDirectOpen - local isFast = demoModeFast["mapSearchCurrent"] ~= false local hasBrowse = #msc.pickDiversePOIs() > 0 + if not hasBrowse then + def.disabled = true + def.disabledMessage = "No searchable POIs in this zone." + return + end local openSnap = function() - ensureLocalMapMode(isFast); openWorldMapToPlayerZone() + openWorldMapToPlayerZone() end -- Snap to end-of-browse: map open with the last POI query - -- typed and results visible, ready for "Click a result". The - -- animation's last hover lands on the last visible row of the - -- final prefix (not necessarily picks[#picks] itself, since - -- the prefix can match siblings). Mirror that here so jumping - -- to the end state and playing through produce the same - -- lastBrowsePOI for clickResult to re-target. + -- typed and results visible, ready for "Click a result". local browseSnap = function() - ensureLocalMapMode(isFast); openWorldMapToPlayerZone() + openWorldMapToPlayerZone() local picks = msc.pickDiversePOIs() if #picks > 0 then local lastPick = picks[#picks] @@ -3480,10 +3006,6 @@ function Demo.Start(ctx) if ns.MapSearch and ns.MapSearch.RunLocalSearch then ns.MapSearch:RunLocalSearch(query) end - -- Walk the visible result list the same way the - -- animation's hoverRowsForPrefix does (first N - -- non-header rows) and pick the last one as the - -- end-of-browse target. local lastName local seen = 0 for i = 1, 20 do @@ -3503,59 +3025,18 @@ function Demo.Start(ctx) local doneSnap = function() clearDemoWaypoint(); resetMapSearchState(); closeWorldMap() end - local modeLabel = isFast - and "Make sure Fast Mode is enabled" - or "Make sure Guide Mode is enabled" - local modeStep = function(done) hoverLocalMapModeBtn(isFast, done) end - if isFast then - if hasBrowse then - def.stepDefs = { - { text = "Open the world map", section = 1 }, - { text = modeLabel, section = 1 }, - { text = "Browse what's around", section = 1 }, - { text = "Click a result", section = 1 }, - } - def.sections = { { header = "", section = 1, firstStep = 1, lastStep = 4 } } - def.run = { - function(done) msc.openMap(true, done) end, - modeStep, - msc.browseWhatsAround, - function(done) msc.clickResult(true, done) end, - } - def.setupAfter = { openSnap, openSnap, browseSnap, doneSnap } - else - def.disabled = true - def.disabledMessage = "No searchable POIs in this zone." - end - else - if hasBrowse then - def.stepDefs = { - { text = "Open the world map", section = 1 }, - { text = modeLabel, section = 1 }, - { text = "Browse what's around", section = 1 }, - { text = "Click a result", section = 1 }, - { text = "Click the pin to start tracking", section = 1 }, - { text = "Search again", section = 1 }, - { text = "Click the nav pin to auto-track instead", section = 1 }, - { text = "Now you're tracking it on the minimap!", section = 1 }, - } - def.sections = { { header = "", section = 1, firstStep = 1, lastStep = 8 } } - def.run = { - function(done) msc.openMap(false, done) end, - modeStep, - msc.browseWhatsAround, - function(done) msc.clickResult(false, done) end, - msc.clickPinTrackContinue, - msc.retypeLastBrowse, - msc.clickNavBtnAutoTrack, - msc.minimapHintAndClose, - } - def.setupAfter = { openSnap, openSnap, browseSnap, openSnap, openSnap, browseSnap, openSnap, doneSnap } - else - def.disabled = true - def.disabledMessage = "No searchable POIs in this zone." - end - end + def.stepDefs = { + { text = "Open the world map", section = 1 }, + { text = "Browse what's around", section = 1 }, + { text = "Click a result", section = 1 }, + } + def.sections = { { header = "", section = 1, firstStep = 1, lastStep = 3 } } + def.run = { + msc.openMap, + msc.browseWhatsAround, + msc.clickResult, + } + def.setupAfter = { openSnap, browseSnap, doneSnap } end ---------------------------------------------------------------- @@ -3676,55 +3157,6 @@ function Demo.Start(ctx) end) end - -- Combined filter steps: open filter → enable Map Search → confirm - -- Local, all chained as a single demo step. - function msui.enableLocalFilter(done) - msui.stepFilterBtnClick(function() - msui.openFilterDropdown() - safeAfter(STEP_PAUSE, function() - msui.ensureMapFilterEnabled() - local localRow = msui.getMapSubRow(true) - if localRow then - moveCursorTo(localRow, CURSOR_MOVE, function() - if EasyFind.db.uiMapSearchLocal == false then - clickAnim(function() - msui.setMapSubLocal(true) - safeAfter(STEP_PAUSE, done) - end) - else - safeAfter(0.6, done) - end - end) - else - msui.setMapSubLocal(true) - safeAfter(0.5, done) - end - end) - end) - end - - -- Combined filter steps: open filter → switch to Global. - function msui.switchToGlobalFilter(done) - msui.stepFilterBtnClick(function() - msui.openFilterDropdown() - safeAfter(STEP_PAUSE, function() - msui.ensureMapFilterEnabled() - local globalRow = msui.getMapSubRow(false) - if globalRow then - moveCursorTo(globalRow, CURSOR_MOVE, function() - clickAnim(function() - msui.setMapSubLocal(false) - safeAfter(STEP_PAUSE, done) - end) - end) - else - msui.setMapSubLocal(false) - safeAfter(0.5, done) - end - end) - end) - end - -- Find a visible UI search result whose name contains the given -- substring (case-insensitive). Falls back to the first visible -- non-header row if no match (so the demo still has something @@ -3745,341 +3177,120 @@ function Demo.Start(ctx) return first end - -- After HandleUISearchClick has started a guide in standard mode, - -- the EasyFind highlighter draws on QuestLogMicroButton. Wait - -- briefly for the highlight to appear, then click the button to - -- open the world map. The pending navigation registered by - -- HandleUISearchClick fires when WorldMapFrame becomes visible. - function msui.clickQuestLogMicroBtn(done) - local btn = _G["QuestLogMicroButton"] - if not btn then done(); return end - safeAfter(0.6, function() - moveCursorTo(btn, CURSOR_MOVE, function() - clickAnim(function() - if btn.Click then pcall(btn.Click, btn) end - -- Wait for map fade-in + pending navigation + - -- pin placement. - safeAfter(1.6, done) - end) - end) - end) - end - - -- Click the EasyFindLocationPin to set a SuperTrack waypoint - -- on it (the pin's OnMouseUp handler does this for left-click - -- when the player is on the same map). Also fires the OnMouseUp - -- script directly so the click registers even though the demo's - -- click blocker eats real mouse events. - function msui.clickLocalPin(done) - local pin = _G["EasyFindLocationPin"] - if not pin or not pin:IsShown() then - safeAfter(0.5, done) + -- Helper: fire the click handler for the flight master or + -- Eastern Plaguelands result row, then settle. + local function msui_clickPartial(substring, afterClick) + local target = msui.findResultRowByPartialName(substring) + if not target then + safeAfter(0.5, afterClick) return end - moveCursorTo(pin, CURSOR_MOVE, function() + moveCursorTo(target, CURSOR_MOVE, function() clickAnim(function() - local handler = pin:GetScript("OnMouseUp") - if handler then pcall(handler, pin, "LeftButton") end - safeAfter(SETTLE_PAUSE, done) + stopBlinkCursor() + if target.data and ns.MapSearch and ns.MapSearch.HandleUISearchClick then + pcall(ns.MapSearch.HandleUISearchClick, ns.MapSearch, target.data) + end + afterClick() end) end) end - local mapSearchUI_fastRun = { + local mapSearchUI_run = { ---------------------------------------------------------------- -- SECTION 1: Local Map POI search (flight master) ---------------------------------------------------------------- - -- 1: Make sure Fast Mode is enabled - function(done) hoverModeButton(done) end, - -- 2: Enable Local Map Search filter - function(done) msui.enableLocalFilter(done) end, - -- 3: Type "fli" - function(done) msui.stepFocusAndType("fli", done) end, - -- 6: Click the Flight Master result. Fast Mode places the - -- waypoint directly (no map opens). Minimap hint, then - -- transition to Global section. + -- 1: Open filter menu + function(done) msui.stepFilterBtnClick(function() msui.openFilterDropdown(); safeAfter(STEP_PAUSE, done) end) end, + -- 2: Enable Map Search filter function(done) - local target = msui.findResultRowByPartialName("flight master") - if not target then - safeAfter(0.5, done) - return - end - moveCursorTo(target, CURSOR_MOVE, function() - clickAnim(function() - stopBlinkCursor() - if target.data and ns.MapSearch and ns.MapSearch.HandleUISearchClick then - pcall(ns.MapSearch.HandleUISearchClick, ns.MapSearch, target.data) - end - minimapCallout:SetText("The point is now being tracked.") - safeAfter(SETTLE_PAUSE, function() - showMinimapHint(function() - minimapCallout:Hide() - minimapArrow:Hide() - clearDemoWaypoint() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if searchFrame.editBox.placeholder then - searchFrame.editBox.placeholder:Show() - end - safeAfter(1.0, function() - transitionFS:SetText("Now let's try a global zone search to find Eastern Plaguelands.") - transitionText:Show() - beginSectionTransition(2) - safeAfter(3.5, done) - end) - end) - end) - end) - end) + msui.ensureMapFilterEnabled() + safeAfter(STEP_PAUSE, done) end, - ---------------------------------------------------------------- - -- SECTION 2: Global zone search (Eastern Plaguelands) - ---------------------------------------------------------------- - -- 5: Enable Global Map Search filter + -- 3: Confirm Local is selected function(done) - transitionText:Hide() - msui.switchToGlobalFilter(done) - end, - -- 6: Type "eas" - function(done) msui.stepFocusAndType("eas", done) end, - -- 10: Click the Eastern Plaguelands result. Strict name - -- match: no fallback to first result. Fast Mode with a - -- global zone result opens the map and navigates to the - -- zone. - function(done) - local target = msui.findResultRowByPartialName("eastern plaguelands") - if not target then - safeAfter(0.5, done) - return - end - moveCursorTo(target, CURSOR_MOVE, function() - clickAnim(function() - stopBlinkCursor() - if target.data and ns.MapSearch and ns.MapSearch.HandleUISearchClick then - pcall(ns.MapSearch.HandleUISearchClick, ns.MapSearch, target.data) + local localRow = msui.getMapSubRow(true) + if localRow then + moveCursorTo(localRow, CURSOR_MOVE, function() + if EasyFind.db.uiMapSearchLocal == false then + clickAnim(function() + msui.setMapSubLocal(true) + safeAfter(STEP_PAUSE, done) + end) + else + safeAfter(0.6, done) end - safeAfter(SETTLE_PAUSE + 1.5, function() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if searchFrame.editBox.placeholder then - searchFrame.editBox.placeholder:Show() - end - local closeBtn = getMapCloseBtn() - if closeBtn and closeBtn:IsShown() then - moveCursorTo(closeBtn, CURSOR_MOVE, function() - clickAnim(function() - resetMapSearchState() - closeWorldMap() - safeAfter(0.5, function() - cursor:Hide() - done() - end) - end) - end) - else - resetMapSearchState() - closeWorldMap() - safeAfter(0.5, function() - cursor:Hide() - done() - end) - end - end) end) - end) - end, - } - - -- Common "directOpen + map filter enabled, local/global" baseline - -- every mapSearchUI setupAfter builds on. Reads demoModeFast so - -- the same baseline reuses across both fast and guide variants. - function msui.baseline(isLocal) - EasyFind.db.directOpen = (demoModeFast["mapSearchUI"] ~= false) - if ns.UpdateModeButtonVisual then - pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) - end - msui.ensureMapFilterEnabled() - msui.setMapSubLocal(isLocal) - end - - local mapSearchUI_fastSetupAfter = { - -- 1: Fast mode enabled - function() msui.baseline(true); msui.closeFilterDropdown() end, - -- 2: Local filter enabled - function() msui.baseline(true); msui.closeFilterDropdown() end, - -- 3: "fli" typed - function() - msui.baseline(true); msui.closeFilterDropdown() - searchFrame.editBox:SetText("fli") - if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Hide() end - UI:OnSearchTextChanged("fli") - startBlinkCursor() - end, - -- 4: Flight master clicked, waypoint placed - function() - msui.baseline(true); msui.closeFilterDropdown() - clearDemoWaypoint() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end - end, - -- 5: Global filter enabled - function() msui.baseline(false); msui.closeFilterDropdown() end, - -- 6: "eas" typed - function() - msui.baseline(false); msui.closeFilterDropdown() - searchFrame.editBox:SetText("eas") - if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Hide() end - UI:OnSearchTextChanged("eas") - startBlinkCursor() - end, - -- 7: Eastern Plaguelands clicked, map closed - function() - msui.baseline(false); msui.closeFilterDropdown() - clearDemoWaypoint() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end - resetMapSearchState(); closeWorldMap() - end, - } - - local mapSearchUI_fastStepDefs = { - { text = "Make sure Fast Mode is enabled", section = 1 }, -- 1 - { text = "Enable Local Map Search filter", section = 1 }, -- 2 - { text = 'Start typing "Flight Master"', section = 1 }, -- 3 - { text = "Click the Flight Master result", section = 1 }, -- 4 - { text = "Enable Global Map Search filter", section = 2 }, -- 5 - { text = 'Start typing "Eastern Plaguelands"', section = 2 }, -- 6 - { text = "Click the Eastern Plaguelands result", section = 2 }, -- 7 - } - local mapSearchUI_fastSections = { - { header = "Local Map POI search: Flight Master", section = 1, firstStep = 1, lastStep = 4 }, - { header = "Global zone search: Eastern Plaguelands", section = 2, firstStep = 5, lastStep = 7 }, - } - - -- Guide-mode variant: clicking a result triggers HandleUISearchClick's - -- guide branch (StartGuide + SetPendingNavigation), so the demo - -- has additional steps to walk through the guide: click the - -- highlighted QuestLogMicroButton, follow any breadcrumbs, then - -- click the destination pin to track. - local mapSearchUI_guideStepDefs = { - { text = "Make sure Guide Mode is enabled", section = 1 }, -- 1 - { text = "Enable Local Map Search filter", section = 1 }, -- 2 - { text = 'Start typing "Flight Master"', section = 1 }, -- 3 - { text = "Click the Flight Master result", section = 1 }, -- 4 - { text = "Click the highlighted Quest Log button", section = 1 }, -- 5 - { text = "Click the flight master pin to track", section = 1 }, -- 6 - { text = "Enable Global Map Search filter", section = 2 }, -- 7 - { text = 'Start typing "Eastern Plaguelands"', section = 2 }, -- 8 - { text = "Click the Eastern Plaguelands result", section = 2 }, -- 9 - { text = "Click the highlighted Quest Log button", section = 2 }, -- 10 - { text = "Follow the breadcrumbs to the zone", section = 2 }, -- 11 - } - local mapSearchUI_guideSections = { - { header = "Local Map POI search: Flight Master", section = 1, firstStep = 1, lastStep = 6 }, - { header = "Global zone search: Eastern Plaguelands", section = 2, firstStep = 7, lastStep = 11 }, - } - - local mapSearchUI_guideRun = { - -- 1: Switch to Guide Mode - function(done) hoverModeButtonToGuide(done) end, - -- 2: Enable Local Map Search filter - function(done) msui.enableLocalFilter(done) end, - -- 3: Type "fli" - function(done) msui.stepFocusAndType("fli", done) end, - -- 6: Click result (HandleUISearchClick → guide starts) - function(done) - local target = msui.findResultRowByPartialName("flight master") - if not target then + else + msui.setMapSubLocal(true) safeAfter(0.5, done) - return end - moveCursorTo(target, CURSOR_MOVE, function() - clickAnim(function() - stopBlinkCursor() - if target.data and ns.MapSearch and ns.MapSearch.HandleUISearchClick then - pcall(ns.MapSearch.HandleUISearchClick, ns.MapSearch, target.data) - end - safeAfter(SETTLE_PAUSE, done) - end) - end) end, - -- 7: Click highlighted Quest Log button → opens map - function(done) msui.clickQuestLogMicroBtn(done) end, - -- 8: Click pin to track → minimap hint → transition + -- 4: Type "fli" + function(done) msui.stepFocusAndType("fli", done) end, + -- 5: Click the Flight Master result. function(done) - msui.clickLocalPin(function() - minimapCallout:SetText("Check your minimap: your target is now tracked!") - showMinimapHint(function() - minimapCallout:Hide() - minimapArrow:Hide() - clearDemoWaypoint() - local closeBtn = getMapCloseBtn() - local function finish() - resetMapSearchState() - closeWorldMap() + msui_clickPartial("flight master", function() + minimapCallout:SetText("The point is now being tracked.") + safeAfter(SETTLE_PAUSE, function() + showMinimapHint(function() + minimapCallout:Hide() + minimapArrow:Hide() + clearDemoWaypoint() searchFrame.editBox:SetText("") UI:OnSearchTextChanged("") if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end - safeAfter(0.8, function() + safeAfter(1.0, function() transitionFS:SetText("Now let's try a global zone search to find Eastern Plaguelands.") transitionText:Show() beginSectionTransition(2) safeAfter(3.5, done) end) - end - if closeBtn and closeBtn:IsShown() then - moveCursorTo(closeBtn, CURSOR_MOVE, function() - clickAnim(finish) - end) - else - finish() - end + end) end) end) end, - -- 7: Enable Global Map Search filter + ---------------------------------------------------------------- + -- SECTION 2: Global zone search (Eastern Plaguelands) + ---------------------------------------------------------------- + -- 6: Open filter menu function(done) transitionText:Hide() - msui.switchToGlobalFilter(done) + msui.stepFilterBtnClick(function() msui.openFilterDropdown(); safeAfter(STEP_PAUSE, done) end) end, - -- 8: Type "eas" - function(done) msui.stepFocusAndType("eas", done) end, - -- 12: Click result (guide starts) + -- 7: Switch to Global function(done) - local target = msui.findResultRowByPartialName("eastern plaguelands") - if not target then + msui.ensureMapFilterEnabled() + local globalRow = msui.getMapSubRow(false) + if globalRow then + moveCursorTo(globalRow, CURSOR_MOVE, function() + clickAnim(function() + msui.setMapSubLocal(false) + safeAfter(STEP_PAUSE, done) + end) + end) + else + msui.setMapSubLocal(false) safeAfter(0.5, done) - return end - moveCursorTo(target, CURSOR_MOVE, function() - clickAnim(function() - stopBlinkCursor() - if target.data and ns.MapSearch and ns.MapSearch.HandleUISearchClick then - pcall(ns.MapSearch.HandleUISearchClick, ns.MapSearch, target.data) - end - safeAfter(SETTLE_PAUSE, done) - end) - end) end, - -- 13: Click highlighted Quest Log button → opens map - function(done) msui.clickQuestLogMicroBtn(done) end, - -- 14: Follow breadcrumbs to the zone, then close the map + -- 8: Type "eas" + function(done) msui.stepFocusAndType("eas", done) end, + -- 9: Click the Eastern Plaguelands result. function(done) - clickBreadcrumbsUntilArrived(function() - safeAfter(1.5, function() + msui_clickPartial("eastern plaguelands", function() + safeAfter(SETTLE_PAUSE + 1.5, function() + searchFrame.editBox:SetText("") + UI:OnSearchTextChanged("") + if searchFrame.editBox.placeholder then + searchFrame.editBox.placeholder:Show() + end local closeBtn = getMapCloseBtn() local function finish() resetMapSearchState() closeWorldMap() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if searchFrame.editBox.placeholder then - searchFrame.editBox.placeholder:Show() - end safeAfter(0.5, function() cursor:Hide() done() @@ -4097,12 +3308,20 @@ function Demo.Start(ctx) end, } - local mapSearchUI_guideSetupAfter = { - -- 1: Guide mode enabled - function() msui.baseline(true); msui.closeFilterDropdown() end, - -- 2: Local filter enabled + -- Baseline every setupAfter builds on: filter enabled + local/global sub. + function msui.baseline(isLocal) + msui.ensureMapFilterEnabled() + msui.setMapSubLocal(isLocal) + end + + local mapSearchUI_setupAfter = { + -- 1: Filter menu opened + function() msui.baseline(true); msui.openFilterDropdown() end, + -- 2: Map filter enabled + function() msui.baseline(true); msui.openFilterDropdown() end, + -- 3: Local confirmed function() msui.baseline(true); msui.closeFilterDropdown() end, - -- 3: "fli" typed + -- 4: "fli" typed function() msui.baseline(true); msui.closeFilterDropdown() searchFrame.editBox:SetText("fli") @@ -4110,22 +3329,16 @@ function Demo.Start(ctx) UI:OnSearchTextChanged("fli") startBlinkCursor() end, - -- 4: Result clicked, guide started + -- 5: Flight master clicked function() msui.baseline(true); msui.closeFilterDropdown() + clearDemoWaypoint() searchFrame.editBox:SetText("") UI:OnSearchTextChanged("") - if searchFrame.editBox.placeholder then - searchFrame.editBox.placeholder:Show() - end - end, - -- 5: QuestLogMicroButton clicked, map open - function() msui.baseline(true); msui.closeFilterDropdown() end, - -- 6: Pin clicked, tracked, map closed (section 1 end) - function() - msui.baseline(true); msui.closeFilterDropdown() - clearDemoWaypoint(); resetMapSearchState(); closeWorldMap() + if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end end, + -- 6: Filter menu opened for section 2 + function() msui.baseline(false); msui.openFilterDropdown() end, -- 7: Global filter enabled function() msui.baseline(false); msui.closeFilterDropdown() end, -- 8: "eas" typed @@ -4136,36 +3349,20 @@ function Demo.Start(ctx) UI:OnSearchTextChanged("eas") startBlinkCursor() end, - -- 9: Result clicked, guide started + -- 9: Eastern Plaguelands clicked, map closed function() msui.baseline(false); msui.closeFilterDropdown() + clearDemoWaypoint() searchFrame.editBox:SetText("") UI:OnSearchTextChanged("") if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end - end, - -- 10: QuestLogMicroButton clicked, map open - function() msui.baseline(false); msui.closeFilterDropdown() end, - -- 11: Breadcrumbs followed, map closed (final) - function() - msui.baseline(false); msui.closeFilterDropdown() resetMapSearchState(); closeWorldMap() end, } DEMOS.mapSearchUI.rebuild = function(def) - savedDirectOpen = EasyFind.db.directOpen - local isFast = demoModeFast["mapSearchUI"] ~= false - if isFast then - def.stepDefs = mapSearchUI_fastStepDefs - def.sections = mapSearchUI_fastSections - def.run = mapSearchUI_fastRun - def.setupAfter = mapSearchUI_fastSetupAfter - else - def.stepDefs = mapSearchUI_guideStepDefs - def.sections = mapSearchUI_guideSections - def.run = mapSearchUI_guideRun - def.setupAfter = mapSearchUI_guideSetupAfter - end + def.run = mapSearchUI_run + def.setupAfter = mapSearchUI_setupAfter end -------------------------------------------------------------------- @@ -4466,8 +3663,6 @@ function Demo.Start(ctx) end - DEMOS.outfits.supportsModeToggle = false - DEMOS.outfits.rebuild = function(def) local outfitEntry = od.findOutfitEntry() @@ -4485,7 +3680,6 @@ function Demo.Start(ctx) od.entry = outfitEntry od.pinnedByDemo = false - local savedDirectOpen = EasyFind.db.directOpen local savedFilters = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.outfits @@ -4544,7 +3738,6 @@ function Demo.Start(ctx) function() snap() end, function() od.cleanup() - EasyFind.db.directOpen = savedDirectOpen if EasyFind.db.uiSearchFilters then EasyFind.db.uiSearchFilters.outfits = savedFilters end @@ -4823,8 +4016,6 @@ function Demo.Start(ctx) asd.clearSearch() end - DEMOS.appearanceSets.supportsModeToggle = true - DEMOS.appearanceSets.rebuild = function(def) local _, _, playerClassID = UnitClass("player") local myArmor = CLASS_ARMOR[playerClassID] @@ -4833,12 +4024,10 @@ function Demo.Start(ctx) if armor ~= myArmor then altClassID = cid; break end end local altClassName = altClassID and (GetClassInfo(altClassID)) or "another class" - local isFast = demoModeFast["appearanceSets"] ~= false def.sections = { { header = "Preview on your character", section = 1, firstStep = 1, lastStep = 3 }, - { header = isFast and "Open in Collections" - or "Guide to Collections", section = 2, firstStep = 4, lastStep = 5 }, + { header = "Open in Collections", section = 2, firstStep = 4, lastStep = 5 }, { header = "Try " .. altClassName .. "'s armor", section = 3, firstStep = 6, lastStep = 8 }, } def.stepDefs = { @@ -4846,8 +4035,7 @@ function Demo.Start(ctx) { text = "Start typing an appearance set name", section = 1 }, { text = "Ctrl+Click to preview on your character", section = 1 }, { text = "Search the same set again", section = 2 }, - { text = isFast and "Click to open it in Collections" - or "Click to start the guide", section = 2 }, + { text = "Click to open it in Collections", section = 2 }, { text = "Switch class filter to " .. altClassName, section = 3 }, { text = "Search a " .. altClassName .. " appearance set", section = 3 }, { text = "Ctrl+Click to preview on your character", section = 3 }, @@ -4885,11 +4073,9 @@ function Demo.Start(ctx) end, -- 5: Regular click → Collections (opens, shows callout, closes) function(done) - local fast = demoModeFast["appearanceSets"] ~= false asd.regularClickSet( - fast and "A regular click opens the set in Collections." - or "A regular click starts the step-by-step guide.", - fast, done) + "A regular click opens the set in Collections.", + true, done) end, -- 6: Switch class filter + cache alt set function(done) @@ -5315,7 +4501,7 @@ function Demo.Start(ctx) -- Cancel any in-flight step animation. Bumps the generation counter -- so every async callback captured before this point aborts on its -- next check, stops the current timer / cursor OnUpdate / blink - -- cursor, and clears stray visuals left by hoverModeButton. + -- cursor, and clears any stray tooltip / hover state. local function cancelInFlight() stepGen = stepGen + 1 wipe(pendingTimers) @@ -5353,88 +4539,16 @@ function Demo.Start(ctx) -- animation. These are used by Previous to rewind: reset to clean -- slate, then call setupAfterStep[target] for the new position. DEMOS.uiSearch.setupAfter = { - -- 1: Fast mode enabled - function() - EasyFind.db.directOpen = true - if ns.UpdateModeButtonVisual then - pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) - end - end, - -- 2: Fast mode + "sp" typed + results showing - function() - EasyFind.db.directOpen = true - if ns.UpdateModeButtonVisual then - pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) - end - searchFrame.editBox:SetText("sp") - UI:OnSearchTextChanged("sp") - startBlinkCursor() - end, - -- 3: Fast mode, Spellbook opened then cleaned up (the run - -- function closes its window and clears the search before - -- calling done), so the end state is just fast mode idle. - function() - EasyFind.db.directOpen = true - if ns.UpdateModeButtonVisual then - pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) - end - end, - -- 4: Guide mode toggled, clean state - function() - EasyFind.db.directOpen = false - if ns.UpdateModeButtonVisual then - pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) - end - end, - -- 5: Guide mode + "sp" typed + results showing + -- 1: "sp" typed + results showing function() - EasyFind.db.directOpen = false - if ns.UpdateModeButtonVisual then - pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) - end searchFrame.editBox:SetText("sp") UI:OnSearchTextChanged("sp") startBlinkCursor() end, - -- 6: Spellbook result selected in guide mode; Highlight - -- system is now showing its first arrow on the micro - -- button. - function() - EasyFind.db.directOpen = false - if ns.UpdateModeButtonVisual then - pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) - end - UI:SelectResult(spellbookEntry) - end, - -- 7: PlayerSpellsFrame now open, Highlight advances to tab 3 - function() - EasyFind.db.directOpen = false - if ns.UpdateModeButtonVisual then - pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) - end - UI:SelectResult(spellbookEntry) - if not InCombatLockdown() then - pcall(ShowUIPanel, _G["PlayerSpellsFrame"]) - end - end, - -- 8: Spellbook tab (3) selected - function() - EasyFind.db.directOpen = false - if ns.UpdateModeButtonVisual then - pcall(ns.UpdateModeButtonVisual, searchFrame.modeBtn) - end - UI:SelectResult(spellbookEntry) - if not InCombatLockdown() then - pcall(ShowUIPanel, _G["PlayerSpellsFrame"]) - end - local psf = _G["PlayerSpellsFrame"] - local tab - if psf and psf.TabSystem and psf.TabSystem.tabs then - tab = psf.TabSystem.tabs[3] - end - if not tab then tab = _G["PlayerSpellsFrameTab3"] end - if tab and tab.Click then pcall(tab.Click, tab) end - end, + -- 2: Spellbook opened then cleaned up (the run function + -- closes its window and clears the search before calling + -- done), so the end state is idle. + function() end, } setupAfterStep = DEMOS.uiSearch.setupAfter @@ -5470,7 +4584,6 @@ function Demo.Start(ctx) rebuildStepRows() refreshStepList() updateButtons() - refreshModeToggle() -- Disabled demo overlay if def.disabled then disabledText:SetText(def.disabledMessage or "This demo is not available.") diff --git a/MapSearch.lua b/MapSearch.lua index 851ad8d..0303506 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -12,7 +12,6 @@ local mmin, mmax, mpi, mfloor = Utils.mmin, Utils.mmax, Utils.mpi, Utils.mfloor 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 GOLD_COLOR = ns.GOLD_COLOR local YELLOW_HIGHLIGHT = ns.YELLOW_HIGHLIGHT local DEFAULT_OPACITY = ns.DEFAULT_OPACITY @@ -1545,69 +1544,25 @@ function MapSearch:CreateSearchFrame() local contentSz = ns.SEARCHBAR_HEIGHT * ns.SEARCHBAR_FILL local iconSz = contentSz * ns.SEARCHBAR_ICON_SCALE - local localModeBtn = CreateFrame("Button", nil, searchFrame) - localModeBtn:SetPoint("TOP", searchFrame, "TOP", 0, 0) - localModeBtn:SetPoint("BOTTOM", searchFrame, "BOTTOM", 0, 0) - localModeBtn:SetPoint("LEFT", searchFrame, "LEFT", 0, 0) - localModeBtn:SetWidth(ns.SEARCHBAR_HEIGHT) - localModeBtn:SetFrameLevel(searchFrame:GetFrameLevel() + 10) + local localIconHolder = CreateFrame("Frame", nil, searchFrame) + localIconHolder:SetPoint("TOP", searchFrame, "TOP", 0, 0) + localIconHolder:SetPoint("BOTTOM", searchFrame, "BOTTOM", 0, 0) + localIconHolder:SetPoint("LEFT", searchFrame, "LEFT", 0, 0) + localIconHolder:SetWidth(ns.SEARCHBAR_HEIGHT) + localIconHolder:SetFrameLevel(searchFrame:GetFrameLevel() + 10) - local searchIcon = localModeBtn:CreateTexture(nil, "OVERLAY") + local searchIcon = localIconHolder:CreateTexture(nil, "OVERLAY") searchIcon:SetSize(iconSz, iconSz) searchIcon:SetPoint("CENTER") + searchIcon:SetAtlas("common-search-magnifyingglass") + searchIcon:SetVertexColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) searchFrame.searchIcon = searchIcon - localModeBtn.icon = searchIcon - - local localModeBtnBg = localModeBtn:CreateTexture(nil, "ARTWORK") - localModeBtnBg:SetAllPoints() - localModeBtnBg:SetTexture(796424) - localModeBtnBg:Hide() - localModeBtn.btnBg = localModeBtnBg - localModeBtn:SetHighlightTexture(130757) - searchFrame.modeBtn = localModeBtn - - local function UpdateLocalModeBtnVisual() - if EasyFind.db.localMapDirectOpen then - searchIcon:SetAtlas(nil) - searchIcon:SetTexture(LIGHTNING_BOLT_TEX) - else - searchIcon:SetTexture(nil) - searchIcon:SetAtlas("common-search-magnifyingglass") - end - searchIcon:SetVertexColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) - end - localModeBtn.UpdateVisual = UpdateLocalModeBtnVisual - - localModeBtn:SetScript("OnEnter", function(self) - self.btnBg:Show() - GameTooltip:SetOwner(self, "ANCHOR_TOP") - if EasyFind.db.localMapDirectOpen then - GameTooltip:SetText("Fast Mode") - GameTooltip:AddLine("Click to switch to Guide mode.", 1, 1, 1, true) - else - GameTooltip:SetText("Guide Mode") - GameTooltip:AddLine("Click to switch to Fast mode.", 1, 1, 1, true) - end - GameTooltip:AddLine("Hold |cFF00FF00Shift|r and drag to reposition.", 0.7, 0.7, 0.7) - GameTooltip:Show() - end) - localModeBtn:SetScript("OnLeave", function(self) - if not self.keyboardFocused then self.btnBg:Hide() end - GameTooltip_Hide() - end) - localModeBtn:SetScript("OnClick", function(self) - EasyFind.db.localMapDirectOpen = not EasyFind.db.localMapDirectOpen - UpdateLocalModeBtnVisual() - local optPanel = _G["EasyFindOptionsFrame"] - if optPanel and optPanel.localNavCheckbox then - optPanel.localNavCheckbox:SetChecked(EasyFind.db.localMapDirectOpen) - end - end) - UpdateLocalModeBtnVisual() + localIconHolder.icon = searchIcon + searchFrame.modeBtn = localIconHolder local editBox = CreateFrame("EditBox", "EasyFindMapSearchBox", searchFrame) editBox:SetHeight(contentSz) - editBox:SetPoint("LEFT", localModeBtn, "RIGHT", 0, 0) + editBox:SetPoint("LEFT", localIconHolder, "RIGHT", 0, 0) -- RIGHT anchor set below after clearBtn creation editBox:SetFontObject(ns.SEARCHBAR_FONT) editBox:SetAutoFocus(false) @@ -1753,13 +1708,8 @@ function MapSearch:CreateSearchFrame() editBox:HookScript("OnEnter", function(self) if self:HasFocus() or not placeholder:IsShown() then return end GameTooltip:SetOwner(searchFrame, "ANCHOR_TOP") - if EasyFind.db.localMapDirectOpen then - GameTooltip:SetText("|cFFFFD100Zone Search|r (Fast)") - GameTooltip:AddLine("Navigates directly to results without zone highlighting.", 1, 1, 1, true) - else - GameTooltip:SetText("|cFFFFD100Zone Search|r (Guide)") - GameTooltip:AddLine("Highlights zones on the map and guides navigation step by step.", 1, 1, 1, true) - end + GameTooltip:SetText("|cFFFFD100Zone Search|r") + GameTooltip:AddLine("Right-click a result for Pin or Guide options.", 1, 1, 1, true) GameTooltip:AddLine("Hold |cFF00FF00Shift|r and drag to reposition.", 0.7, 0.7, 0.7) GameTooltip:Show() end) @@ -1986,69 +1936,25 @@ function MapSearch:CreateSearchFrame() end end) - local globalModeBtn = CreateFrame("Button", nil, globalSearchFrame) - globalModeBtn:SetPoint("TOP", globalSearchFrame, "TOP", 0, 0) - globalModeBtn:SetPoint("BOTTOM", globalSearchFrame, "BOTTOM", 0, 0) - globalModeBtn:SetPoint("LEFT", globalSearchFrame, "LEFT", 0, 0) - globalModeBtn:SetWidth(ns.SEARCHBAR_HEIGHT) - globalModeBtn:SetFrameLevel(globalSearchFrame:GetFrameLevel() + 10) + local globalIconHolder = CreateFrame("Frame", nil, globalSearchFrame) + globalIconHolder:SetPoint("TOP", globalSearchFrame, "TOP", 0, 0) + globalIconHolder:SetPoint("BOTTOM", globalSearchFrame, "BOTTOM", 0, 0) + globalIconHolder:SetPoint("LEFT", globalSearchFrame, "LEFT", 0, 0) + globalIconHolder:SetWidth(ns.SEARCHBAR_HEIGHT) + globalIconHolder:SetFrameLevel(globalSearchFrame:GetFrameLevel() + 10) - local globalSearchIcon = globalModeBtn:CreateTexture(nil, "OVERLAY") + local globalSearchIcon = globalIconHolder:CreateTexture(nil, "OVERLAY") globalSearchIcon:SetSize(iconSz, iconSz) globalSearchIcon:SetPoint("CENTER") + globalSearchIcon:SetAtlas("common-search-magnifyingglass") + globalSearchIcon:SetVertexColor(0.4, 0.8, 1) globalSearchFrame.searchIcon = globalSearchIcon - globalModeBtn.icon = globalSearchIcon - - local globalModeBtnBg = globalModeBtn:CreateTexture(nil, "ARTWORK") - globalModeBtnBg:SetAllPoints() - globalModeBtnBg:SetTexture(796424) - globalModeBtnBg:Hide() - globalModeBtn.btnBg = globalModeBtnBg - globalModeBtn:SetHighlightTexture(130757) - globalSearchFrame.modeBtn = globalModeBtn - - local function UpdateGlobalModeBtnVisual() - if EasyFind.db.globalMapDirectOpen then - globalSearchIcon:SetAtlas(nil) - globalSearchIcon:SetTexture(LIGHTNING_BOLT_TEX) - else - globalSearchIcon:SetTexture(nil) - globalSearchIcon:SetAtlas("common-search-magnifyingglass") - end - globalSearchIcon:SetVertexColor(0.4, 0.8, 1) - end - globalModeBtn.UpdateVisual = UpdateGlobalModeBtnVisual - - globalModeBtn:SetScript("OnEnter", function(self) - self.btnBg:Show() - GameTooltip:SetOwner(self, "ANCHOR_TOP") - if EasyFind.db.globalMapDirectOpen then - GameTooltip:SetText("Fast Mode") - GameTooltip:AddLine("Click to switch to Guide mode.", 1, 1, 1, true) - else - GameTooltip:SetText("Guide Mode") - GameTooltip:AddLine("Click to switch to Fast mode.", 1, 1, 1, true) - end - GameTooltip:AddLine("Hold |cFF00FF00Shift|r and drag to reposition.", 0.7, 0.7, 0.7) - GameTooltip:Show() - end) - globalModeBtn:SetScript("OnLeave", function(self) - if not self.keyboardFocused then self.btnBg:Hide() end - GameTooltip_Hide() - end) - globalModeBtn:SetScript("OnClick", function(self) - EasyFind.db.globalMapDirectOpen = not EasyFind.db.globalMapDirectOpen - UpdateGlobalModeBtnVisual() - local optPanel = _G["EasyFindOptionsFrame"] - if optPanel and optPanel.globalNavCheckbox then - optPanel.globalNavCheckbox:SetChecked(EasyFind.db.globalMapDirectOpen) - end - end) - UpdateGlobalModeBtnVisual() + globalIconHolder.icon = globalSearchIcon + globalSearchFrame.modeBtn = globalIconHolder local globalEditBox = CreateFrame("EditBox", "EasyFindMapGlobalSearchBox", globalSearchFrame) globalEditBox:SetHeight(contentSz) - globalEditBox:SetPoint("LEFT", globalModeBtn, "RIGHT", 0, 0) + globalEditBox:SetPoint("LEFT", globalIconHolder, "RIGHT", 0, 0) -- RIGHT anchor set below after globalClearBtn creation globalEditBox:SetFontObject(ns.SEARCHBAR_FONT) globalEditBox:SetAutoFocus(false) @@ -2194,13 +2100,8 @@ function MapSearch:CreateSearchFrame() globalEditBox:HookScript("OnEnter", function(self) if self:HasFocus() or not globalPlaceholder:IsShown() then return end GameTooltip:SetOwner(globalSearchFrame, "ANCHOR_TOP") - if EasyFind.db.globalMapDirectOpen then - GameTooltip:SetText("|cFF66CCFFGlobal Search|r (Fast)") - GameTooltip:AddLine("Navigates directly to clicked zones and dungeon entrances.", 1, 1, 1, true) - else - GameTooltip:SetText("|cFF66CCFFGlobal Search|r (Guide)") - GameTooltip:AddLine("Highlights zones on the map and guides navigation step by step.", 1, 1, 1, true) - end + GameTooltip:SetText("|cFF66CCFFGlobal Search|r") + GameTooltip:AddLine("Right-click a result for Pin or Guide options.", 1, 1, 1, true) GameTooltip:AddLine("Hold |cFF00FF00Shift|r and drag to reposition.", 0.7, 0.7, 0.7) GameTooltip:Show() end) diff --git a/Options.lua b/Options.lua index 1dca563..b9e05c5 100644 --- a/Options.lua +++ b/Options.lua @@ -995,33 +995,6 @@ function Options:Initialize() uiSep:SetHeight(1) uiSep:SetColorTexture(0.8, 0.65, 0.0, 0.6) - local uiSpeedBox = CreateFrame("Frame", nil, sec1, "BackdropTemplate") - uiSpeedBox:SetPoint("TOPLEFT", sec1, "TOPLEFT", 8, -38) - uiSpeedBox:SetSize(210, 36) - uiSpeedBox:SetBackdrop({ - edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", - edgeSize = 14, - insets = { left = 3, right = 3, top = 3, bottom = 3 }, - }) - uiSpeedBox:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.6) - local uiSpeedLabel = uiSpeedBox:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - uiSpeedLabel:SetPoint("BOTTOM", uiSpeedBox, "TOP", 0, -8) - uiSpeedLabel:SetText("Speed") - - local directOpenCheckbox = CreateCheckbox(sec1, "DirectOpen", "Open Panels Directly", - "When enabled (default), Fast Mode opens the destination panel immediately.\n\nWhen disabled, Guide Mode walks you through step-by-step with highlights showing where to click.") - directOpenCheckbox:SetPoint("TOPLEFT", uiSpeedBox, "TOPLEFT", 8, -4) - directOpenCheckbox:SetChecked(EasyFind.db.directOpen or false) - directOpenCheckbox:SetScript("OnClick", function(self) - EasyFind.db.directOpen = self:GetChecked() - ns.Highlight:ClearAll() - local sf = _G["EasyFindSearchFrame"] - if sf and sf.modeBtn and ns.UpdateModeButtonVisual then - ns.UpdateModeButtonVisual(sf.modeBtn) - end - end) - optionsFrame.directOpenCheckbox = directOpenCheckbox - local resizeUIBtn = CreateFrame("Button", nil, sec1, "UIPanelButtonTemplate") resizeUIBtn:SetSize(160, 22) resizeUIBtn:SetPoint("BOTTOMLEFT", sec1, "BOTTOMLEFT", 8, 32) @@ -1038,7 +1011,7 @@ function Options:Initialize() local smartShowCheckbox = CreateCheckbox(sec1, "SmartShow", "Smart Show |cFF888888(Recommended)|r", "When enabled, the UI search bar hides itself until you move your mouse near its position.\n\nThe bar reappears when your mouse enters the area and fades away when you move away.\n\nA separate Smart Show toggle for map search bars is available in the Map Search section.") - smartShowCheckbox:SetPoint("TOPLEFT", uiSpeedBox, "BOTTOMLEFT", 8, -6) + smartShowCheckbox:SetPoint("TOPLEFT", sec1, "TOPLEFT", 16, -38) smartShowCheckbox:SetChecked(EasyFind.db.smartShow or false) smartShowCheckbox:SetScript("OnClick", function(self) EasyFind.db.smartShow = self:GetChecked() @@ -1097,7 +1070,7 @@ function Options:Initialize() StaticPopup_Show("EASYFIND_RESET_UI_POS") end) - uiControls = { resizeUIBtn, resetUIBtn, resetUIPosBtn, uiFontSlider, directOpenCheckbox, smartShowCheckbox, staticOpacityCheckbox, uiResultsAboveCheckbox } + uiControls = { resizeUIBtn, resetUIBtn, resetUIPosBtn, uiFontSlider, smartShowCheckbox, staticOpacityCheckbox, uiResultsAboveCheckbox } UpdateUIToggleVisual() -- SECTION 3: Map Search @@ -1134,46 +1107,9 @@ function Options:Initialize() mapSep:SetHeight(1) mapSep:SetColorTexture(0.8, 0.65, 0.0, 0.6) - local mapSpeedBox = CreateFrame("Frame", nil, sec2, "BackdropTemplate") - mapSpeedBox:SetPoint("TOPLEFT", sec2, "TOPLEFT", 8, -38) - mapSpeedBox:SetSize(210, 72) - mapSpeedBox:SetBackdrop({ - edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", - edgeSize = 14, - insets = { left = 3, right = 3, top = 3, bottom = 3 }, - }) - mapSpeedBox:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.6) - local mapSpeedLabel = mapSpeedBox:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - mapSpeedLabel:SetPoint("BOTTOM", mapSpeedBox, "TOP", 0, -8) - mapSpeedLabel:SetText("Speed") - - local localNavCheckbox = CreateCheckbox(sec2, "LocalNav", "Navigate Directly: Zone Bar", - "When enabled, clicking zone or entrance results in the zone search bar navigates directly without step-by-step zone highlighting.\n\nAlso toggled by clicking the search icon on the zone bar.") - localNavCheckbox:SetPoint("TOPLEFT", mapSpeedBox, "TOPLEFT", 8, -10) - localNavCheckbox:SetChecked(EasyFind.db.localMapDirectOpen or false) - localNavCheckbox:SetScript("OnClick", function(self) - EasyFind.db.localMapDirectOpen = self:GetChecked() - if ns.MapSearch and ns.MapSearch.UpdateMapModeBtns then - ns.MapSearch:UpdateMapModeBtns() - end - end) - optionsFrame.localNavCheckbox = localNavCheckbox - - local globalNavCheckbox = CreateCheckbox(sec2, "GlobalNav", "Navigate Directly: Global Bar", - "When enabled, clicking zone or dungeon results in the global search bar navigates directly without breadcrumb zone highlighting.\n\nAlso toggled by clicking the search icon on the global bar.") - globalNavCheckbox:SetPoint("TOPLEFT", localNavCheckbox, "BOTTOMLEFT", 0, -4) - globalNavCheckbox:SetChecked(EasyFind.db.globalMapDirectOpen or false) - globalNavCheckbox:SetScript("OnClick", function(self) - EasyFind.db.globalMapDirectOpen = self:GetChecked() - if ns.MapSearch and ns.MapSearch.UpdateMapModeBtns then - ns.MapSearch:UpdateMapModeBtns() - end - end) - optionsFrame.globalNavCheckbox = globalNavCheckbox - local rareTrackCheckbox = CreateCheckbox(sec2, "RareTrack", "Auto-track Rares", "When enabled, active rare mobs are always displayed as pins on the world map without needing to search.\n\nThis can also be toggled from the filter dropdown on the zone search bar.") - rareTrackCheckbox:SetPoint("TOPLEFT", mapSpeedBox, "BOTTOMLEFT", 0, -4) + rareTrackCheckbox:SetPoint("TOPLEFT", sec2, "TOPLEFT", 16, -38) rareTrackCheckbox:SetChecked(EasyFind.db.alwaysShowRares or false) rareTrackCheckbox:SetScript("OnClick", function(self) EasyFind.db.alwaysShowRares = self:GetChecked() @@ -1276,15 +1212,15 @@ function Options:Initialize() optionsFrame.mapIconSlider = mapIconSlider local minimapGroup = CreateMultiSelectDropdown(sec2, "Minimap", { - { label = "Arrow Glow", shortLabel = "Arrow", dbKey = "minimapArrowGlow", default = true, + { label = "Arrow Glow", shortLabel = "Arrow", dbKey = "minimapArrowGlow", default = false, tooltip = "A pulsing glow highlights the minimap perimeter arrow that points toward your active map pin.\nDisable if you find the glow distracting." }, { label = "Only EasyFind Pins", dbKey = "glowOnlyEasyFind", default = false, indent = true, hiddenFromSummary = true, parentDbKey = "minimapArrowGlow", tooltip = "When enabled, the arrow glow only appears for waypoints placed by EasyFind (clicking search results or navigation pins).\nWaypoints placed by Ctrl+clicking the map or other means will not show the glow." }, - { label = "Guide Circle", shortLabel = "Circle", dbKey = "minimapGuideCircle", default = true, + { label = "Guide Circle", shortLabel = "Circle", dbKey = "minimapGuideCircle", default = false, tooltip = "A directional ring and arrow appears around your character on the minimap when a map pin is nearby, pointing toward the destination.\nDisable if you prefer only the default minimap pin." }, { label = "Only EasyFind Pins", dbKey = "circleOnlyEasyFind", default = false, indent = true, hiddenFromSummary = true, parentDbKey = "minimapGuideCircle", tooltip = "When enabled, the guide circle only appears for waypoints placed by EasyFind.\nWaypoints placed by Ctrl+clicking the map or other means will not show the circle." }, - { label = "Pin Glow", shortLabel = "Glow", dbKey = "minimapPinGlow", default = true, indent = true, parentDbKey = "minimapGuideCircle", + { label = "Pin Glow", shortLabel = "Glow", dbKey = "minimapPinGlow", default = false, indent = true, parentDbKey = "minimapGuideCircle", tooltip = "A pulsing glow appears on the minimap pin when the guide circle shrinks onto it.\nDisable if you find the glow distracting." }, }, nil, FLYOUT_W) minimapGroup:SetPoint("TOPLEFT", mapPinGroup, "BOTTOMLEFT", 0, -6) @@ -1343,7 +1279,7 @@ function Options:Initialize() end) mapControls = { - resizeMapBtn, resetMapBtn, resetMapPosBtn, localNavCheckbox, globalNavCheckbox, rareTrackCheckbox, + resizeMapBtn, resetMapBtn, resetMapPosBtn, rareTrackCheckbox, searchBarGroup, mapPinGroup, minimapGroup, automationGroup } UpdateMapToggleVisual() @@ -1686,9 +1622,6 @@ function Options:DoResetAll() EasyFind.db.mapSearchPositionMax = nil EasyFind.db.globalSearchPositionMax = nil EasyFind.db.mapSearchYOffset = 0 - EasyFind.db.directOpen = true - EasyFind.db.localMapDirectOpen = false - EasyFind.db.globalMapDirectOpen = false EasyFind.db.smartShow = false EasyFind.db.resultsTheme = "Retail" EasyFind.db.uiResultsHeight = 280 @@ -1706,13 +1639,13 @@ function Options:DoResetAll() EasyFind.db.minimapButtonAngle = 200 EasyFind.db.arrivalDistance = 10 EasyFind.db.panelOpacity = 0.9 - EasyFind.db.minimapArrowGlow = true + EasyFind.db.minimapArrowGlow = false EasyFind.db.glowOnlyEasyFind = false EasyFind.db.circleOnlyEasyFind = false - EasyFind.db.minimapGuideCircle = true + EasyFind.db.minimapGuideCircle = false EasyFind.db.autoPinClear = true EasyFind.db.autoTrackPins = true - EasyFind.db.minimapPinGlow = true + EasyFind.db.minimapPinGlow = false EasyFind.db.guideCircleScale = 1.0 EasyFind.db.mapSmartShow = false EasyFind.db.hideSearchBarsMaximized = true @@ -1782,14 +1715,6 @@ function Options:DoResetAll() optionsFrame.opacitySlider:SetValue(DEFAULT_OPACITY) optionsFrame.uiFontSlider:SetValue(0.9) optionsFrame.mapFontSlider:SetValue(0.9) - optionsFrame.directOpenCheckbox:SetChecked(true) - local sf = _G["EasyFindSearchFrame"] - if sf and sf.modeBtn and ns.UpdateModeButtonVisual then - ns.UpdateModeButtonVisual(sf.modeBtn) - end - optionsFrame.localNavCheckbox:SetChecked(false) - optionsFrame.globalNavCheckbox:SetChecked(false) - if ns.MapSearch and ns.MapSearch.UpdateMapModeBtns then ns.MapSearch:UpdateMapModeBtns() end optionsFrame.smartShowCheckbox:SetChecked(false) optionsFrame.staticOpacityCheckbox:SetChecked(false) optionsFrame.loginMessageCheckbox:SetChecked(true) @@ -1846,7 +1771,6 @@ function Options:DoResetAll() end function Options:DoResetUI() - EasyFind.db.directOpen = true EasyFind.db.smartShow = false EasyFind.db.staticOpacity = false EasyFind.db.uiResultsAbove = false @@ -1870,15 +1794,10 @@ function Options:DoResetUI() EasyFind.db.appearanceSetPvP = true EasyFind.db.uiMapSearchLocal = true - optionsFrame.directOpenCheckbox:SetChecked(true) optionsFrame.smartShowCheckbox:SetChecked(false) optionsFrame.staticOpacityCheckbox:SetChecked(false) optionsFrame.uiResultsAboveCheckbox:SetChecked(false) optionsFrame.uiFontSlider:SetValue(0.9) - local sf = _G["EasyFindSearchFrame"] - if sf and sf.modeBtn and ns.UpdateModeButtonVisual then - ns.UpdateModeButtonVisual(sf.modeBtn) - end ns.Highlight:ClearAll() if _G["EasyFindSearchFrame"] and ns.UI then @@ -1892,8 +1811,6 @@ function Options:DoResetUI() end function Options:DoResetMap() - EasyFind.db.localMapDirectOpen = false - EasyFind.db.globalMapDirectOpen = false EasyFind.db.mapSmartShow = false EasyFind.db.hideSearchBarsMaximized = true EasyFind.db.mapResultsAbove = false @@ -1911,11 +1828,11 @@ function Options:DoResetMap() EasyFind.db.mapResultsHeight = 168 EasyFind.db.mapPinHighlight = true EasyFind.db.blinkingPins = false - EasyFind.db.minimapArrowGlow = true + EasyFind.db.minimapArrowGlow = false EasyFind.db.glowOnlyEasyFind = false EasyFind.db.circleOnlyEasyFind = false - EasyFind.db.minimapGuideCircle = true - EasyFind.db.minimapPinGlow = true + EasyFind.db.minimapGuideCircle = false + EasyFind.db.minimapPinGlow = false EasyFind.db.guideCircleScale = 1.0 EasyFind.db.autoPinClear = true EasyFind.db.autoTrackPins = true @@ -1925,10 +1842,7 @@ function Options:DoResetMap() EasyFind.db.localSearchFilters = { instances = true, travel = true, services = true } EasyFind.db.alwaysShowRares = false - optionsFrame.localNavCheckbox:SetChecked(false) - optionsFrame.globalNavCheckbox:SetChecked(false) if optionsFrame.rareTrackCheckbox then optionsFrame.rareTrackCheckbox:SetChecked(false) end - if ns.MapSearch and ns.MapSearch.UpdateMapModeBtns then ns.MapSearch:UpdateMapModeBtns() end if optionsFrame.searchBarGroup then optionsFrame.searchBarGroup:UpdateVisuals() end if optionsFrame.mapPinGroup then optionsFrame.mapPinGroup:UpdateVisuals() end if optionsFrame.minimapGroup then optionsFrame.minimapGroup:UpdateVisuals() end @@ -2071,9 +1985,6 @@ function Options:Show() if optionsFrame.mapIconSlider then optionsFrame.mapIconSlider:SetValue(EasyFind.db.iconScale or 0.8) end if optionsFrame.mapYOffsetSlider then optionsFrame.mapYOffsetSlider:SetValue(EasyFind.db.mapSearchYOffset or 0) end if optionsFrame.arrivalSlider then optionsFrame.arrivalSlider:SetValue(EasyFind.db.arrivalDistance or 10) end - if optionsFrame.directOpenCheckbox then optionsFrame.directOpenCheckbox:SetChecked(EasyFind.db.directOpen or false) end - if optionsFrame.localNavCheckbox then optionsFrame.localNavCheckbox:SetChecked(EasyFind.db.localMapDirectOpen or false) end - if optionsFrame.globalNavCheckbox then optionsFrame.globalNavCheckbox:SetChecked(EasyFind.db.globalMapDirectOpen or false) end optionsFrame.smartShowCheckbox:SetChecked(EasyFind.db.smartShow or false) optionsFrame.staticOpacityCheckbox:SetChecked(EasyFind.db.staticOpacity or false) optionsFrame.loginMessageCheckbox:SetChecked(EasyFind.db.showLoginMessage ~= false) diff --git a/UI.lua b/UI.lua index fdca57b..1eade09 100644 --- a/UI.lua +++ b/UI.lua @@ -28,7 +28,7 @@ local InCombatLockdown = InCombatLockdown local HideUIPanel = HideUIPanel local wipe = wipe -local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind\\textures\\lightning-bolt" +local EYE_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\eye" local REP_BAR_WIDTH = 100 local searchFrame @@ -239,12 +239,39 @@ function UI:SyncOutfitPins() syncList(charPins) end --- Simple pin context popup (BOTTOMLEFT anchored at cursor so it opens above) +-- Right-click context menu with Pin/Unpin and optional Guide row. +-- Anchored BOTTOMLEFT at cursor so it opens above the pointer. +local EYE_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\eye" +local PIN_MENU_ROW_H = 22 +local PIN_MENU_WIDTH = 96 local pinPopup -local function ShowPinPopup(btn, isPinned, onAction) + +local function CreatePinMenuRow(parent) + local row = CreateFrame("Button", nil, parent) + row:SetHeight(PIN_MENU_ROW_H) + row:RegisterForClicks("LeftButtonUp") + local label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal") + label:SetPoint("LEFT", row, "LEFT", 8, 0) + row.label = label + local icon = row:CreateTexture(nil, "OVERLAY") + icon:SetSize(14, 14) + icon:SetPoint("RIGHT", row, "RIGHT", -8, 0) + icon:Hide() + row.icon = icon + row:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD") + return row +end + +-- Hide when the cursor leaves the popup's bounding box. A child row's OnLeave +-- fires when the cursor crosses to a sibling row too, so IsMouseOver on the +-- parent discriminates "left the menu entirely" from "moved between rows". +local function pinPopupRowOnLeave() + if pinPopup and not pinPopup:IsMouseOver() then pinPopup:Hide() end +end + +local function ShowPinPopup(btn, isPinned, onPinAction, onGuide) if not pinPopup then - pinPopup = CreateFrame("Button", "EasyFindPinPopup", UIParent, "BackdropTemplate") - pinPopup:SetSize(80, 28) + pinPopup = CreateFrame("Frame", "EasyFindPinPopup", UIParent, "BackdropTemplate") pinPopup:SetFrameStrata("TOOLTIP") pinPopup:SetFrameLevel(10000) pinPopup:SetBackdrop({ @@ -254,32 +281,48 @@ local function ShowPinPopup(btn, isPinned, onAction) insets = { left = 2, right = 2, top = 2, bottom = 2 } }) pinPopup:SetBackdropColor(DARK_PANEL_BG[1], DARK_PANEL_BG[2], DARK_PANEL_BG[3], DARK_PANEL_BG[4]) - local label = pinPopup:CreateFontString(nil, "OVERLAY", "GameFontNormal") - label:SetPoint("CENTER") - pinPopup.label = label - pinPopup:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD") - -- Delayed hide: brief grace period so the popup doesn't vanish when - -- the cursor drifts a pixel outside the button - pinPopup:SetScript("OnLeave", function(self) - if self._hideTimer then self._hideTimer:Cancel() end - self._hideTimer = C_Timer.NewTimer(0.25, function() - if not self:IsMouseOver() then self:Hide() end - end) - end) - pinPopup:SetScript("OnEnter", function(self) - if self._hideTimer then self._hideTimer:Cancel(); self._hideTimer = nil end - end) + pinPopup.guideRow = CreatePinMenuRow(pinPopup) + pinPopup.guideRow.label:SetText("Guide") + pinPopup.guideRow.icon:SetTexture(EYE_ICON_TEX) + pinPopup.guideRow.icon:Show() + pinPopup.pinRow = CreatePinMenuRow(pinPopup) + + pinPopup.pinRow:SetScript("OnLeave", pinPopupRowOnLeave) + pinPopup.guideRow:SetScript("OnLeave", pinPopupRowOnLeave) end - pinPopup.label:SetText(isPinned and "Unpin" or "Pin") - pinPopup:SetScript("OnClick", function(self) - self:Hide() - onAction() + + pinPopup.pinRow:Show() + pinPopup.pinRow.label:SetText(isPinned and "Unpin" or "Pin") + pinPopup.pinRow:SetScript("OnClick", function() + pinPopup:Hide() + if onPinAction then onPinAction() end end) + pinPopup.pinRow:ClearAllPoints() + + if onGuide then + pinPopup.guideRow:ClearAllPoints() + pinPopup.guideRow:SetPoint("TOPLEFT", pinPopup, "TOPLEFT", 4, -4) + pinPopup.guideRow:SetPoint("TOPRIGHT", pinPopup, "TOPRIGHT", -4, -4) + pinPopup.guideRow:Show() + pinPopup.guideRow:SetScript("OnClick", function() + pinPopup:Hide() + onGuide() + end) + pinPopup.pinRow:SetPoint("TOPLEFT", pinPopup.guideRow, "BOTTOMLEFT", 0, 0) + pinPopup.pinRow:SetPoint("TOPRIGHT", pinPopup.guideRow, "BOTTOMRIGHT", 0, 0) + pinPopup:SetSize(PIN_MENU_WIDTH, PIN_MENU_ROW_H * 2 + 8) + else + pinPopup.guideRow:Hide() + pinPopup.guideRow:SetScript("OnClick", nil) + pinPopup.pinRow:SetPoint("TOPLEFT", pinPopup, "TOPLEFT", 4, -4) + pinPopup.pinRow:SetPoint("TOPRIGHT", pinPopup, "TOPRIGHT", -4, -4) + pinPopup:SetSize(PIN_MENU_WIDTH, PIN_MENU_ROW_H + 8) + end + local scale = UIParent:GetEffectiveScale() local x, y = GetCursorPosition() pinPopup:ClearAllPoints() pinPopup:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", x / scale, y / scale) - if pinPopup._hideTimer then pinPopup._hideTimer:Cancel(); pinPopup._hideTimer = nil end pinPopup:Show() end @@ -579,77 +622,32 @@ function UI:CreateSearchFrame() ns.SetSearchBorderShown(searchFrame, false) end - -- Mode toggle button (search icon area, flush left) + -- Static magnifying-glass icon (non-interactive, flush left) local contentSz = ns.SEARCHBAR_HEIGHT * ns.SEARCHBAR_FILL local iconSz = contentSz * ns.SEARCHBAR_ICON_SCALE - local modeBtn = CreateFrame("Button", "EasyFindModeButton", searchFrame) - modeBtn:SetPoint("TOP", searchFrame, "TOP", 0, 0) - modeBtn:SetPoint("BOTTOM", searchFrame, "BOTTOM", 0, 0) - modeBtn:SetPoint("LEFT", searchFrame, "LEFT", 0, 0) - modeBtn:SetWidth(searchFrame:GetHeight()) - modeBtn:SetFrameLevel(searchFrame:GetFrameLevel() + 10) - - local modeIcon = modeBtn:CreateTexture(nil, "OVERLAY") - modeIcon:SetSize(iconSz, iconSz) - modeIcon:SetPoint("CENTER") - modeBtn.icon = modeIcon - - local modeBtnBg = modeBtn:CreateTexture(nil, "ARTWORK") - modeBtnBg:SetAllPoints() - modeBtnBg:SetTexture(796424) - modeBtnBg:Hide() - modeBtn.btnBg = modeBtnBg - - modeBtn:SetHighlightTexture(130757) - searchFrame.modeBtn = modeBtn - searchFrame.searchIcon = modeIcon - - local function UpdateModeButtonVisual(btn) - if EasyFind.db.directOpen then - btn.icon:SetAtlas(nil) - btn.icon:SetTexture(LIGHTNING_BOLT_TEX) - else - btn.icon:SetTexture(nil) - btn.icon:SetAtlas("common-search-magnifyingglass") - end - end - ns.UpdateModeButtonVisual = UpdateModeButtonVisual - - modeBtn:SetScript("OnEnter", function(self) - self.btnBg:Show() - GameTooltip:SetOwner(self, "ANCHOR_BOTTOM") - if EasyFind.db.directOpen then - GameTooltip:SetText("Fast Mode") - GameTooltip:AddLine("Click to switch to step-by-step Guide Mode.", 1, 1, 1, true) - else - GameTooltip:SetText("Guide Mode") - GameTooltip:AddLine("Click to enable Fast Mode (opens panels directly).", 1, 1, 1, true) - end - GameTooltip:Show() - end) - - modeBtn:SetScript("OnLeave", function(self) - if not self.keyboardFocused then self.btnBg:Hide() end - GameTooltip_Hide() - end) - - modeBtn:SetScript("OnClick", function(self) - EasyFind.db.directOpen = not EasyFind.db.directOpen - UpdateModeButtonVisual(self) - ns.Highlight:ClearAll() - local optPanel = _G["EasyFindOptionsFrame"] - if optPanel and optPanel.directOpenCheckbox then - optPanel.directOpenCheckbox:SetChecked(EasyFind.db.directOpen) - end - end) - - UpdateModeButtonVisual(modeBtn) + local iconHolder = CreateFrame("Frame", nil, searchFrame) + iconHolder:SetPoint("TOP", searchFrame, "TOP", 0, 0) + iconHolder:SetPoint("BOTTOM", searchFrame, "BOTTOM", 0, 0) + iconHolder:SetPoint("LEFT", searchFrame, "LEFT", 0, 0) + iconHolder:SetWidth(searchFrame:GetHeight()) + iconHolder:SetFrameLevel(searchFrame:GetFrameLevel() + 10) + + local searchIcon = iconHolder:CreateTexture(nil, "OVERLAY") + searchIcon:SetSize(iconSz, iconSz) + searchIcon:SetPoint("CENTER") + searchIcon:SetAtlas("common-search-magnifyingglass") + iconHolder.icon = searchIcon + searchFrame.searchIcon = searchIcon + -- Kept for Demo.lua / legacy references; no longer a clickable toggle. + searchFrame.modeBtn = iconHolder + -- No-op shim so legacy pcalls from Demo.lua succeed. + ns.UpdateModeButtonVisual = ns.UpdateModeButtonVisual or function() end -- Editbox local editBox = CreateFrame("EditBox", "EasyFindSearchBox", searchFrame) editBox:SetHeight(contentSz) - editBox:SetPoint("LEFT", modeBtn, "RIGHT", 0, 0) + editBox:SetPoint("LEFT", iconHolder, "RIGHT", 0, 0) editBox:SetPoint("RIGHT", searchFrame, "RIGHT", -8, 0) editBox:SetFontObject(ns.SEARCHBAR_FONT) editBox:SetAutoFocus(false) @@ -810,7 +808,7 @@ function UI:CreateSearchFrame() -- Anchor editBox right edge to left of clear button area (filter button zone) editBox:ClearAllPoints() - editBox:SetPoint("LEFT", modeBtn, "RIGHT", 0, 0) + editBox:SetPoint("LEFT", iconHolder, "RIGHT", 0, 0) editBox:SetPoint("RIGHT", filterBtn, "LEFT", -4, 0) -- Click anywhere on the search frame to focus the editbox (enables blinking cursor). @@ -822,7 +820,7 @@ function UI:CreateSearchFrame() searchFrame:HookScript("OnMouseDown", function(self, button) if button ~= "LeftButton" or IsShiftKeyDown() or self.setupMode then return end if (filterBtn and filterBtn:IsMouseOver()) - or (modeBtn and modeBtn:IsMouseOver()) + or (iconHolder and iconHolder:IsMouseOver()) or (clearTextBtn and clearTextBtn:IsShown() and clearTextBtn:IsMouseOver()) then return end @@ -912,7 +910,6 @@ function UI:CreateSearchFrame() local function GetToolbarControls() local controls = {} - tinsert(controls, modeBtn) if clearTextBtn:IsShown() then tinsert(controls, clearTextBtn) end @@ -997,17 +994,13 @@ function UI:CreateSearchFrame() elseif key == "END" then UI:JumpToEnd() elseif key == "TAB" then - -- Ring order: modeBtn(1) → editBox → [clearBtn] → filterBtn → wrap - -- EditBox sits between toolbar index 1 and 2 + -- Ring order: editBox → [clearBtn] → filterBtn → wrap back to editBox if IsShiftKeyDown() then if selectedIndex > 0 and toggleFocused then toggleFocused = false UI:UpdateSelectionHighlight() elseif toolbarFocus > 0 then if toolbarFocus == 1 then - local controls = GetToolbarControls() - SetToolbarFocus(#controls) - elseif toolbarFocus == 2 then ClearToolbarFocus() Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) searchFrame.editBox:SetFocus() @@ -1028,12 +1021,10 @@ function UI:CreateSearchFrame() end elseif toolbarFocus > 0 then local controls = GetToolbarControls() - if toolbarFocus == 1 then + if toolbarFocus >= #controls then ClearToolbarFocus() Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) searchFrame.editBox:SetFocus() - elseif toolbarFocus >= #controls then - SetToolbarFocus(1) else SetToolbarFocus(toolbarFocus + 1) end @@ -1118,19 +1109,17 @@ function UI:CreateSearchFrame() 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: - -- Shift+Tab (left) → modeBtn (index 1) - -- Tab (right) → first control after editBox (index 2) + -- Tab/Shift+Tab from editbox: navigate toolbar controls to the right of + -- the editbox (clearTextBtn, filterBtn). Shift+Tab wraps to the last. editBox:HookScript("OnKeyDown", function(self, key) if key ~= "TAB" then return end self:ClearFocus() Utils.SafeCallMethod(navFrame, "EnableKeyboard", true) + local controls = GetToolbarControls() if IsShiftKeyDown() then - SetToolbarFocus(1) + SetToolbarFocus(#controls) else - SetToolbarFocus(2) + SetToolbarFocus(1) end end) @@ -3530,10 +3519,15 @@ function UI:CreateResultButton(index) ClearCursor() end) end - -- Right-click: show pin/unpin popup + -- Right-click: show pin/unpin popup (plus Guide row if entry has a guide path) if mouseButton == "RightButton" and self.data then local pinData = self.data local isPinned = IsUIItemPinned(pinData) + local hasGuide = pinData.steps or pinData.transmogSetID + or (pinData.category == "Loot" and pinData.itemID) + local onGuide = hasGuide and function() + UI:SelectResult(pinData, true) + end or nil ShowPinPopup(self, isPinned, function() if isPinned then UnpinUIItem(pinData) @@ -3547,7 +3541,7 @@ function UI:CreateResultButton(index) else UI:OnSearchTextChanged(text) end - end) + end, onGuide) return end @@ -5525,8 +5519,9 @@ function UI:ApplyTransmogBrowseMode() end end -function UI:SelectResult(data) +function UI:SelectResult(data, forceGuide) if not data then return end + local useFast = not forceGuide selectingResult = true searchFrame.editBox:SetText("") @@ -5576,7 +5571,7 @@ function UI:SelectResult(data) }, } - if EasyFind.db.directOpen then + if useFast then self:DirectOpen(guideData) else EasyFind:StartGuide(guideData) @@ -5611,7 +5606,7 @@ function UI:SelectResult(data) guideData.steps[#guideData.steps + 1] = { waitForFrame = "WardrobeCollectionFrame", transmogVariantDropdown = true } guideData.steps[#guideData.steps + 1] = { waitForFrame = "WardrobeCollectionFrame", transmogVariantSetID = setID } end - if EasyFind.db.directOpen then + if useFast then self:DirectOpen(guideData) else EasyFind:StartGuide(guideData) @@ -5651,7 +5646,7 @@ function UI:SelectResult(data) self:FlashLabel(data.flashLabel) end - if EasyFind.db.directOpen and data.steps then + if useFast and data.steps then -- Portrait menu can't be automated (secure frame restriction) local mustGuide = false for _, step in ipairs(data.steps) do @@ -6867,70 +6862,12 @@ function UI:ShowFirstTimeSetup() gotItBtn:SetPoint("BOTTOM", panel, "BOTTOM", 0, 12) gotItBtn:SetText("Got it") - -- Floating "Mode toggle" pointer group, independent of the panel. - -- Sits to the LEFT of the search bar so the arrow can point RIGHT at - -- the mode button. Uses the Blizzard atlas arrow (sheet 1121272), - -- rotated -90deg so the natively-up arrow points right. - -- - -- The frame itself stays fixed next to the mode button; the TEXTURE - -- inside the frame slides via a sin wave so only the arrow bounces, - -- leaving the label and See Demo button rock-steady. - -- Tutorial label + animated chevron pointing at the mode button. - -- AttachPointer handles rotation, origin (trail-back flush with the - -- label's RIGHT border), and per-cycle translation toward the target. - local modePointerLabel = ns.TutorialBox.Create(searchFrame, "GameFontNormalLarge") - modePointerLabel.fs:SetText("Mode toggle") - modePointerLabel:SetAutoSized(200) - modePointerLabel:ClearAllPoints() - modePointerLabel:SetPoint("RIGHT", searchFrame.modeBtn, "LEFT", -72, 0) - local seeDemoBtn = CreateFrame("Button", nil, searchFrame, "UIPanelButtonTemplate") seeDemoBtn:SetSize(120, 24) - seeDemoBtn:SetPoint("TOP", modePointerLabel, "BOTTOM", 0, -8) + seeDemoBtn:SetPoint("TOP", searchFrame, "BOTTOM", 0, -12) seeDemoBtn:SetText("See demo") seeDemoBtn:SetIgnoreParentAlpha(true) - -- Pulsing gold flash over the mode button. Uses a radial star glow - -- texture (already referenced elsewhere in the addon) so the flash - -- has soft edges instead of the hard square corners you'd get from - -- tinting WHITE8x8. Sized larger than the button so the glow halo - -- extends past the button's bounds. - local modeFlashFrame = CreateFrame("Frame", nil, searchFrame.modeBtn) - modeFlashFrame:SetPoint("CENTER", searchFrame.modeBtn, "CENTER", 0, 0) - local flashSize = searchFrame.modeBtn:GetHeight() * 1.8 - modeFlashFrame:SetSize(flashSize, flashSize) - modeFlashFrame:SetFrameLevel(searchFrame.modeBtn:GetFrameLevel() + 5) - modeFlashFrame:SetIgnoreParentAlpha(true) - local modeFlash = modeFlashFrame:CreateTexture(nil, "OVERLAY") - modeFlash:SetAllPoints() - modeFlash:SetTexture("Interface\\Cooldown\\star4") - modeFlash:SetVertexColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1) - modeFlash:SetBlendMode("ADD") - - local FLASH_MIN, FLASH_MAX = 0.15, 0.55 - modeFlashFrame:SetAlpha(FLASH_MIN) - - -- Chevron pointer; its onPhase callback syncs the mode-button flash - -- so the brightest moment of the flash matches a fresh arrow being - -- born at the start position. - local POINTER_COUNT = 2 - local modePointer = ns.TutorialBox.AttachPointer(modePointerLabel, { - direction = "right", - travel = 48, - duration = 1.25, - count = POINTER_COUNT, - fadeStart = 0.75, - startOffset = -10, - easing = 0.5, - glow = 0.7, - -- Flash pulses once per fresh-arrow birth, so it cycles - -- `count` times per animation period. - onPhase = function(phase) - local flashPhase = (phase * POINTER_COUNT) % 1 - modeFlashFrame:SetAlpha(FLASH_MIN + (FLASH_MAX - FLASH_MIN) * (1 - flashPhase)) - end, - }) - -- During setup: allow drag without holding Shift searchFrame:SetScript("OnDragStart", function(self) self:StartMoving() @@ -6955,10 +6892,7 @@ function UI:ShowFirstTimeSetup() resizer:SetScript("OnUpdate", nil) resizer:Hide() panel:Hide() - modePointer:Stop() - modePointerLabel:Hide() seeDemoBtn:Hide() - modeFlashFrame:Hide() if demoFrame then demoFrame:Hide() end -- Clear the demo suspend flag so the dropdown auto-close works if searchFrame.filterDropdown then @@ -7017,10 +6951,7 @@ function UI:ShowFirstTimeSetup() -- Hide the positioning UI before the demo runs. panel:Hide() resizer:Hide() - modePointer:Stop() - modePointerLabel:Hide() seeDemoBtn:Hide() - modeFlashFrame:Hide() startDemo() end) end diff --git a/textures/eye.tga b/textures/eye.tga new file mode 100644 index 0000000000000000000000000000000000000000..978f515d9cc0813e7a3e24de9c005d3181039ef0 GIT binary patch literal 16402 zcmeI0y-FlO6ouQ3G*t{l16>ge=Me-$1I-Nv)MPUg5qt*0r!a9548$ie5*HRDK`<1f zO?ORp_B&7)+NN>3W@IO*bKy|cUH7lQsqUHSs`^reZB!f8@_HP3GyxMZ0TVC*6EFc2 zFaZ-V0TVC*6EFc2FaZ-V0Tak0Fc=JaXdj)SYxIC#(QouONpqdkntbG|m|kAB$$QiD zEZRfg(JwTvDTi_?=j`OKu)PC%+d>zpX-}LuN5^OfX+JEW88nroxz1@#KJrzJVw|!SvS?}kDGtgcxrE>`&vWK%umqLs5ytlDR-)& zT4Cy8dsysety60eX>W!-GC9b}kN*);-)~8pA7$n^AV#&r)YD>4j$B{z@1l67{b8Ql z^C~g*J;UKU)7olIU-*lqdXcHO-JJNYzT`bsfn=AAD_vv5kC>0HJ#ycNf7emHAoUb> z+BJ5V9|@dPM3~rrzgcEoUTd4&5QyPqda3-}EJ3&$_Pm){M_NrpOvn&oW)ZTu!`v z6_a8a%Deu=pxFH%`-RWG*ymp`NPD5k@4p6vVt@Yx2kwVrfBy!9bUzgN|EJC%apk(+o=fGjWeHtBU_9JTuLK`%Jc;pH;#C^Hl2G zi2pjSk4bTVBvNag-G41x&!^4o|9KjTV|WOCnTjl@o2mQ4N?ct^{S#eXzz3iIGz!J<7w z2LJYSRE`Kl_dlASeLI2Z?%RC!bX1NAME5_MpZ$9TBKyCWjOq~q+>Z!p???5oO+8M! zi2mcy(Virt`!{SB<}@7=Su;q&y~F1-7lr?0*1?ZUfXZM)<2-ab70jnmgY;pxJ=S8cO% z>AjtJ_M0P6`;?~-&tA2y&ZYPC;n{00J*PaKc=f{SJOM8!o_*%i*FNd##H&|rqu0`V zIq~XqEj_2bym<7%<~0HyPP}?tOU~&=z{`zCABzCg zVM|U`Bk*ZA9`|F?M!>_(r+e6vQ?-J?)8E314D_@Sz`c!|O4XD8RhU%Df&WUj`>eR{ zc~o0H<-Zy?g}k^|uxQVKe>*#c$|w9QaHyC8_Z6-0neY1FZ{jT@fjj;)=4Ixcz#aFk zcE5?Yj0EoZ&zP5)cLKQISyX(p`me}JF*E)ZtKUkfsy~{^E02F0PgMV6e`dBE94`?IRX5i^HJW# zy|UeLw%`1ZD>z{} RfB*y_009U<00I#R{0C2$ Date: Sun, 19 Apr 2026 20:06:40 -0400 Subject: [PATCH 017/103] Add MapTab scaffold: 3rd side tab with search panel shell Creates EasyFindMapSearchTab as a 42x55 side tab below MapLegendTab matching the Blizzard quest log tab style (QuestLog-tab-side bg, Select and hover glows, magnifier icon). Clicking the tab swaps the QuestMapFrame side panel to an EasyFindMapSearchPanel containing a search box styled like QuestScrollFrame.SearchBox plus a settings cogwheel using QuestLog-icon-setting. Tab-switching state machine hides Blizzard's QuestsFrame / MapLegendFrame when our panel is active and vice versa, keeping Select glows in sync. Search engine wiring, results list with local/global sections, hover preview, Pin/Guide right-click menu, and cog filter dropdown are placeholders for the next commits. --- EasyFind.toc | 1 + MapTab.lua | 284 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 MapTab.lua diff --git a/EasyFind.toc b/EasyFind.toc index f248f1b..defb3e5 100644 --- a/EasyFind.toc +++ b/EasyFind.toc @@ -18,6 +18,7 @@ ChevronConfig.lua UI.lua Highlight.lua MapSearch.lua +MapTab.lua Rescaler.lua Options.lua Demo.lua diff --git a/MapTab.lua b/MapTab.lua new file mode 100644 index 0000000..1c1cc25 --- /dev/null +++ b/MapTab.lua @@ -0,0 +1,284 @@ +local _, ns = ... + +local MapTab = {} +ns.MapTab = MapTab + +local Utils = ns.Utils +local SafeAfter = Utils and Utils.SafeAfter or function(delay, fn) C_Timer.After(delay, fn) end + +local CreateFrame = CreateFrame +local GameTooltip = GameTooltip +local GameTooltip_Hide = GameTooltip_Hide +local select = select + +-- QuestMapFrame side tabs: 42x55 Frames at strata HIGH/3 stacked on the +-- right edge of the map. We clone the existing visual structure and add +-- a 3rd tab below QuestMapFrame.MapLegendTab. +local TAB_W, TAB_H = 42, 55 +local TAB_BG_W, TAB_BG_H = 51, 59 +local TAB_ICON_SIZE = 28 + +local initialized = false +local tabFrame +local panel +local selectedIsOurs = false + +-- Find a texture by its atlas name on a frame. Blizzard's tab frames +-- don't expose their Select / Hover glows as named members, so we +-- scan the region list for the one with the right atlas. +local function FindAtlasTexture(frame, atlas) + if not frame or not frame.GetRegions then return nil end + for i = 1, frame:GetNumRegions() do + local region = select(i, frame:GetRegions()) + if region and region.GetAtlas and region.GetObjectType and region:GetObjectType() == "Texture" then + if region:GetAtlas() == atlas then return region end + end + end + return nil +end + +local function HideBlizzPanels(qmf) + if qmf.QuestsFrame then qmf.QuestsFrame:Hide() end + if qmf.DetailsFrame then qmf.DetailsFrame:Hide() end + if qmf.MapLegendFrame then qmf.MapLegendFrame:Hide() end +end + +local function RefreshSelectGlows() + local qmf = _G["QuestMapFrame"] + if not qmf then return end + + local function setGlow(tab, shown) + if not tab then return end + local glow = tab._efSelectGlow or FindAtlasTexture(tab, "QuestLog-Tab-side-Glow-Select") + if glow then + tab._efSelectGlow = glow + glow:SetShown(shown) + end + end + + -- When our panel is up, only our tab is "selected". + if selectedIsOurs then + setGlow(qmf.QuestsTab, false) + setGlow(qmf.MapLegendTab, false) + setGlow(tabFrame, true) + else + setGlow(tabFrame, false) + -- Let Blizzard's own logic drive the Quests / Legend glow. + end +end + +local function ShowOurPanel() + local qmf = _G["QuestMapFrame"] + if not qmf or not panel then return end + selectedIsOurs = true + HideBlizzPanels(qmf) + panel:Show() + RefreshSelectGlows() +end + +local function HideOurPanel() + if not selectedIsOurs then return end + selectedIsOurs = false + if panel then panel:Hide() end + RefreshSelectGlows() +end + +-- Styled EditBox matching Blizzard's QuestScrollFrame.SearchBox +-- (common-search-border-* atlases + magnifier overlay). +local function CreateSearchBox(parent) + local editBox = CreateFrame("EditBox", nil, parent) + editBox:SetSize(301, 20) + editBox:SetFontObject("GameFontHighlightSmall") + editBox:SetAutoFocus(false) + editBox:SetMaxLetters(60) + editBox:SetTextInsets(22, 8, 2, 2) + + local left = editBox:CreateTexture(nil, "BACKGROUND") + left:SetAtlas("common-search-border-left", false) + left:SetSize(8, 20) + left:SetPoint("LEFT") + + local right = editBox:CreateTexture(nil, "BACKGROUND") + right:SetAtlas("common-search-border-right", false) + right:SetSize(7, 20) + right:SetPoint("RIGHT") + + local mid = editBox:CreateTexture(nil, "BACKGROUND") + mid:SetAtlas("common-search-border-middle", false) + mid:SetPoint("LEFT", left, "RIGHT") + mid:SetPoint("RIGHT", right, "LEFT") + mid:SetHeight(20) + + local icon = editBox:CreateTexture(nil, "OVERLAY") + icon:SetAtlas("common-search-magnifyingglass", false) + icon:SetSize(10, 10) + icon:SetPoint("LEFT", editBox, "LEFT", 8, 0) + icon:SetVertexColor(0.6, 0.6, 0.6, 1) + + local placeholder = editBox:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + placeholder:SetPoint("LEFT", icon, "RIGHT", 4, 0) + placeholder:SetPoint("RIGHT", editBox, "RIGHT", -4, 0) + placeholder:SetJustifyH("LEFT") + placeholder:SetText("Search for POIs, zones, instances...") + editBox.placeholder = placeholder + + editBox:HookScript("OnTextChanged", function(self) + placeholder:SetShown(self:GetText() == "") + end) + editBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + + return editBox +end + +-- Settings cog matching QuestScrollFrame.SettingsDropdown +-- (QuestLog-icon-setting atlas). Dropdown population is a TODO. +local function CreateFilterCog(parent) + local cog = CreateFrame("Button", nil, parent) + cog:SetSize(14, 16) + local iconTex = cog:CreateTexture(nil, "ARTWORK") + iconTex:SetAtlas("QuestLog-icon-setting", false) + iconTex:SetAllPoints() + local hoverTex = cog:CreateTexture(nil, "HIGHLIGHT") + hoverTex:SetAtlas("QuestLog-icon-setting", false) + hoverTex:SetAllPoints() + hoverTex:SetAlpha(0.4) + cog:SetScript("OnEnter", function(self) + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText("Filter options") + GameTooltip:AddLine("(not yet wired)", 0.7, 0.7, 0.7, true) + GameTooltip:Show() + end) + cog:SetScript("OnLeave", GameTooltip_Hide) + return cog +end + +local function CreateTabFrame(qmf) + local tab = CreateFrame("Frame", "EasyFindMapSearchTab", qmf) + tab:SetSize(TAB_W, TAB_H) + tab:SetFrameStrata("HIGH") + tab:SetFrameLevel(qmf.MapLegendTab:GetFrameLevel()) + -- Stack below MapLegendTab, same left offset relative to QuestMapFrame. + tab:SetPoint("TOPLEFT", qmf.MapLegendTab, "BOTTOMLEFT", 0, 2) + tab:EnableMouse(true) + + -- Side-piece background (same atlas as Quests / MapLegend tabs) + local bg = tab:CreateTexture(nil, "BACKGROUND") + bg:SetAtlas("QuestLog-tab-side", false) + bg:SetSize(TAB_BG_W, TAB_BG_H) + bg:SetPoint("TOPLEFT", tab, "TOPLEFT", 0, 0) + + -- Icon: magnifying glass. Offset slightly left to match how the + -- quest / legend icons sit inside the leather tab frame. + local icon = tab:CreateTexture(nil, "ARTWORK") + icon:SetAtlas("common-search-magnifyingglass", false) + icon:SetSize(TAB_ICON_SIZE, TAB_ICON_SIZE) + icon:SetPoint("CENTER", tab, "CENTER", -4, 0) + + -- Active-state glow (hidden until our tab is selected) + local selectGlow = tab:CreateTexture(nil, "OVERLAY") + selectGlow:SetAtlas("QuestLog-Tab-side-Glow-Select", false) + selectGlow:SetSize(TAB_BG_W, TAB_BG_H) + selectGlow:SetPoint("TOPLEFT", bg, "TOPLEFT", 0, 0) + selectGlow:Hide() + tab._efSelectGlow = selectGlow + + -- Hover glow (managed by WoW's HIGHLIGHT layer automatically) + local hoverGlow = tab:CreateTexture(nil, "HIGHLIGHT") + hoverGlow:SetAtlas("QuestLog-Tab-side-Glow-hover", false) + hoverGlow:SetSize(TAB_BG_W, TAB_BG_H) + hoverGlow:SetPoint("TOPLEFT", bg, "TOPLEFT", 0, 0) + + tab:SetScript("OnMouseUp", function(_, button) + if button == "LeftButton" then ShowOurPanel() end + end) + tab:SetScript("OnEnter", function(self) + GameTooltip:SetOwner(self, "ANCHOR_LEFT") + GameTooltip:SetText("EasyFind Map Search") + GameTooltip:AddLine("Search POIs, flight masters, zones, dungeons, raids, and more.", 1, 1, 1, true) + GameTooltip:Show() + end) + tab:SetScript("OnLeave", GameTooltip_Hide) + + return tab +end + +local function CreatePanel(qmf) + local host = qmf.QuestsFrame or qmf + local p = CreateFrame("Frame", "EasyFindMapSearchPanel", qmf) + p:SetFrameStrata(qmf:GetFrameStrata()) + p:SetFrameLevel(qmf:GetFrameLevel() + 5) + -- Match the region the Blizzard quest list occupies so our search + -- panel slots cleanly into the same rect. + p:SetAllPoints(host) + p:EnableMouse(true) + p:Hide() + + local searchBox = CreateSearchBox(p) + searchBox:SetPoint("TOPLEFT", p, "TOPLEFT", 6, -8) + p.searchBox = searchBox + + local cog = CreateFilterCog(p) + cog:SetPoint("LEFT", searchBox, "RIGHT", 6, 0) + p.cog = cog + + -- Results container (scrolling list + local/global sections TBD) + local results = CreateFrame("Frame", nil, p) + results:SetPoint("TOPLEFT", searchBox, "BOTTOMLEFT", -4, -12) + results:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", -6, 8) + p.results = results + + local placeholderFS = results:CreateFontString(nil, "OVERLAY", "GameFontHighlight") + placeholderFS:SetPoint("TOP", results, "TOP", 0, -32) + placeholderFS:SetJustifyH("CENTER") + placeholderFS:SetWidth(260) + placeholderFS:SetText("|cff999999Local results above, global below, separator between. Search engine wiring pending.|r") + + return p +end + +function MapTab:Initialize() + if initialized then return end + local qmf = _G["QuestMapFrame"] + if not qmf or not qmf.MapLegendTab then return end + initialized = true + + tabFrame = CreateTabFrame(qmf) + panel = CreatePanel(qmf) + + -- When Blizzard switches to Quests or MapLegend, drop our panel. + if qmf.QuestsTab then + qmf.QuestsTab:HookScript("OnMouseUp", function(_, button) + if button == "LeftButton" then HideOurPanel() end + end) + end + if qmf.MapLegendTab then + qmf.MapLegendTab:HookScript("OnMouseUp", function(_, button) + if button == "LeftButton" then HideOurPanel() end + end) + end + if qmf.QuestsFrame then + qmf.QuestsFrame:HookScript("OnShow", HideOurPanel) + end + if qmf.MapLegendFrame then + qmf.MapLegendFrame:HookScript("OnShow", HideOurPanel) + end +end + +-- Blizzard_WorldMap is on-demand loaded. Hook the moment it loads, plus +-- defend against race conditions where it's already loaded at our init. +local eventFrame = CreateFrame("Frame") +eventFrame:RegisterEvent("ADDON_LOADED") +eventFrame:RegisterEvent("PLAYER_LOGIN") +eventFrame:SetScript("OnEvent", function(_, event, addonName) + if event == "ADDON_LOADED" and addonName == "Blizzard_WorldMap" then + SafeAfter(0, function() MapTab:Initialize() end) + elseif event == "PLAYER_LOGIN" then + local isLoaded + if C_AddOns and C_AddOns.IsAddOnLoaded then + isLoaded = C_AddOns.IsAddOnLoaded("Blizzard_WorldMap") + end + if isLoaded then + SafeAfter(0, function() MapTab:Initialize() end) + end + end +end) From 948668fcd73554b05328fd2c0d5eb31f967af96a Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 19 Apr 2026 20:14:34 -0400 Subject: [PATCH 018/103] Wire MapTab search engine: result rows, pin menu, hover preview Extracts the result-computation body from MapSearch:OnSearchTextChanged into a pure MapSearch:BuildResults(text, isGlobal, skipPins) that returns the ranked/filtered list without rendering. Exposes pin helpers (IsMapItemPinned, PinMapItem, UnpinMapItem, GetMapPinKey) on the MapSearch table. MapTab now runs BuildResults twice per query (once local, once global, both with skipPins) and renders two sections with gold category headers ("In This Zone" / "Across Azeroth"). Pinned items show above both sections when present, with a collapsible "Pinned" header. Row interactions wired: - Hover shows waypoint preview via MapSearch:GetPreviewCoords. - Left-click dispatches to MapSearch:SelectResult. - Right-click opens a Pin/Guide popup matching UI.lua's pattern; Guide row appears only for global results (zones / entrances). - Pin-header click toggles mapPinsCollapsed and refreshes. Search is debounced 80ms per keystroke. Scratch tables inside BuildResults are shallow-copied on the MapTab side because they're reused across the two back-to-back searches. --- MapSearch.lua | 34 +++- MapTab.lua | 540 ++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 532 insertions(+), 42 deletions(-) diff --git a/MapSearch.lua b/MapSearch.lua index 0303506..49a516f 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -1086,6 +1086,13 @@ local function UnpinMapItem(data) end end +-- Expose pin helpers on the module so external renderers (MapTab) can +-- toggle pins without duplicating the canonicalization logic. +MapSearch.GetMapPinKey = function(_, data) return GetMapPinKey(data) end +MapSearch.IsMapItemPinned = function(_, data) return IsMapItemPinned(data) end +MapSearch.PinMapItem = function(_, data) return PinMapItem(data) end +MapSearch.UnpinMapItem = function(_, data) return UnpinMapItem(data) end + -- Simple pin context popup (BOTTOMLEFT anchored at cursor so it opens above) local pinPopup local function ShowPinPopup(btn, isPinned, onAction) @@ -5817,9 +5824,22 @@ function MapSearch:OnSearchTextChanged(text) self:ClearZoneHighlight() self:ClearHighlight() + local results = self:BuildResults(text, isGlobalSearch) + self:ShowResults(results) +end + +-- Pure result computation: takes a query and a global-mode flag, returns the +-- ranked + filtered + pin-merged result list. No rendering. Safe to call +-- from MapTab or any other renderer that wants both local and global sets. +-- Temporarily sets the module-level `isGlobalSearch` so transitively-called +-- helpers (SearchZones, GetGlobalInstanceCache, ...) see the requested mode. +function MapSearch:BuildResults(text, isGlobal, skipPins) + local savedGlobalFlag = isGlobalSearch + isGlobalSearch = isGlobal and true or false + -- Search for zones (works for both local and global mode) local zoneMatches = {} - if self:IsOnContinentMap() or isGlobalSearch then + if self:IsOnContinentMap() or isGlobal then zoneMatches = self:SearchZones(text) end @@ -5856,7 +5876,7 @@ function MapSearch:OnSearchTextChanged(text) -- Dungeon-type zone results from SearchZones are suppressed when the -- cache covers them. Cache entries are promoted to zone-style display -- (with full breadcrumb paths) using a mapID-to-path lookup. - if isGlobalSearch then + if isGlobal then local instancePOIs = self:GetGlobalInstanceCache() -- Build mapID → full path string from the world zones cache @@ -6014,7 +6034,7 @@ function MapSearch:OnSearchTextChanged(text) local results = self:SearchPOIs(allPOIs, text) -- Apply global search filters (zones / dungeons / raids / delves) - if isGlobalSearch then + if isGlobal then local filters = EasyFind.db.globalSearchFilters wipe(reuseFilteredResults) local filteredResults = reuseFilteredResults @@ -6059,8 +6079,9 @@ function MapSearch:OnSearchTextChanged(text) results = filteredResults end - -- Prepend pinned items with header (always shown at top regardless of query) - local pins = EasyFind.db.pinnedMapItems + -- Prepend pinned items with header (always shown at top regardless of query). + -- Callers that render their own pinned section (e.g. MapTab) pass skipPins. + local pins = not skipPins and EasyFind.db.pinnedMapItems or nil if pins and #pins > 0 then wipe(reusePinnedKeys) wipe(reusePinned) @@ -6094,7 +6115,8 @@ function MapSearch:OnSearchTextChanged(text) results = pinned end - self:ShowResults(results) + isGlobalSearch = savedGlobalFlag + return results end function MapSearch:SearchPOIs(pois, query) diff --git a/MapTab.lua b/MapTab.lua index 1c1cc25..af0eada 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -5,27 +5,51 @@ ns.MapTab = MapTab local Utils = ns.Utils local SafeAfter = Utils and Utils.SafeAfter or function(delay, fn) C_Timer.After(delay, fn) end +local sfind = Utils and Utils.sfind or string.find +local slower = Utils and Utils.slower or string.lower +local tinsert = Utils and Utils.tinsert or table.insert +local wipe = wipe + +local GOLD_COLOR = ns.GOLD_COLOR or {1.0, 0.82, 0.0} +local TOOLTIP_BORDER = ns.TOOLTIP_BORDER +local DARK_PANEL_BG = ns.DARK_PANEL_BG local CreateFrame = CreateFrame local GameTooltip = GameTooltip local GameTooltip_Hide = GameTooltip_Hide +local C_Timer = C_Timer +local GetCursorPosition = GetCursorPosition +local UIParent = UIParent local select = select --- QuestMapFrame side tabs: 42x55 Frames at strata HIGH/3 stacked on the --- right edge of the map. We clone the existing visual structure and add --- a 3rd tab below QuestMapFrame.MapLegendTab. +-- Side-tab geometry and shared atlas textures from the Blizzard map. local TAB_W, TAB_H = 42, 55 local TAB_BG_W, TAB_BG_H = 51, 59 local TAB_ICON_SIZE = 28 +-- Popup menu geometry (matches UI.lua's pin/guide popup) +local EYE_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\eye" +local PIN_MENU_ROW_H = 22 +local PIN_MENU_WIDTH = 96 + +-- Result row layout +local ROW_HEIGHT = 24 +local ROW_ICON_SIZE = 18 +local SECTION_HEADER_H = 22 +local MAX_ROW_POOL = 60 + local initialized = false local tabFrame local panel local selectedIsOurs = false +local rowPool = {} +local lastQueryGen = 0 -- bumped per search to invalidate in-flight rebuilds +local pendingSearchTimer + +-- =================================================================== +-- Helpers +-- =================================================================== --- Find a texture by its atlas name on a frame. Blizzard's tab frames --- don't expose their Select / Hover glows as named members, so we --- scan the region list for the one with the right atlas. local function FindAtlasTexture(frame, atlas) if not frame or not frame.GetRegions then return nil end for i = 1, frame:GetNumRegions() do @@ -46,7 +70,6 @@ end local function RefreshSelectGlows() local qmf = _G["QuestMapFrame"] if not qmf then return end - local function setGlow(tab, shown) if not tab then return end local glow = tab._efSelectGlow or FindAtlasTexture(tab, "QuestLog-Tab-side-Glow-Select") @@ -55,18 +78,441 @@ local function RefreshSelectGlows() glow:SetShown(shown) end end - - -- When our panel is up, only our tab is "selected". if selectedIsOurs then setGlow(qmf.QuestsTab, false) setGlow(qmf.MapLegendTab, false) setGlow(tabFrame, true) else setGlow(tabFrame, false) - -- Let Blizzard's own logic drive the Quests / Legend glow. end end +local function ShallowCopyList(list) + if not list then return {} end + local copy = {} + for i = 1, #list do copy[i] = list[i] end + return copy +end + +-- =================================================================== +-- Pin/Guide context menu (mirrors UI.lua's popup pattern) +-- =================================================================== + +local pinPopup + +local function CreateMenuRow(parent) + local row = CreateFrame("Button", nil, parent) + row:SetHeight(PIN_MENU_ROW_H) + row:RegisterForClicks("LeftButtonUp") + local label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal") + label:SetPoint("LEFT", row, "LEFT", 8, 0) + row.label = label + local icon = row:CreateTexture(nil, "OVERLAY") + icon:SetSize(14, 14) + icon:SetPoint("RIGHT", row, "RIGHT", -8, 0) + icon:Hide() + row.icon = icon + row:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD") + return row +end + +local function MenuRowOnLeave() + if pinPopup and not pinPopup:IsMouseOver() then pinPopup:Hide() end +end + +local function ShowPopup(isPinned, onPin, onGuide) + if not pinPopup then + pinPopup = CreateFrame("Frame", "EasyFindMapTabPopup", UIParent, "BackdropTemplate") + pinPopup:SetFrameStrata("TOOLTIP") + pinPopup:SetFrameLevel(10000) + pinPopup:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", + edgeFile = TOOLTIP_BORDER, + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 2, right = 2, top = 2, bottom = 2 } + }) + if DARK_PANEL_BG then + pinPopup:SetBackdropColor(DARK_PANEL_BG[1], DARK_PANEL_BG[2], DARK_PANEL_BG[3], DARK_PANEL_BG[4]) + end + pinPopup.guideRow = CreateMenuRow(pinPopup) + pinPopup.guideRow.label:SetText("Guide") + pinPopup.guideRow.icon:SetTexture(EYE_ICON_TEX) + pinPopup.guideRow.icon:Show() + pinPopup.pinRow = CreateMenuRow(pinPopup) + pinPopup.pinRow:SetScript("OnLeave", MenuRowOnLeave) + pinPopup.guideRow:SetScript("OnLeave", MenuRowOnLeave) + end + + pinPopup.pinRow:Show() + pinPopup.pinRow.label:SetText(isPinned and "Unpin" or "Pin") + pinPopup.pinRow:SetScript("OnClick", function() + pinPopup:Hide() + if onPin then onPin() end + end) + pinPopup.pinRow:ClearAllPoints() + + if onGuide then + pinPopup.guideRow:ClearAllPoints() + pinPopup.guideRow:SetPoint("TOPLEFT", pinPopup, "TOPLEFT", 4, -4) + pinPopup.guideRow:SetPoint("TOPRIGHT", pinPopup, "TOPRIGHT", -4, -4) + pinPopup.guideRow:Show() + pinPopup.guideRow:SetScript("OnClick", function() + pinPopup:Hide() + onGuide() + end) + pinPopup.pinRow:SetPoint("TOPLEFT", pinPopup.guideRow, "BOTTOMLEFT", 0, 0) + pinPopup.pinRow:SetPoint("TOPRIGHT", pinPopup.guideRow, "BOTTOMRIGHT", 0, 0) + pinPopup:SetSize(PIN_MENU_WIDTH, PIN_MENU_ROW_H * 2 + 8) + else + pinPopup.guideRow:Hide() + pinPopup.guideRow:SetScript("OnClick", nil) + pinPopup.pinRow:SetPoint("TOPLEFT", pinPopup, "TOPLEFT", 4, -4) + pinPopup.pinRow:SetPoint("TOPRIGHT", pinPopup, "TOPRIGHT", -4, -4) + pinPopup:SetSize(PIN_MENU_WIDTH, PIN_MENU_ROW_H + 8) + end + + local scale = UIParent:GetEffectiveScale() + local x, y = GetCursorPosition() + pinPopup:ClearAllPoints() + pinPopup:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", x / scale, y / scale) + pinPopup:Show() +end + +-- =================================================================== +-- Result rows +-- =================================================================== + +local function CreateResultRow(parent) + local row = CreateFrame("Button", nil, parent) + row:SetHeight(ROW_HEIGHT) + row:RegisterForClicks("LeftButtonUp", "RightButtonUp") + row:EnableMouse(true) + + local icon = row:CreateTexture(nil, "ARTWORK") + icon:SetSize(ROW_ICON_SIZE, ROW_ICON_SIZE) + icon:SetPoint("LEFT", row, "LEFT", 4, 0) + row.icon = icon + + local text = row:CreateFontString(nil, "OVERLAY", "GameFontNormal") + text:SetPoint("LEFT", icon, "RIGHT", 6, 0) + text:SetPoint("RIGHT", row, "RIGHT", -6, 0) + text:SetJustifyH("LEFT") + text:SetWordWrap(false) + row.text = text + + local path = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + path:SetJustifyH("LEFT") + path:SetTextColor(0.55, 0.55, 0.55) + path:Hide() + row.path = path + + row:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD") + local hl = row:GetHighlightTexture() + hl:SetVertexColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.25) + + return row +end + +local function SetRowIcon(row, data) + local icon = data.icon + row.icon:SetTexCoord(0, 1, 0, 1) + if type(icon) == "string" and icon:sub(1, 6) == "atlas:" then + row.icon:SetTexture(nil) + row.icon:SetAtlas(icon:sub(7), false) + elseif type(icon) == "number" then + row.icon:SetAtlas(nil) + row.icon:SetTexture(icon) + elseif type(icon) == "string" then + row.icon:SetAtlas(nil) + row.icon:SetTexture(icon) + elseif type(icon) == "table" and icon.file then + row.icon:SetAtlas(nil) + row.icon:SetTexture(icon.file) + if icon.coords then + row.icon:SetTexCoord(icon.coords[1], icon.coords[2], icon.coords[3], icon.coords[4]) + end + else + row.icon:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark") + end +end + +local function HoverPreview(data) + local MapSearch = ns.MapSearch + if not MapSearch or not MapSearch.GetPreviewCoords then return end + local coords = MapSearch:GetPreviewCoords(data) + if not coords then return end + MapSearch._savedPinState = MapSearch._savedPinState + MapSearch._previewing = true + if coords.instances then + MapSearch:ShowMultipleWaypoints(coords.instances) + elseif coords.pin and coords.pin:IsShown() then + MapSearch:HighlightPin(coords.pin, coords.x, coords.y, coords.icon, coords.category) + else + MapSearch:ShowWaypointAt(coords.x, coords.y, coords.icon, coords.category) + end +end + +local function ClearHoverPreview() + local MapSearch = ns.MapSearch + if not MapSearch then return end + if MapSearch._previewing then + MapSearch._previewing = nil + if MapSearch.ClearHighlight then MapSearch:ClearHighlight() end + end +end + +-- Refresh the current search without stealing focus. Called after pin toggle. +local function RefreshCurrentSearch() + if not panel or not panel.searchBox then return end + MapTab:RunSearch(panel.searchBox:GetText() or "") +end + +local function RowOnClick(row, button) + local data = row.data + if not data then return end + local MapSearch = ns.MapSearch + + if data.isPinHeader then + EasyFind.db.mapPinsCollapsed = not EasyFind.db.mapPinsCollapsed + RefreshCurrentSearch() + return + end + + if button == "RightButton" then + local isPinned = MapSearch and MapSearch:IsMapItemPinned(data) + local isGlobal = data.isZone or data.isDungeonEntrance + local onGuide = isGlobal and function() + if MapSearch and MapSearch.SelectResult then + MapSearch:SelectResult(data) + end + end or nil + ShowPopup(isPinned, function() + if isPinned then + MapSearch:UnpinMapItem(data) + else + MapSearch:PinMapItem(data) + end + RefreshCurrentSearch() + end, onGuide) + return + end + + if MapSearch and MapSearch.SelectResult then + MapSearch:SelectResult(data) + end +end + +local function RowOnEnter(row) + if row.data then HoverPreview(row.data) end +end + +local function RowOnLeave() + ClearHoverPreview() +end + +local function AcquireRow(parent) + for i = 1, #rowPool do + local row = rowPool[i] + if not row:IsShown() then + row:SetParent(parent) + return row + end + end + if #rowPool >= MAX_ROW_POOL then return nil end + local row = CreateResultRow(parent) + row:SetScript("OnClick", RowOnClick) + row:SetScript("OnEnter", RowOnEnter) + row:SetScript("OnLeave", RowOnLeave) + tinsert(rowPool, row) + return row +end + +local function ReleaseAllRows() + for i = 1, #rowPool do + rowPool[i]:Hide() + rowPool[i].data = nil + end +end + +-- =================================================================== +-- Section header (between Pinned / Local / Global) +-- =================================================================== + +local function CreateSectionHeader(parent, labelText) + local hdr = CreateFrame("Frame", nil, parent) + hdr:SetHeight(SECTION_HEADER_H) + local fs = hdr:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + fs:SetPoint("LEFT", hdr, "LEFT", 6, 0) + fs:SetText(labelText or "") + fs:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) + hdr.label = fs + local line = hdr:CreateTexture(nil, "ARTWORK") + line:SetHeight(1) + line:SetPoint("LEFT", fs, "RIGHT", 6, 0) + line:SetPoint("RIGHT", hdr, "RIGHT", -6, 0) + line:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.4) + return hdr +end + +-- Section header pool (pinned / local / global) +local headerPool = {} +local function AcquireHeader(parent, labelText) + for i = 1, #headerPool do + local h = headerPool[i] + if not h:IsShown() then + h:SetParent(parent) + h.label:SetText(labelText or "") + return h + end + end + local h = CreateSectionHeader(parent, labelText) + tinsert(headerPool, h) + return h +end + +local function ReleaseAllHeaders() + for i = 1, #headerPool do headerPool[i]:Hide() end +end + +-- =================================================================== +-- Render pipeline +-- =================================================================== + +local function BuildPinnedSection() + local pins = EasyFind.db.pinnedMapItems + if not pins or #pins == 0 then return nil end + local list = { { isPinHeader = true, name = "Pinned" } } + if not EasyFind.db.mapPinsCollapsed then + for _, pin in ipairs(pins) do + local copy = {} + for k, v in pairs(pin) do copy[k] = v end + copy.isPinned = true + list[#list + 1] = copy + end + end + return list +end + +local function RenderRows(scrollChild, pinned, localResults, globalResults) + ReleaseAllRows() + ReleaseAllHeaders() + + local y = 4 + + local function placeRow(data) + local row = AcquireRow(scrollChild) + if not row then return end + row:ClearAllPoints() + row:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 4, -y) + row:SetPoint("TOPRIGHT", scrollChild, "TOPRIGHT", -4, -y) + row.data = data + SetRowIcon(row, data) + local name = data.name or "" + if data.isPinHeader then + row.text:SetTextColor(0.7, 0.7, 0.7) + row.text:SetText((EasyFind.db.mapPinsCollapsed and "> " or "v ") .. name) + row.icon:Hide() + else + row.icon:Show() + if data.isZone then + row.text:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) + else + row.text:SetTextColor(1, 1, 1) + end + if data.pathPrefix and data.pathPrefix ~= "" then + row.text:SetText(name .. " |cff808080" .. data.pathPrefix .. "|r") + else + row.text:SetText(name) + end + end + row:Show() + y = y + ROW_HEIGHT + end + + local function placeHeader(labelText) + local hdr = AcquireHeader(scrollChild, labelText) + hdr:ClearAllPoints() + hdr:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 4, -y) + hdr:SetPoint("TOPRIGHT", scrollChild, "TOPRIGHT", -4, -y) + hdr:Show() + y = y + SECTION_HEADER_H + end + + if pinned and #pinned > 0 then + for _, data in ipairs(pinned) do placeRow(data) end + end + + if localResults and #localResults > 0 then + placeHeader("In This Zone") + for _, data in ipairs(localResults) do placeRow(data) end + end + + if globalResults and #globalResults > 0 then + placeHeader("Across Azeroth") + for _, data in ipairs(globalResults) do placeRow(data) end + end + + scrollChild:SetHeight(math.max(1, y + 4)) +end + +-- =================================================================== +-- Search runner +-- =================================================================== + +function MapTab:RunSearch(text) + if not panel then return end + lastQueryGen = lastQueryGen + 1 + local myGen = lastQueryGen + + local scrollChild = panel.scrollChild + local MapSearch = ns.MapSearch + local pinned = BuildPinnedSection() + + if not text or #text < 2 then + -- No query: show pinned section only (if any) + if pinned then + RenderRows(scrollChild, pinned, nil, nil) + panel.emptyMsg:Hide() + else + ReleaseAllRows() + ReleaseAllHeaders() + scrollChild:SetHeight(1) + panel.emptyMsg:SetText("|cff999999Start typing to search for POIs, zones, dungeons, and raids.|r") + panel.emptyMsg:Show() + end + return + end + + panel.emptyMsg:Hide() + + if not MapSearch or not MapSearch.BuildResults then + ReleaseAllRows() + ReleaseAllHeaders() + return + end + + -- Run both local and global searches. Shallow-copy each list because + -- BuildResults uses module-level scratch tables that are wiped on the + -- next call. + local localResults = ShallowCopyList(MapSearch:BuildResults(text, false, true)) + if myGen ~= lastQueryGen then return end + local globalResults = ShallowCopyList(MapSearch:BuildResults(text, true, true)) + if myGen ~= lastQueryGen then return end + + RenderRows(scrollChild, pinned, localResults, globalResults) + + if (not pinned or #pinned == 0) + and (not localResults or #localResults == 0) + and (not globalResults or #globalResults == 0) then + panel.emptyMsg:SetText("|cff999999No matches.|r") + panel.emptyMsg:Show() + end +end + +-- =================================================================== +-- Panel state +-- =================================================================== + local function ShowOurPanel() local qmf = _G["QuestMapFrame"] if not qmf or not panel then return end @@ -74,17 +520,25 @@ local function ShowOurPanel() HideBlizzPanels(qmf) panel:Show() RefreshSelectGlows() + if panel.searchBox then panel.searchBox:SetFocus() end + MapTab:RunSearch(panel.searchBox and panel.searchBox:GetText() or "") end local function HideOurPanel() if not selectedIsOurs then return end selectedIsOurs = false - if panel then panel:Hide() end + if panel then + panel:Hide() + if panel.searchBox then panel.searchBox:ClearFocus() end + end + ClearHoverPreview() RefreshSelectGlows() end --- Styled EditBox matching Blizzard's QuestScrollFrame.SearchBox --- (common-search-border-* atlases + magnifier overlay). +-- =================================================================== +-- Search box / filter cog +-- =================================================================== + local function CreateSearchBox(parent) local editBox = CreateFrame("EditBox", nil, parent) editBox:SetSize(301, 20) @@ -124,14 +578,18 @@ local function CreateSearchBox(parent) editBox:HookScript("OnTextChanged", function(self) placeholder:SetShown(self:GetText() == "") + if pendingSearchTimer then pendingSearchTimer:Cancel() end + -- Debounce so we don't rebuild per keystroke + local snapshot = self:GetText() or "" + pendingSearchTimer = C_Timer.NewTimer(0.08, function() + MapTab:RunSearch(snapshot) + end) end) editBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) return editBox end --- Settings cog matching QuestScrollFrame.SettingsDropdown --- (QuestLog-icon-setting atlas). Dropdown population is a TODO. local function CreateFilterCog(parent) local cog = CreateFrame("Button", nil, parent) cog:SetSize(14, 16) @@ -145,36 +603,35 @@ local function CreateFilterCog(parent) cog:SetScript("OnEnter", function(self) GameTooltip:SetOwner(self, "ANCHOR_RIGHT") GameTooltip:SetText("Filter options") - GameTooltip:AddLine("(not yet wired)", 0.7, 0.7, 0.7, true) + GameTooltip:AddLine("Category filters coming soon.", 0.7, 0.7, 0.7, true) GameTooltip:Show() end) cog:SetScript("OnLeave", GameTooltip_Hide) return cog end +-- =================================================================== +-- Tab creation +-- =================================================================== + local function CreateTabFrame(qmf) local tab = CreateFrame("Frame", "EasyFindMapSearchTab", qmf) tab:SetSize(TAB_W, TAB_H) tab:SetFrameStrata("HIGH") tab:SetFrameLevel(qmf.MapLegendTab:GetFrameLevel()) - -- Stack below MapLegendTab, same left offset relative to QuestMapFrame. tab:SetPoint("TOPLEFT", qmf.MapLegendTab, "BOTTOMLEFT", 0, 2) tab:EnableMouse(true) - -- Side-piece background (same atlas as Quests / MapLegend tabs) local bg = tab:CreateTexture(nil, "BACKGROUND") bg:SetAtlas("QuestLog-tab-side", false) bg:SetSize(TAB_BG_W, TAB_BG_H) bg:SetPoint("TOPLEFT", tab, "TOPLEFT", 0, 0) - -- Icon: magnifying glass. Offset slightly left to match how the - -- quest / legend icons sit inside the leather tab frame. local icon = tab:CreateTexture(nil, "ARTWORK") icon:SetAtlas("common-search-magnifyingglass", false) icon:SetSize(TAB_ICON_SIZE, TAB_ICON_SIZE) icon:SetPoint("CENTER", tab, "CENTER", -4, 0) - -- Active-state glow (hidden until our tab is selected) local selectGlow = tab:CreateTexture(nil, "OVERLAY") selectGlow:SetAtlas("QuestLog-Tab-side-Glow-Select", false) selectGlow:SetSize(TAB_BG_W, TAB_BG_H) @@ -182,7 +639,6 @@ local function CreateTabFrame(qmf) selectGlow:Hide() tab._efSelectGlow = selectGlow - -- Hover glow (managed by WoW's HIGHLIGHT layer automatically) local hoverGlow = tab:CreateTexture(nil, "HIGHLIGHT") hoverGlow:SetAtlas("QuestLog-Tab-side-Glow-hover", false) hoverGlow:SetSize(TAB_BG_W, TAB_BG_H) @@ -202,13 +658,15 @@ local function CreateTabFrame(qmf) return tab end +-- =================================================================== +-- Panel creation +-- =================================================================== + local function CreatePanel(qmf) local host = qmf.QuestsFrame or qmf local p = CreateFrame("Frame", "EasyFindMapSearchPanel", qmf) p:SetFrameStrata(qmf:GetFrameStrata()) p:SetFrameLevel(qmf:GetFrameLevel() + 5) - -- Match the region the Blizzard quest list occupies so our search - -- panel slots cleanly into the same rect. p:SetAllPoints(host) p:EnableMouse(true) p:Hide() @@ -221,21 +679,32 @@ local function CreatePanel(qmf) cog:SetPoint("LEFT", searchBox, "RIGHT", 6, 0) p.cog = cog - -- Results container (scrolling list + local/global sections TBD) - local results = CreateFrame("Frame", nil, p) - results:SetPoint("TOPLEFT", searchBox, "BOTTOMLEFT", -4, -12) - results:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", -6, 8) - p.results = results - - local placeholderFS = results:CreateFontString(nil, "OVERLAY", "GameFontHighlight") - placeholderFS:SetPoint("TOP", results, "TOP", 0, -32) - placeholderFS:SetJustifyH("CENTER") - placeholderFS:SetWidth(260) - placeholderFS:SetText("|cff999999Local results above, global below, separator between. Search engine wiring pending.|r") + local scrollFrame = CreateFrame("ScrollFrame", nil, p, "UIPanelScrollFrameTemplate") + scrollFrame:SetPoint("TOPLEFT", searchBox, "BOTTOMLEFT", 0, -12) + scrollFrame:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", -24, 8) + p.scrollFrame = scrollFrame + + local scrollChild = CreateFrame("Frame", nil, scrollFrame) + scrollChild:SetSize(1, 1) + scrollFrame:SetScrollChild(scrollChild) + scrollFrame:HookScript("OnSizeChanged", function(_, w) scrollChild:SetWidth(w) end) + scrollChild:SetWidth(scrollFrame:GetWidth()) + p.scrollChild = scrollChild + + local emptyMsg = p:CreateFontString(nil, "OVERLAY", "GameFontHighlight") + emptyMsg:SetPoint("TOP", scrollFrame, "TOP", 0, -24) + emptyMsg:SetWidth(260) + emptyMsg:SetJustifyH("CENTER") + emptyMsg:SetText("|cff999999Start typing to search for POIs, zones, dungeons, and raids.|r") + p.emptyMsg = emptyMsg return p end +-- =================================================================== +-- Initialize +-- =================================================================== + function MapTab:Initialize() if initialized then return end local qmf = _G["QuestMapFrame"] @@ -245,7 +714,6 @@ function MapTab:Initialize() tabFrame = CreateTabFrame(qmf) panel = CreatePanel(qmf) - -- When Blizzard switches to Quests or MapLegend, drop our panel. if qmf.QuestsTab then qmf.QuestsTab:HookScript("OnMouseUp", function(_, button) if button == "LeftButton" then HideOurPanel() end From 85e3ba125e123005abd846727b5beb0c8f0ce2c9 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 19 Apr 2026 20:21:56 -0400 Subject: [PATCH 019/103] Retire floating map search bars; MapTab is the new entry point MapSearch:Initialize no longer creates searchFrame, globalSearchFrame, or resultsFrame. HookWorldMap keeps waypoint restoration and breadcrumb positioning on map open/close/maximize but drops all bar-positioning, bar-focus, and bar-clear logic. Guarded the remaining direct editbox references in SelectResult and OnSearchTextChanged, ShowResults, and ShowPinnedItems so they early- return or skip when the floating bars are nil. ResetPosition becomes a pure SavedVariables wipe. Removed the three map demos (zone / current / UI-bar) from DEMO_LIST since their recorded flows click the retired bars. The demo bodies are left in Demo.lua for now but unreachable via the picker. Removed the "Resize Map Search" Options button (dead; Rescaler's map-mode targeted the retired bars). --- Demo.lua | 3 - MapSearch.lua | 154 +++++++++++--------------------------------------- Options.lua | 18 +----- 3 files changed, 35 insertions(+), 140 deletions(-) diff --git a/Demo.lua b/Demo.lua index 8b1d1aa..1188ecd 100644 --- a/Demo.lua +++ b/Demo.lua @@ -551,9 +551,6 @@ function Demo.Start(ctx) local DEMO_LIST = { { name = "UI Search", key = "uiSearch" }, { name = "Guide Mode", key = "guide" }, - { name = "Zone/instance map search", key = "mapSearchZone" }, - { name = "Current zone map search", key = "mapSearchCurrent" }, - { name = "Map search through UI bar",key = "mapSearchUI" }, { name = "Outfits", key = "outfits" }, { name = "Appearance sets", key = "appearanceSets" }, { name = "Loot", key = "loot" }, diff --git a/MapSearch.lua b/MapSearch.lua index 49a516f..4c42f03 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -1283,35 +1283,14 @@ local GLOBAL_SEARCH_CATEGORIES = { local STATIC_LOCATIONS = ns.STATIC_LOCATIONS or {} function MapSearch:Initialize() - self:CreateSearchFrame() - self:CreateResultsFrame() + -- Floating search bars (searchFrame / globalSearchFrame) and the floating + -- results dropdown (resultsFrame) are deprecated; MapTab is the new + -- front-end. We only create waypoint / highlight / world-map-hook + -- machinery, which MapTab, HandleUISearchClick, and rare tracking all + -- still depend on. self:CreateHighlightFrame() self:CreateZoneHighlightFrame() self:HookWorldMap() - self:UpdateScale() - self:UpdateWidth() - self:UpdateOpacity() - self:UpdateFontSize() - - -- Block focus during init window - if searchFrame and searchFrame.editBox then - searchFrame.editBox.blockFocus = true - searchFrame.editBox:ClearFocus() - end - if globalSearchFrame and globalSearchFrame.editBox then - globalSearchFrame.editBox.blockFocus = true - globalSearchFrame.editBox:ClearFocus() - end - C_Timer.After(1, function() - if searchFrame and searchFrame.editBox then - searchFrame.editBox.blockFocus = nil - searchFrame.editBox:ClearFocus() - end - if globalSearchFrame and globalSearchFrame.editBox then - globalSearchFrame.editBox.blockFocus = nil - globalSearchFrame.editBox:ClearFocus() - end - end) end -- SHARED FILTER DROPDOWN BUILDER - creates a tracking-menu-style checkbox panel @@ -4593,18 +4572,7 @@ function MapSearch:UpdateHideMaximized() end function MapSearch:HookWorldMap() - HookMapSmartShow(searchFrame) - HookMapSmartShow(globalSearchFrame) - WorldMapFrame:HookScript("OnShow", function() - searchFrame:Show() - globalSearchFrame:Show() - if EasyFind.db.mapSmartShow then - searchFrame:SetAlpha(0) - globalSearchFrame:SetAlpha(0) - searchFrame.smartShowVisible = false - globalSearchFrame.smartShowVisible = false - end -- Restore pins only if the player is in the pin's zone. -- Map opens to the player's current zone by default, so if they -- left the zone the pin was in, it's gone. @@ -4633,63 +4601,24 @@ function MapSearch:HookWorldMap() end) WorldMapFrame:HookScript("OnHide", function() - searchFrame:Hide() - globalSearchFrame:Hide() - if searchFrame.filterDropdown then - searchFrame.filterDropdown:Hide() - end - if globalSearchFrame.filterDropdown then - globalSearchFrame.filterDropdown:Hide() - end - self:HideResults() - -- Hide ALL high-strata visuals that paint through the closed map. + -- Hide high-strata visuals that would paint through the closed map. -- activePinState is preserved so they restore on reopen. self:ClearHighlight() self:ClearZoneHighlight() self.pendingWaypoint = nil end) - -- Reposition search bars when map toggles between maximized and windowed + -- Track maximized state so breadcrumb repositioning and waypoint + -- placement logic can branch on it, even though we no longer have + -- floating bars to move. if WorldMapFrame.IsMaximized then - local function RepositionForMapMode() - local maximized = WorldMapFrame:IsMaximized() - mapIsMaximized = maximized - if maximized then - -- Move both bars to top of the map, independently positioned - local maxXOff = EasyFind.db.mapSearchPositionMax or 4 - searchFrame:ClearAllPoints() - searchFrame:SetPoint("TOPLEFT", WorldMapFrame.ScrollContainer, "TOPLEFT", maxXOff, -4) - if globalSearchFrame then - globalSearchFrame:ClearAllPoints() - local gMaxXOff = EasyFind.db.globalSearchPositionMax or -150 - globalSearchFrame:SetPoint("TOPRIGHT", WorldMapFrame.ScrollContainer, "TOPRIGHT", gMaxXOff, -4) - end - if EasyFind.db.hideSearchBarsMaximized then - searchFrame:Hide() - if globalSearchFrame then globalSearchFrame:Hide() end - end - else - -- Restore normal bottom-edge positions - local yOff = EasyFind.db.mapSearchYOffset or 0 - searchFrame:ClearAllPoints() - local xOff = EasyFind.db.mapSearchPosition or 0 - searchFrame:SetPoint("TOPLEFT", WorldMapFrame.ScrollContainer, "BOTTOMLEFT", xOff, yOff) - if globalSearchFrame then - globalSearchFrame:ClearAllPoints() - local gXOff = EasyFind.db.globalSearchPosition or 0 - globalSearchFrame:SetPoint("TOPRIGHT", WorldMapFrame.ScrollContainer, "BOTTOMRIGHT", gXOff, yOff) - end - -- Restore bars that were hidden in maximized mode - searchFrame:Show() - if globalSearchFrame then globalSearchFrame:Show() end - end - self:RefreshResultsAnchor() + local function OnMapModeChange() + mapIsMaximized = WorldMapFrame:IsMaximized() self:UpdateBreadcrumbPosition() end - hooksecurefunc(WorldMapFrame, "Maximize", RepositionForMapMode) - hooksecurefunc(WorldMapFrame, "Minimize", RepositionForMapMode) - -- Apply on first show if already maximized - WorldMapFrame:HookScript("OnShow", RepositionForMapMode) + hooksecurefunc(WorldMapFrame, "Maximize", OnMapModeChange) + hooksecurefunc(WorldMapFrame, "Minimize", OnMapModeChange) + WorldMapFrame:HookScript("OnShow", OnMapModeChange) end hooksecurefunc(WorldMapFrame, "OnMapChanged", function() @@ -4702,23 +4631,14 @@ function MapSearch:HookWorldMap() -- pendingWaypoint is NOT wiped by ClearZoneHighlight so no snapshot needed. local savedPendingZone = self.pendingZoneHighlight - self:HideResults() self:ClearHighlight() - self:ClearZoneHighlight() -- Explicit clear - SetText below may not reliably fire OnTextChanged inside hooksecurefunc + self:ClearZoneHighlight() -- Clear breadcrumb highlight if self.breadcrumbHighlight then self.breadcrumbHighlight:Hide() end - -- Clear both search bars on map change - searchFrame.editBox:SetText("") - searchFrame.editBox.placeholder:Show() - if globalSearchFrame and globalSearchFrame.editBox then - globalSearchFrame.editBox:SetText("") - globalSearchFrame.editBox.placeholder:Show() - end - -- If we have both a pending zone AND a pending waypoint, check if the -- entrance is actually visible on the current map via the Encounter Journal -- API. This handles sub-zones (e.g. "City of Threads - Lower" inside @@ -5799,6 +5719,7 @@ function MapSearch:GetPinInfo(pin) end function MapSearch:OnSearchTextChanged(text) + if not resultsFrame then return end -- floating bars deprecated if self._suppressTextChanged then self._suppressTextChanged = nil return @@ -6242,6 +6163,7 @@ function MapSearch:SearchPOIs(pois, query) end function MapSearch:ShowResults(results) + if not resultsFrame then return end -- floating bars deprecated selectedResultIndex = 0 navBtnFocused = false self._lastResults = results @@ -6498,6 +6420,7 @@ function MapSearch:HideResults() end function MapSearch:ShowPinnedItems() + if not resultsFrame then return end -- floating bars deprecated local pins = EasyFind.db.pinnedMapItems if not pins or #pins == 0 then self:HideResults() @@ -6746,14 +6669,17 @@ function MapSearch:SelectResult(data) self._previewing = nil self._savedPinState = nil self._suppressTextChanged = true - searchFrame.editBox:SetText("") - searchFrame.editBox:ClearFocus() - searchFrame.editBox.placeholder:Show() - if globalSearchFrame then + -- Floating bars may be gone (deprecated) - guard the editbox clears. + if searchFrame and searchFrame.editBox then + searchFrame.editBox:SetText("") + searchFrame.editBox:ClearFocus() + if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end + end + if globalSearchFrame and globalSearchFrame.editBox then self._suppressTextChanged = true globalSearchFrame.editBox:SetText("") globalSearchFrame.editBox:ClearFocus() - globalSearchFrame.editBox.placeholder:Show() + if globalSearchFrame.editBox.placeholder then globalSearchFrame.editBox.placeholder:Show() end end self:HideResults() @@ -6881,13 +6807,14 @@ function MapSearch:SelectResult(data) -- Clear search text after pins are placed. Flag persists until -- OnSearchTextChanged consumes it (OnTextChanged may fire next frame). + -- Floating bars may be retired - guard editbox access. self._suppressTextChanged = true - if isGlobalSearch and globalSearchFrame then + if isGlobalSearch and globalSearchFrame and globalSearchFrame.editBox then globalSearchFrame.editBox:SetText("") - globalSearchFrame.editBox.placeholder:Show() - else + if globalSearchFrame.editBox.placeholder then globalSearchFrame.editBox.placeholder:Show() end + elseif searchFrame and searchFrame.editBox then searchFrame.editBox:SetText("") - searchFrame.editBox.placeholder:Show() + if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end end end @@ -7773,28 +7700,11 @@ function MapSearch:UpdateSearchBarTheme() end function MapSearch:ResetPosition() + -- Floating bars are retired so there's nothing to reposition. EasyFind.db.mapSearchPosition = nil EasyFind.db.globalSearchPosition = nil EasyFind.db.mapSearchPositionMax = nil EasyFind.db.globalSearchPositionMax = nil - if mapIsMaximized then - searchFrame:ClearAllPoints() - searchFrame:SetPoint("TOPLEFT", WorldMapFrame.ScrollContainer, "TOPLEFT", 4, -4) - if globalSearchFrame then - globalSearchFrame:ClearAllPoints() - globalSearchFrame:SetPoint("TOPRIGHT", WorldMapFrame.ScrollContainer, "TOPRIGHT", -150, -4) - end - return - end - local yOff = EasyFind.db.mapSearchYOffset or 0 - if searchFrame then - searchFrame:ClearAllPoints() - searchFrame:SetPoint("TOPLEFT", WorldMapFrame.ScrollContainer, "BOTTOMLEFT", 0, yOff) - end - if globalSearchFrame then - globalSearchFrame:ClearAllPoints() - globalSearchFrame:SetPoint("TOPRIGHT", WorldMapFrame.ScrollContainer, "BOTTOMRIGHT", 0, yOff) - end end function MapSearch:UpdateYOffset() diff --git a/Options.lua b/Options.lua index b9e05c5..cb4d696 100644 --- a/Options.lua +++ b/Options.lua @@ -1122,20 +1122,8 @@ function Options:Initialize() end) optionsFrame.rareTrackCheckbox = rareTrackCheckbox - local resizeMapBtn = CreateFrame("Button", nil, sec2, "UIPanelButtonTemplate") - resizeMapBtn:SetSize(160, 22) - resizeMapBtn:SetPoint("RIGHT", sec2, "RIGHT", -8, 0) - resizeMapBtn:SetPoint("TOP", mapEnableCheckbox, "TOP", 0, 0) - resizeMapBtn:SetText("Resize Map Search") - resizeMapBtn:SetScript("OnClick", function() - if ns.Rescaler then ns.Rescaler:Enter("map") end - end) - resizeMapBtn:SetScript("OnEnter", function(self) - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - GameTooltip:SetText("Visually resize the map search bars and results dropdown.\nDrag edges for width, corners for scale.") - GameTooltip:Show() - end) - resizeMapBtn:SetScript("OnLeave", GameTooltip_Hide) + -- Resize Map Search button removed: floating map search bars are retired + -- and the new MapTab uses the Blizzard quest-log-tab sizing. local FLYOUT_W = 260 @@ -1279,7 +1267,7 @@ function Options:Initialize() end) mapControls = { - resizeMapBtn, resetMapBtn, resetMapPosBtn, rareTrackCheckbox, + resetMapBtn, resetMapPosBtn, rareTrackCheckbox, searchBarGroup, mapPinGroup, minimapGroup, automationGroup } UpdateMapToggleVisual() From 83fae9b484b10799df739bd0b79caff28f7d640b Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 19 Apr 2026 20:24:18 -0400 Subject: [PATCH 020/103] MapTab: fullscreen-hide tab, keybind focus entry point Hides the EasyFindMapSearchTab when the world map is maximized, matching how Blizzard's QuestsTab and MapLegendTab behave. Restores the tab when the map returns to windowed mode. Adds MapTab:Focus() which opens the world map, selects our tab, and focuses the search box. The ToggleFocusSearchUI keybind dispatcher now routes to MapTab:Focus when the map is open, replacing the old floating-bar FocusLocalSearch call. --- Core.lua | 4 ++-- MapTab.lua | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Core.lua b/Core.lua index 5cedf4a..669191d 100644 --- a/Core.lua +++ b/Core.lua @@ -529,8 +529,8 @@ function EasyFind:FocusSearchUI() end function EasyFind:ToggleFocusSearchUI() - if WorldMapFrame and WorldMapFrame:IsShown() and ns.MapSearch then - ns.MapSearch:FocusLocalSearch() + if WorldMapFrame and WorldMapFrame:IsShown() and ns.MapTab then + ns.MapTab:Focus() elseif ns.UI then ns.UI:ToggleFocus() end diff --git a/MapTab.lua b/MapTab.lua index af0eada..21fe508 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -705,6 +705,19 @@ end -- Initialize -- =================================================================== +-- Focus the MapTab search box, opening the map + selecting our tab if +-- not already there. Intended to be called from the "focus map search" +-- keybind dispatcher in Core. +function MapTab:Focus() + if not initialized then self:Initialize() end + if not panel then return end + if WorldMapFrame and not WorldMapFrame:IsShown() then + ToggleWorldMap() + end + ShowOurPanel() + if panel.searchBox then panel.searchBox:SetFocus() end +end + function MapTab:Initialize() if initialized then return end local qmf = _G["QuestMapFrame"] @@ -730,6 +743,23 @@ function MapTab:Initialize() if qmf.MapLegendFrame then qmf.MapLegendFrame:HookScript("OnShow", HideOurPanel) end + + -- Hide our tab in the maximized map view, matching Blizzard's tabs. + if WorldMapFrame and WorldMapFrame.IsMaximized then + local function UpdateTabVisibility() + if not tabFrame then return end + if WorldMapFrame:IsMaximized() then + HideOurPanel() + tabFrame:Hide() + else + tabFrame:Show() + end + end + hooksecurefunc(WorldMapFrame, "Maximize", UpdateTabVisibility) + hooksecurefunc(WorldMapFrame, "Minimize", UpdateTabVisibility) + WorldMapFrame:HookScript("OnShow", UpdateTabVisibility) + UpdateTabVisibility() + end end -- Blizzard_WorldMap is on-demand loaded. Hook the moment it loads, plus From 2b2b0f018cbd5542f90017b7d5042cf788e8b4ee Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 19 Apr 2026 20:38:31 -0400 Subject: [PATCH 021/103] Fix MapTab visuals + tab-switching glitches Tab frame: - Stack flush under MapLegendTab (removed the +2 vertical fudge). - Center the QuestLog-tab-side background, Select glow, and hover glow on the clickable area. They're 51x59 atop the 42x55 tab, so CENTER anchoring places the leather overhang symmetrically, matching Blizzard's own QuestsTab / MapLegendTab visual extent. - Center the magnifier icon on the tab (removed the -4 X offset). Panel: - Paint QuestLog-main-background atlas as the backdrop so the panel looks identical to the Blizzard quest-log scroll area. - Anchor to QuestScrollFrame's rect when available (exact same hitbox as Blizzard's quest list) with fallbacks to QuestsFrame / qmf. Tab-switching: - Stopped hiding Blizzard's QuestsFrame / MapLegendFrame when our panel opens. Clicking between tabs was leaving those panels Hidden, which made the quest log blank when the user clicked back to Quests. The MapTab panel is frame-level +5 with an opaque backdrop so it cleanly overlays Blizzard's content without disturbing its state. --- MapTab.lua | 43 +++++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/MapTab.lua b/MapTab.lua index 21fe508..66e1d2b 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -61,11 +61,13 @@ local function FindAtlasTexture(frame, atlas) return nil end -local function HideBlizzPanels(qmf) - if qmf.QuestsFrame then qmf.QuestsFrame:Hide() end - if qmf.DetailsFrame then qmf.DetailsFrame:Hide() end - if qmf.MapLegendFrame then qmf.MapLegendFrame:Hide() end -end +-- Note: we deliberately do NOT hide QuestsFrame / MapLegendFrame when our +-- panel opens. Blizzard's tab logic assumes those frames stay alive and +-- simply swap visibility internally; hiding them from here leaves the +-- quest-log scroll area blank when the user clicks back to Quests or +-- MapLegend. Our panel is parented to QuestMapFrame with a higher frame +-- level + an opaque QuestLog-main-background atlas, so it cleanly +-- overlays the other content without disturbing it. local function RefreshSelectGlows() local qmf = _G["QuestMapFrame"] @@ -517,7 +519,6 @@ local function ShowOurPanel() local qmf = _G["QuestMapFrame"] if not qmf or not panel then return end selectedIsOurs = true - HideBlizzPanels(qmf) panel:Show() RefreshSelectGlows() if panel.searchBox then panel.searchBox:SetFocus() end @@ -619,30 +620,36 @@ local function CreateTabFrame(qmf) tab:SetSize(TAB_W, TAB_H) tab:SetFrameStrata("HIGH") tab:SetFrameLevel(qmf.MapLegendTab:GetFrameLevel()) - tab:SetPoint("TOPLEFT", qmf.MapLegendTab, "BOTTOMLEFT", 0, 2) + -- Stack flush under MapLegendTab. Inherits its X offset from the map + -- frame's right edge, so horizontally it lines up identically. + tab:SetPoint("TOPLEFT", qmf.MapLegendTab, "BOTTOMLEFT", 0, 0) tab:EnableMouse(true) + -- Leather side piece. The atlas (51x59) is wider/taller than the + -- clickable area (42x55); centering places the overhang symmetrically + -- around the tab so it matches the visual extent of Blizzard's own + -- QuestsTab and MapLegendTab. local bg = tab:CreateTexture(nil, "BACKGROUND") bg:SetAtlas("QuestLog-tab-side", false) bg:SetSize(TAB_BG_W, TAB_BG_H) - bg:SetPoint("TOPLEFT", tab, "TOPLEFT", 0, 0) + bg:SetPoint("CENTER", tab, "CENTER", 0, 0) local icon = tab:CreateTexture(nil, "ARTWORK") icon:SetAtlas("common-search-magnifyingglass", false) icon:SetSize(TAB_ICON_SIZE, TAB_ICON_SIZE) - icon:SetPoint("CENTER", tab, "CENTER", -4, 0) + icon:SetPoint("CENTER", tab, "CENTER", 0, 0) local selectGlow = tab:CreateTexture(nil, "OVERLAY") selectGlow:SetAtlas("QuestLog-Tab-side-Glow-Select", false) selectGlow:SetSize(TAB_BG_W, TAB_BG_H) - selectGlow:SetPoint("TOPLEFT", bg, "TOPLEFT", 0, 0) + selectGlow:SetPoint("CENTER", bg, "CENTER", 0, 0) selectGlow:Hide() tab._efSelectGlow = selectGlow local hoverGlow = tab:CreateTexture(nil, "HIGHLIGHT") hoverGlow:SetAtlas("QuestLog-Tab-side-Glow-hover", false) hoverGlow:SetSize(TAB_BG_W, TAB_BG_H) - hoverGlow:SetPoint("TOPLEFT", bg, "TOPLEFT", 0, 0) + hoverGlow:SetPoint("CENTER", bg, "CENTER", 0, 0) tab:SetScript("OnMouseUp", function(_, button) if button == "LeftButton" then ShowOurPanel() end @@ -663,7 +670,10 @@ end -- =================================================================== local function CreatePanel(qmf) - local host = qmf.QuestsFrame or qmf + -- Match the QuestScrollFrame rect so our panel slots exactly into the + -- quest log content area. Pulling from _G in case QuestsFrame is + -- loaded but the scroll frame gets attached later. + local host = _G["QuestScrollFrame"] or qmf.QuestsFrame or qmf local p = CreateFrame("Frame", "EasyFindMapSearchPanel", qmf) p:SetFrameStrata(qmf:GetFrameStrata()) p:SetFrameLevel(qmf:GetFrameLevel() + 5) @@ -671,6 +681,15 @@ local function CreatePanel(qmf) p:EnableMouse(true) p:Hide() + -- Paint the same backdrop the Blizzard quest-log scroll area uses + -- (QuestLog-main-background atlas, file 5684770). Layered at + -- BACKGROUND sublevel -1 so it sits behind the search box + rows + -- but above whatever QuestMapFrame shows underneath us. + local bg = p:CreateTexture(nil, "BACKGROUND", nil, -1) + bg:SetAtlas("QuestLog-main-background", false) + bg:SetAllPoints(p) + p.backdrop = bg + local searchBox = CreateSearchBox(p) searchBox:SetPoint("TOPLEFT", p, "TOPLEFT", 6, -8) p.searchBox = searchBox From 18a87be1d477674e860206e82517806251b473e4 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 19 Apr 2026 20:50:44 -0400 Subject: [PATCH 022/103] MapTab visual polish: tab gap, gold icon, header search, clean scroll Tab: - Added 6px vertical gap under MapLegendTab so the leather edges don't collide. - Shrunk the magnifier to 20x20 (native atlas is 24x24; smaller keeps it crisp instead of upscaled-blurry). - Tint the icon dim gold when inactive, bright gold (GOLD_COLOR) when our tab is selected, matching the other tabs' gold accent palette. RefreshSelectGlows toggles both the Select glow and the icon color. Panel layout: - Search box and cog now sit ABOVE the paper backdrop in the map frame's dark header area, mirroring where Blizzard's Search Quest Log field renders. Scroll area now covers the full paper rect. - Replaced UIPanelScrollFrameTemplate with a plain ScrollFrame + mouse-wheel handler so we don't inherit the blue/gold scroll decorations. A proper minimal scrollbar is a followup. Tab switching: - Brought back HideBlizzPanels when our panel opens, which stops the MapLegend header text bleeding through our overlay. - Blizzard tab click hooks now explicitly Show the correct panel and Hide the other (belt-and-suspenders in case Blizzard's internal tab switcher bailed on "already selected" state). Removed auto-focus of the search box on tab click. User can click the box when they want to type. MapTab:Focus (keybind path) still focuses explicitly. --- MapTab.lua | 90 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/MapTab.lua b/MapTab.lua index 66e1d2b..a1a0400 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -25,7 +25,13 @@ local select = select -- Side-tab geometry and shared atlas textures from the Blizzard map. local TAB_W, TAB_H = 42, 55 local TAB_BG_W, TAB_BG_H = 51, 59 -local TAB_ICON_SIZE = 28 +-- The common-search-magnifyingglass atlas is 24x24 native. Rendering at +-- 20x20 keeps it crisp (no upscale blur) while sitting inside the tab. +local TAB_ICON_SIZE = 20 +local TAB_ICON_GOLD = {1.00, 0.82, 0.00} +local TAB_ICON_DIM = {0.55, 0.45, 0.10} +-- Vertical gap under MapLegendTab so the two tabs don't collide. +local TAB_STACK_GAP = -6 -- Popup menu geometry (matches UI.lua's pin/guide popup) local EYE_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\eye" @@ -61,13 +67,17 @@ local function FindAtlasTexture(frame, atlas) return nil end --- Note: we deliberately do NOT hide QuestsFrame / MapLegendFrame when our --- panel opens. Blizzard's tab logic assumes those frames stay alive and --- simply swap visibility internally; hiding them from here leaves the --- quest-log scroll area blank when the user clicks back to Quests or --- MapLegend. Our panel is parented to QuestMapFrame with a higher frame --- level + an opaque QuestLog-main-background atlas, so it cleanly --- overlays the other content without disturbing it. +-- When our panel opens we hide Blizzard's QuestsFrame + MapLegendFrame; +-- otherwise their text (section headers, "Map Legend" title) leaks +-- through our overlay since those fontstrings live at frame sublevels +-- above any single backdrop. We track which was visible so the user's +-- click on a Blizzard tab correctly restores its panel even if our +-- state overlapped a Blizzard internal that skipped a re-Show. +local function HideBlizzPanels(qmf) + if qmf.QuestsFrame then qmf.QuestsFrame:Hide() end + if qmf.DetailsFrame then qmf.DetailsFrame:Hide() end + if qmf.MapLegendFrame then qmf.MapLegendFrame:Hide() end +end local function RefreshSelectGlows() local qmf = _G["QuestMapFrame"] @@ -87,6 +97,10 @@ local function RefreshSelectGlows() else setGlow(tabFrame, false) end + if tabFrame and tabFrame._efIcon then + local c = selectedIsOurs and TAB_ICON_GOLD or TAB_ICON_DIM + tabFrame._efIcon:SetVertexColor(c[1], c[2], c[3]) + end end local function ShallowCopyList(list) @@ -519,9 +533,11 @@ local function ShowOurPanel() local qmf = _G["QuestMapFrame"] if not qmf or not panel then return end selectedIsOurs = true + HideBlizzPanels(qmf) panel:Show() RefreshSelectGlows() - if panel.searchBox then panel.searchBox:SetFocus() end + -- Don't auto-focus the search box; clicking the tab just swaps content. + -- User can click the box when they want to type. MapTab:RunSearch(panel.searchBox and panel.searchBox:GetText() or "") end @@ -620,9 +636,9 @@ local function CreateTabFrame(qmf) tab:SetSize(TAB_W, TAB_H) tab:SetFrameStrata("HIGH") tab:SetFrameLevel(qmf.MapLegendTab:GetFrameLevel()) - -- Stack flush under MapLegendTab. Inherits its X offset from the map - -- frame's right edge, so horizontally it lines up identically. - tab:SetPoint("TOPLEFT", qmf.MapLegendTab, "BOTTOMLEFT", 0, 0) + -- Stack under MapLegendTab with a small vertical gap so the leather + -- edges of the two tabs don't collide. + tab:SetPoint("TOPLEFT", qmf.MapLegendTab, "BOTTOMLEFT", 0, TAB_STACK_GAP) tab:EnableMouse(true) -- Leather side piece. The atlas (51x59) is wider/taller than the @@ -634,10 +650,14 @@ local function CreateTabFrame(qmf) bg:SetSize(TAB_BG_W, TAB_BG_H) bg:SetPoint("CENTER", tab, "CENTER", 0, 0) + -- Icon: common-search-magnifyingglass atlas tinted gold. RefreshSelectGlows + -- toggles between dim (inactive) and bright (active) gold. local icon = tab:CreateTexture(nil, "ARTWORK") icon:SetAtlas("common-search-magnifyingglass", false) icon:SetSize(TAB_ICON_SIZE, TAB_ICON_SIZE) icon:SetPoint("CENTER", tab, "CENTER", 0, 0) + icon:SetVertexColor(TAB_ICON_DIM[1], TAB_ICON_DIM[2], TAB_ICON_DIM[3]) + tab._efIcon = icon local selectGlow = tab:CreateTexture(nil, "OVERLAY") selectGlow:SetAtlas("QuestLog-Tab-side-Glow-Select", false) @@ -690,17 +710,25 @@ local function CreatePanel(qmf) bg:SetAllPoints(p) p.backdrop = bg + -- Search box and cog sit ABOVE the paper, in the dark header bar of + -- the map frame (matching where Blizzard's Search Quest Log sits). + -- Anchored with positive Y so they float above the panel's top edge. local searchBox = CreateSearchBox(p) - searchBox:SetPoint("TOPLEFT", p, "TOPLEFT", 6, -8) + searchBox:SetPoint("TOPLEFT", p, "TOPLEFT", 4, 30) p.searchBox = searchBox local cog = CreateFilterCog(p) cog:SetPoint("LEFT", searchBox, "RIGHT", 6, 0) p.cog = cog - local scrollFrame = CreateFrame("ScrollFrame", nil, p, "UIPanelScrollFrameTemplate") - scrollFrame:SetPoint("TOPLEFT", searchBox, "BOTTOMLEFT", 0, -12) - scrollFrame:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", -24, 8) + -- Scroll area covers the full paper area. Use a plain ScrollFrame + -- (no template) so we don't inherit the blue/gold UIPanel scroll + -- decorations. Mouse wheel drives scrolling; a proper minimal + -- scrollbar is a followup. + local scrollFrame = CreateFrame("ScrollFrame", nil, p) + scrollFrame:SetPoint("TOPLEFT", p, "TOPLEFT", 6, -8) + scrollFrame:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", -6, 8) + scrollFrame:EnableMouseWheel(true) p.scrollFrame = scrollFrame local scrollChild = CreateFrame("Frame", nil, scrollFrame) @@ -708,6 +736,14 @@ local function CreatePanel(qmf) scrollFrame:SetScrollChild(scrollChild) scrollFrame:HookScript("OnSizeChanged", function(_, w) scrollChild:SetWidth(w) end) scrollChild:SetWidth(scrollFrame:GetWidth()) + scrollFrame:SetScript("OnMouseWheel", function(self, delta) + local maxScroll = math.max(0, scrollChild:GetHeight() - self:GetHeight()) + local cur = self:GetVerticalScroll() or 0 + local target = cur - delta * 24 + if target < 0 then target = 0 end + if target > maxScroll then target = maxScroll end + self:SetVerticalScroll(target) + end) p.scrollChild = scrollChild local emptyMsg = p:CreateFontString(nil, "OVERLAY", "GameFontHighlight") @@ -746,22 +782,28 @@ function MapTab:Initialize() tabFrame = CreateTabFrame(qmf) panel = CreatePanel(qmf) + -- Blizzard tab click: hide our panel. Belt-and-suspenders show the + -- clicked panel explicitly in case Blizzard's internal handler saw a + -- state we disturbed (we hid both panels when our tab opened, and + -- Blizzard's tab click may early-out on "already selected" state). if qmf.QuestsTab then qmf.QuestsTab:HookScript("OnMouseUp", function(_, button) - if button == "LeftButton" then HideOurPanel() end + if button == "LeftButton" then + HideOurPanel() + if qmf.MapLegendFrame then qmf.MapLegendFrame:Hide() end + if qmf.QuestsFrame then qmf.QuestsFrame:Show() end + end end) end if qmf.MapLegendTab then qmf.MapLegendTab:HookScript("OnMouseUp", function(_, button) - if button == "LeftButton" then HideOurPanel() end + if button == "LeftButton" then + HideOurPanel() + if qmf.QuestsFrame then qmf.QuestsFrame:Hide() end + if qmf.MapLegendFrame then qmf.MapLegendFrame:Show() end + end end) end - if qmf.QuestsFrame then - qmf.QuestsFrame:HookScript("OnShow", HideOurPanel) - end - if qmf.MapLegendFrame then - qmf.MapLegendFrame:HookScript("OnShow", HideOurPanel) - end -- Hide our tab in the maximized map view, matching Blizzard's tabs. if WorldMapFrame and WorldMapFrame.IsMaximized then From a9d1256bd89257c37f4543158920c238b6f816d0 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 19 Apr 2026 20:58:07 -0400 Subject: [PATCH 023/103] Fix MapTab overlay bleed-through from other tabs Stopped hiding QuestsFrame / MapLegendFrame when our panel opens; the show/hide dance fought Blizzard's internal tab-state machine, and the "Map Legend" title fontstring lives on QuestMapFrame directly (not on MapLegendFrame), so hiding the panel never hid the title anyway. New approach: overlay everything. - Panel extends 42px up into QuestMapFrame's dark header bar. - Opaque dark ColorTexture covers that header region, occluding any Blizzard title text that would otherwise bleed through. - QuestLog-main-background atlas paints only the lower 468px (paper). - Panel frame level raised to qmf + 50 so it sits above any font strings Blizzard anchors to QuestMapFrame. - Search box + cog live in the header region. - Scroll area fills the paper region with a 14px right margin for a future minimal scrollbar. Blizzard tab-click hooks simplified: just HideOurPanel; Blizzard's own OnMouseUp handles showing the clicked panel since we no longer disturb their visibility state. --- MapTab.lua | 96 +++++++++++++++++++++++++++--------------------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/MapTab.lua b/MapTab.lua index a1a0400..8258016 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -67,17 +67,14 @@ local function FindAtlasTexture(frame, atlas) return nil end --- When our panel opens we hide Blizzard's QuestsFrame + MapLegendFrame; --- otherwise their text (section headers, "Map Legend" title) leaks --- through our overlay since those fontstrings live at frame sublevels --- above any single backdrop. We track which was visible so the user's --- click on a Blizzard tab correctly restores its panel even if our --- state overlapped a Blizzard internal that skipped a re-Show. -local function HideBlizzPanels(qmf) - if qmf.QuestsFrame then qmf.QuestsFrame:Hide() end - if qmf.DetailsFrame then qmf.DetailsFrame:Hide() end - if qmf.MapLegendFrame then qmf.MapLegendFrame:Hide() end -end +-- We overlay Blizzard's panels with ours rather than hiding them. Hiding +-- QuestsFrame / MapLegendFrame fought Blizzard's own tab-state machine +-- (the "Map Legend" title fontstring lives on QuestMapFrame directly, +-- not on MapLegendFrame, so hiding the panel didn't hide the title). +-- A high frame level plus an opaque backdrop extending up through the +-- header area cleanly covers everything without disturbing Blizzard +-- state. HideBlizzPanels is kept as a no-op shim for clarity. +local function HideBlizzPanels(_) end local function RefreshSelectGlows() local qmf = _G["QuestMapFrame"] @@ -690,44 +687,55 @@ end -- =================================================================== local function CreatePanel(qmf) - -- Match the QuestScrollFrame rect so our panel slots exactly into the - -- quest log content area. Pulling from _G in case QuestsFrame is - -- loaded but the scroll frame gets attached later. + -- Anchor to the QuestScrollFrame rect so the paper portion of our + -- panel lines up with Blizzard's quest-list area. The header region + -- is handled by a separate upward anchor on the search-box host + -- frame so it extends above into the dark map header bar. local host = _G["QuestScrollFrame"] or qmf.QuestsFrame or qmf local p = CreateFrame("Frame", "EasyFindMapSearchPanel", qmf) p:SetFrameStrata(qmf:GetFrameStrata()) - p:SetFrameLevel(qmf:GetFrameLevel() + 5) - p:SetAllPoints(host) + -- Very high level so we sit above any FontStrings Blizzard anchors + -- to QuestMapFrame (like the "Map Legend" title text). + p:SetFrameLevel(qmf:GetFrameLevel() + 50) + p:SetPoint("TOPLEFT", host, "TOPLEFT", 0, 42) + p:SetPoint("BOTTOMRIGHT", host, "BOTTOMRIGHT", 0, 0) p:EnableMouse(true) p:Hide() + -- Opaque dark header block covering the area above the paper, where + -- Blizzard's title fontstring ("Map & Quest Log" / "Map Legend") + -- sits. Without this, the title bleeds through when another tab + -- was last active. + local headerBg = p:CreateTexture(nil, "BACKGROUND", nil, -2) + headerBg:SetColorTexture(0.08, 0.08, 0.09, 1.0) + headerBg:SetPoint("TOPLEFT", p, "TOPLEFT", 0, 0) + headerBg:SetPoint("TOPRIGHT", p, "TOPRIGHT", 0, 0) + headerBg:SetHeight(42) + p.headerBg = headerBg + -- Paint the same backdrop the Blizzard quest-log scroll area uses - -- (QuestLog-main-background atlas, file 5684770). Layered at - -- BACKGROUND sublevel -1 so it sits behind the search box + rows - -- but above whatever QuestMapFrame shows underneath us. - local bg = p:CreateTexture(nil, "BACKGROUND", nil, -1) - bg:SetAtlas("QuestLog-main-background", false) - bg:SetAllPoints(p) - p.backdrop = bg - - -- Search box and cog sit ABOVE the paper, in the dark header bar of - -- the map frame (matching where Blizzard's Search Quest Log sits). - -- Anchored with positive Y so they float above the panel's top edge. + -- (QuestLog-main-background atlas) on the lower paper portion. + local paperBg = p:CreateTexture(nil, "BACKGROUND", nil, -1) + paperBg:SetAtlas("QuestLog-main-background", false) + paperBg:SetPoint("TOPLEFT", p, "TOPLEFT", 0, -42) + paperBg:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", 0, 0) + p.paperBg = paperBg + + -- Search box + cog live in the header area (upper 42px of our panel) + -- so they sit above the paper and below the map title bar, matching + -- where QuestScrollFrame.SearchBox renders in Blizzard's layout. local searchBox = CreateSearchBox(p) - searchBox:SetPoint("TOPLEFT", p, "TOPLEFT", 4, 30) + searchBox:SetPoint("TOPLEFT", p, "TOPLEFT", 6, -12) p.searchBox = searchBox local cog = CreateFilterCog(p) cog:SetPoint("LEFT", searchBox, "RIGHT", 6, 0) p.cog = cog - -- Scroll area covers the full paper area. Use a plain ScrollFrame - -- (no template) so we don't inherit the blue/gold UIPanel scroll - -- decorations. Mouse wheel drives scrolling; a proper minimal - -- scrollbar is a followup. + -- Scroll area occupies the paper portion only. local scrollFrame = CreateFrame("ScrollFrame", nil, p) - scrollFrame:SetPoint("TOPLEFT", p, "TOPLEFT", 6, -8) - scrollFrame:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", -6, 8) + scrollFrame:SetPoint("TOPLEFT", paperBg, "TOPLEFT", 6, -6) + scrollFrame:SetPoint("BOTTOMRIGHT", paperBg, "BOTTOMRIGHT", -14, 6) scrollFrame:EnableMouseWheel(true) p.scrollFrame = scrollFrame @@ -782,26 +790,18 @@ function MapTab:Initialize() tabFrame = CreateTabFrame(qmf) panel = CreatePanel(qmf) - -- Blizzard tab click: hide our panel. Belt-and-suspenders show the - -- clicked panel explicitly in case Blizzard's internal handler saw a - -- state we disturbed (we hid both panels when our tab opened, and - -- Blizzard's tab click may early-out on "already selected" state). + -- Blizzard tab click: just hide our overlay; Blizzard's own OnMouseUp + -- (which runs before this HookScript) handles showing the correct + -- panel. No need to re-do its work since we never disturb Quests / + -- MapLegend visibility ourselves. if qmf.QuestsTab then qmf.QuestsTab:HookScript("OnMouseUp", function(_, button) - if button == "LeftButton" then - HideOurPanel() - if qmf.MapLegendFrame then qmf.MapLegendFrame:Hide() end - if qmf.QuestsFrame then qmf.QuestsFrame:Show() end - end + if button == "LeftButton" then HideOurPanel() end end) end if qmf.MapLegendTab then qmf.MapLegendTab:HookScript("OnMouseUp", function(_, button) - if button == "LeftButton" then - HideOurPanel() - if qmf.QuestsFrame then qmf.QuestsFrame:Hide() end - if qmf.MapLegendFrame then qmf.MapLegendFrame:Show() end - end + if button == "LeftButton" then HideOurPanel() end end) end From bfc113f0dd8f75387ad04de65adc02e0c189d273 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 19 Apr 2026 21:04:46 -0400 Subject: [PATCH 024/103] Fix MapTab: anchor to QuestsFrame, alpha-hide Blizzard panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dark header issue: panel was anchored to QuestScrollFrame with a +42 y-offset that pushed the top edge outside QuestMapFrame's visible bounds, so the opaque header backdrop extended past the map window. Panel now anchors to QuestsFrame (the right-column container that already spans both the header + paper areas), reducing header extent to an internal 34px block that stays within QuestMapFrame. Scrollbar-bleeding issue: Blizzard's scrollbars / content in QuestsFrame or MapLegendFrame were drawing above our backdrop because nested children can sit at frame sublevels above our +50. We can't just Hide those panels (breaks Blizzard's tab state). Instead we SetAlpha(0) on them while our panel is active — children inherit alpha 0 so nothing renders, but frames stay Shown so Blizzard's tab logic is undisturbed. HideOurPanel restores alpha 1 on close. Panel right edge extended 8px past QuestsFrame to cover any scrollbar child that might anchor just outside. --- MapTab.lua | 71 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/MapTab.lua b/MapTab.lua index 8258016..8d57251 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -67,14 +67,20 @@ local function FindAtlasTexture(frame, atlas) return nil end --- We overlay Blizzard's panels with ours rather than hiding them. Hiding --- QuestsFrame / MapLegendFrame fought Blizzard's own tab-state machine --- (the "Map Legend" title fontstring lives on QuestMapFrame directly, --- not on MapLegendFrame, so hiding the panel didn't hide the title). --- A high frame level plus an opaque backdrop extending up through the --- header area cleanly covers everything without disturbing Blizzard --- state. HideBlizzPanels is kept as a no-op shim for clarity. -local function HideBlizzPanels(_) end +-- Blizzard's scrollbars + panel contents can still draw above our +-- backdrop even at a high frame level because their children nest at +-- higher sublevels. Rather than Hide the panels (which broke tab-state +-- last time), we SetAlpha(0) on them while ours is active — their +-- children inherit alpha 0 so nothing draws, but the frames remain +-- Shown so Blizzard's tab logic stays consistent. +local function HideBlizzPanels(qmf) + if qmf.QuestsFrame then qmf.QuestsFrame:SetAlpha(0) end + if qmf.MapLegendFrame then qmf.MapLegendFrame:SetAlpha(0) end +end +local function RestoreBlizzPanels(qmf) + if qmf.QuestsFrame then qmf.QuestsFrame:SetAlpha(1) end + if qmf.MapLegendFrame then qmf.MapLegendFrame:SetAlpha(1) end +end local function RefreshSelectGlows() local qmf = _G["QuestMapFrame"] @@ -545,6 +551,8 @@ local function HideOurPanel() panel:Hide() if panel.searchBox then panel.searchBox:ClearFocus() end end + local qmf = _G["QuestMapFrame"] + if qmf then RestoreBlizzPanels(qmf) end ClearHoverPreview() RefreshSelectGlows() end @@ -687,52 +695,57 @@ end -- =================================================================== local function CreatePanel(qmf) - -- Anchor to the QuestScrollFrame rect so the paper portion of our - -- panel lines up with Blizzard's quest-list area. The header region - -- is handled by a separate upward anchor on the search-box host - -- frame so it extends above into the dark map header bar. - local host = _G["QuestScrollFrame"] or qmf.QuestsFrame or qmf + -- Anchor to QuestsFrame (the right-column container that spans both + -- the header area where SearchBox sits and the paper scroll area). + -- This matches exactly what Blizzard's Quests panel covers, so we + -- naturally occlude their whole rect (including any scrollbar). + local host = qmf.QuestsFrame or _G["QuestScrollFrame"] or qmf local p = CreateFrame("Frame", "EasyFindMapSearchPanel", qmf) p:SetFrameStrata(qmf:GetFrameStrata()) -- Very high level so we sit above any FontStrings Blizzard anchors - -- to QuestMapFrame (like the "Map Legend" title text). + -- to QuestMapFrame (like the "Map Legend" title text) and above + -- any scrollbar children anchored at higher sublevels. p:SetFrameLevel(qmf:GetFrameLevel() + 50) - p:SetPoint("TOPLEFT", host, "TOPLEFT", 0, 42) - p:SetPoint("BOTTOMRIGHT", host, "BOTTOMRIGHT", 0, 0) + -- A touch of right-edge extension just in case a child scrollbar + -- peeks outside QuestsFrame's own bounds. + p:SetPoint("TOPLEFT", host, "TOPLEFT", 0, 0) + p:SetPoint("BOTTOMRIGHT", host, "BOTTOMRIGHT", 8, 0) p:EnableMouse(true) p:Hide() - -- Opaque dark header block covering the area above the paper, where - -- Blizzard's title fontstring ("Map & Quest Log" / "Map Legend") - -- sits. Without this, the title bleeds through when another tab - -- was last active. + -- Approximate height of the header area inside QuestsFrame where the + -- search bar + settings cog sit (above the paper). Measured against + -- QuestScrollFrame (the paper area) so subsequent texture sizing + -- adapts if Blizzard tweaks the layout. + local HEADER_H = 34 + + -- Opaque dark header block covering the area above the paper. + -- Occludes any Blizzard title text that would bleed through. local headerBg = p:CreateTexture(nil, "BACKGROUND", nil, -2) headerBg:SetColorTexture(0.08, 0.08, 0.09, 1.0) headerBg:SetPoint("TOPLEFT", p, "TOPLEFT", 0, 0) headerBg:SetPoint("TOPRIGHT", p, "TOPRIGHT", 0, 0) - headerBg:SetHeight(42) + headerBg:SetHeight(HEADER_H) p.headerBg = headerBg - -- Paint the same backdrop the Blizzard quest-log scroll area uses - -- (QuestLog-main-background atlas) on the lower paper portion. + -- Paper backdrop (QuestLog-main-background atlas) covers the rest. local paperBg = p:CreateTexture(nil, "BACKGROUND", nil, -1) paperBg:SetAtlas("QuestLog-main-background", false) - paperBg:SetPoint("TOPLEFT", p, "TOPLEFT", 0, -42) + paperBg:SetPoint("TOPLEFT", p, "TOPLEFT", 0, -HEADER_H) paperBg:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", 0, 0) p.paperBg = paperBg - -- Search box + cog live in the header area (upper 42px of our panel) - -- so they sit above the paper and below the map title bar, matching - -- where QuestScrollFrame.SearchBox renders in Blizzard's layout. + -- Search box + cog sit inside the header region. local searchBox = CreateSearchBox(p) - searchBox:SetPoint("TOPLEFT", p, "TOPLEFT", 6, -12) + searchBox:SetPoint("TOPLEFT", p, "TOPLEFT", 6, -8) p.searchBox = searchBox local cog = CreateFilterCog(p) cog:SetPoint("LEFT", searchBox, "RIGHT", 6, 0) p.cog = cog - -- Scroll area occupies the paper portion only. + -- Scroll area fills just the paper. Right margin reserved for a + -- future minimal scrollbar. local scrollFrame = CreateFrame("ScrollFrame", nil, p) scrollFrame:SetPoint("TOPLEFT", paperBg, "TOPLEFT", 6, -6) scrollFrame:SetPoint("BOTTOMRIGHT", paperBg, "BOTTOMRIGHT", -14, 6) From 18294d5c096c08f5c7a08d96b43c902653720dbf Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Wed, 29 Apr 2026 16:49:50 -0400 Subject: [PATCH 025/103] MapTab: inline autocomplete, group headers, one-click row activation Search panel rewrite layered on the prior MapTab scaffold. - Inline autocomplete via SetText + HighlightText: typed prefix plus suggestion suffix shown as a selected range. Native caret stays at the end of typed text. Tab/Enter confirm, backspace dismisses without re-conjuring the suggestion, typing replaces the selection to advance through it. - Group headers with collapse/expand by continent and zone. Display paths collapse to the immediate parent. World/Azeroth/Cosmic stripped as virtual roots that add no disambiguation. - Multi-token queries ("northrend raid") match ancestor names so category-style filtering works without a literal substring. - Recent searches list (capped, dedupe, show/hide toggle) and pinned items section with right-click pin/unpin/guide popup. - Keyboard nav: arrow keys / Ctrl+J/K / Enter / Esc with hold-to-step acceleration; navFrame keyboard capture flips between editbox and result list as focus moves. - One-click row activation: rows, group headers, and toggle buttons register clicks for ButtonDown so OnClick fires on press, bypassing WoW's focus-transition machinery that was absorbing the mouseUp on the first click and forcing a second. - Hover preview throttled during typing so re-rendered rows landing under a stationary cursor don't spawn spurious map pins. --- MapTab.lua | 2434 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 2081 insertions(+), 353 deletions(-) diff --git a/MapTab.lua b/MapTab.lua index 8d57251..821e38f 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -8,7 +8,7 @@ local SafeAfter = Utils and Utils.SafeAfter or function(delay, fn) C_Timer.After local sfind = Utils and Utils.sfind or string.find local slower = Utils and Utils.slower or string.lower local tinsert = Utils and Utils.tinsert or table.insert -local wipe = wipe +local tremove = table.remove local GOLD_COLOR = ns.GOLD_COLOR or {1.0, 0.82, 0.0} local TOOLTIP_BORDER = ns.TOOLTIP_BORDER @@ -20,20 +20,18 @@ local GameTooltip_Hide = GameTooltip_Hide local C_Timer = C_Timer local GetCursorPosition = GetCursorPosition local UIParent = UIParent -local select = select --- Side-tab geometry and shared atlas textures from the Blizzard map. +-- --------------------------------------------------------------------------- +-- Tab geometry +-- --------------------------------------------------------------------------- local TAB_W, TAB_H = 42, 55 local TAB_BG_W, TAB_BG_H = 51, 59 --- The common-search-magnifyingglass atlas is 24x24 native. Rendering at --- 20x20 keeps it crisp (no upscale blur) while sitting inside the tab. local TAB_ICON_SIZE = 20 local TAB_ICON_GOLD = {1.00, 0.82, 0.00} local TAB_ICON_DIM = {0.55, 0.45, 0.10} --- Vertical gap under MapLegendTab so the two tabs don't collide. -local TAB_STACK_GAP = -6 +local TAB_STACK_GAP = -3 --- Popup menu geometry (matches UI.lua's pin/guide popup) +-- Pin / Guide popup geometry (mirrors UI.lua) local EYE_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\eye" local PIN_MENU_ROW_H = 22 local PIN_MENU_WIDTH = 96 @@ -42,46 +40,223 @@ local PIN_MENU_WIDTH = 96 local ROW_HEIGHT = 24 local ROW_ICON_SIZE = 18 local SECTION_HEADER_H = 22 -local MAX_ROW_POOL = 60 +-- Pool grows as needed and is reused across queries; the cap exists +-- only to prevent unbounded growth on pathological inputs. Global +-- searches that match a category (e.g. "fp" → every flight master) +-- can easily produce 200+ rows, so the cap needs headroom well past +-- the worst real query. +local MAX_ROW_POOL = 1000 + +-- Roots stripped from pathPrefix display. Every zone in WoW descends +-- from "World", and the vast majority descend from "Azeroth", so those +-- segments add no disambiguation — strip them in display order only. +-- Other continents ("Outland", "Draenor", etc.) are kept because they +-- genuinely distinguish a zone's origin. +-- Top-level virtual roots that should NOT surface as group headers. +-- "Cosmic" (mapID 946) sits above Azeroth, Outland, Draenor, and +-- Shadowlands; without it here, raids and dungeons from those +-- expansions all group under "Cosmic" instead of their actual +-- continent name. +local PATH_STRIP_ROOTS = { "World", "Azeroth", "Cosmic" } + +local function FormatPathPrefix(pathStr) + if type(pathStr) ~= "string" or pathStr == "" then return pathStr end + for i = 1, #PATH_STRIP_ROOTS do + local root = PATH_STRIP_ROOTS[i] .. " > " + if pathStr:sub(1, #root) == root then + pathStr = pathStr:sub(#root + 1) + else + break -- stop on first non-matching root so order is preserved + end + end + -- Exact match (pathStr == a stripped root with nothing after): drop it. + for i = 1, #PATH_STRIP_ROOTS do + if pathStr == PATH_STRIP_ROOTS[i] then return "" end + end + return pathStr +end + +-- Top non-stripped ancestor name for a mapID. Walks the parentMapID +-- chain until the next step would be a stripped root ("World"/"Azeroth") +-- or root is reached. Used as the grouping key so deeply-nested zones +-- (Dalaran inside Crystalsong Forest inside Northrend) group with +-- siblings at the same continent-level header. +local topAncestorCache = {} +local function IsStripped(name) + if not name then return false end + for i = 1, #PATH_STRIP_ROOTS do + if PATH_STRIP_ROOTS[i] == name then return true end + end + return false +end +local function GetTopAncestor(mapID) + if not mapID or mapID == 0 then return nil end + local cached = topAncestorCache[mapID] + if cached ~= nil then + if cached == false then return nil end + return cached.name, cached.mapID + end + local current = mapID + local resultName, resultID + for _ = 1, 20 do + local info = C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(current) + if not info then break end + local parentID = info.parentMapID + local parentInfo = parentID and parentID ~= 0 and C_Map.GetMapInfo(parentID) or nil + local parentName = parentInfo and parentInfo.name + if not parentInfo or not parentName or IsStripped(parentName) or parentID == 0 then + resultName = info.name + resultID = current + break + end + current = parentID + end + if resultName then + topAncestorCache[mapID] = { name = resultName, mapID = resultID } + else + topAncestorCache[mapID] = false + end + return resultName, resultID +end + +-- Exposed so UI search and other callers can group map results by the +-- same continent label the MapTab uses. +MapTab.GetTopAncestor = function(_, mapID) return GetTopAncestor(mapID) end + +-- Walk mapID's parent chain and return the first ancestor whose +-- parentMapID is ancestorMapID — i.e. the direct child of `ancestorMapID` +-- that contains `mapID`. Used for second-level zone grouping inside a +-- continent group: an FM in Hillsbrad → Hillsbrad; a zone result for +-- Eastern Plaguelands → itself. +local zoneUnderAncestorCache = {} +local function GetZoneUnderAncestor(mapID, ancestorMapID) + if not mapID or not ancestorMapID then return nil end + if mapID == ancestorMapID then return nil end + local cacheKey = mapID .. "_" .. ancestorMapID + local cached = zoneUnderAncestorCache[cacheKey] + if cached ~= nil then + if cached == false then return nil end + return cached.name, cached.mapID + end + local current = mapID + for _ = 1, 20 do + local info = C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(current) + if not info then break end + if info.parentMapID == ancestorMapID then + zoneUnderAncestorCache[cacheKey] = { name = info.name, mapID = current } + return info.name, current + end + if not info.parentMapID or info.parentMapID == 0 then break end + current = info.parentMapID + end + zoneUnderAncestorCache[cacheKey] = false + return nil +end + +-- Children of a map from Blizzard's world hierarchy (non-recursive). +-- Used to populate a group on-demand when the user expands a matched +-- parent zone that didn't have any query-matched children. Cached per +-- session; invalidated on the same events that clear the zone cache. +local worldChildrenCache = {} +local function GetWorldChildren(mapID) + if not mapID or mapID == 0 then return nil end + local cached = worldChildrenCache[mapID] + if cached then return cached end + local result = {} + local GetMapChildrenInfo = C_Map and C_Map.GetMapChildrenInfo + if not GetMapChildrenInfo then + worldChildrenCache[mapID] = result + return result + end + local children = GetMapChildrenInfo(mapID, nil, false) + if children then + for i = 1, #children do + local child = children[i] + local mt = child.mapType + if child.name and mt + and mt ~= Enum.UIMapType.Micro + and mt ~= Enum.UIMapType.Orphan then + result[#result + 1] = { + name = child.name, + category = "zone", + isZone = true, + zoneMapID = child.mapID, + zoneMapType = mt, + icon = 237382, + synthesized = true, + } + end + end + end + -- Blizzard returns child maps in an internal order (often by mapID + -- creation, which lines up with expansion-release order, not + -- alphabetical). Sort by name so the displayed list scans cleanly + -- under any expanded continent. + table.sort(result, function(a, b) return a.name < b.name end) + worldChildrenCache[mapID] = result + return result +end + +do + local inv = CreateFrame("Frame") + inv:RegisterEvent("PLAYER_ENTERING_WORLD") + inv:RegisterEvent("ZONE_CHANGED_NEW_AREA") + inv:SetScript("OnEvent", function() worldChildrenCache = {} end) +end local initialized = false local tabFrame +-- Forward declaration: RefreshCurrentSearch is defined later but +-- captured by closures (e.g. delete button in CreateResultRow) that +-- are compiled before its definition. Without this upvalue, Lua +-- resolves the name as a global and later fails with "attempt to call +-- global 'RefreshCurrentSearch' (a nil value)". +local RefreshCurrentSearch local panel local selectedIsOurs = false local rowPool = {} -local lastQueryGen = 0 -- bumped per search to invalidate in-flight rebuilds +local headerPool = {} +-- Ephemeral collapse state: scoped to the current query text. Reset +-- when the search text changes so fresh matches always default to +-- expanded (auto-expand on parent match). User clicks on +/- within +-- the same query keep their effect until the text changes. +local sessionCollapsed = {} +local sessionCollapsedQuery = nil +local lastQueryGen = 0 local pendingSearchTimer - --- =================================================================== --- Helpers --- =================================================================== - +-- Tracks the query that produced the currently-displayed results. The +-- search box text can diverge from this (e.g. clicking a recent-search +-- row runs a search without populating the box), so result-click and +-- Enter-press paths read this instead of the editbox to push the right +-- string onto the recents list. +local currentQuery = "" +-- Text of the last rendered search. Used to decide whether a re-render +-- is a refresh (keep scroll position) or a brand-new query (reset to +-- the top). Pin toggles, filter changes, and group collapses all hit +-- the refresh path because they re-run RunSearch with unchanged text. +local lastRenderedQuery +-- Keyboard navigation state. navRowIndex 0 = nothing highlighted; 1..N = +-- index into visibleNavRows (array of frames in display order, rows and +-- group headers interleaved). Rebuilt on every RenderRows. +local navRowIndex = 0 +local visibleNavRows = {} +local navFrame -- created lazily by EnsureNavFrame() + +-- --------------------------------------------------------------------------- +-- Tab select glow + icon tint helper +-- --------------------------------------------------------------------------- local function FindAtlasTexture(frame, atlas) if not frame or not frame.GetRegions then return nil end for i = 1, frame:GetNumRegions() do local region = select(i, frame:GetRegions()) - if region and region.GetAtlas and region.GetObjectType and region:GetObjectType() == "Texture" then - if region:GetAtlas() == atlas then return region end + if region and region.GetAtlas and region.GetObjectType and region:GetObjectType() == "Texture" + and region:GetAtlas() == atlas then + return region end end return nil end --- Blizzard's scrollbars + panel contents can still draw above our --- backdrop even at a high frame level because their children nest at --- higher sublevels. Rather than Hide the panels (which broke tab-state --- last time), we SetAlpha(0) on them while ours is active — their --- children inherit alpha 0 so nothing draws, but the frames remain --- Shown so Blizzard's tab logic stays consistent. -local function HideBlizzPanels(qmf) - if qmf.QuestsFrame then qmf.QuestsFrame:SetAlpha(0) end - if qmf.MapLegendFrame then qmf.MapLegendFrame:SetAlpha(0) end -end -local function RestoreBlizzPanels(qmf) - if qmf.QuestsFrame then qmf.QuestsFrame:SetAlpha(1) end - if qmf.MapLegendFrame then qmf.MapLegendFrame:SetAlpha(1) end -end - local function RefreshSelectGlows() local qmf = _G["QuestMapFrame"] if not qmf then return end @@ -96,6 +271,7 @@ local function RefreshSelectGlows() if selectedIsOurs then setGlow(qmf.QuestsTab, false) setGlow(qmf.MapLegendTab, false) + setGlow(qmf.EventsTab, false) setGlow(tabFrame, true) else setGlow(tabFrame, false) @@ -106,17 +282,73 @@ local function RefreshSelectGlows() end end -local function ShallowCopyList(list) - if not list then return {} end - local copy = {} - for i = 1, #list do copy[i] = list[i] end - return copy +-- --------------------------------------------------------------------------- +-- Panel show / hide: sibling content-panel behavior. +-- Clicking our tab Hide()'s the three Blizzard content panels (Quests, +-- Events, MapLegend) and Show()'s ours. Clicking a Blizzard tab: their own +-- OnMouseUp Show()'s the right panel; we just hide ours. +-- --------------------------------------------------------------------------- +local function ShowOurPanel() + local qmf = _G["QuestMapFrame"] + if not qmf or not panel then return end + selectedIsOurs = true + -- Measure Blizzard's SearchBox BEFORE we hide QuestsFrame, so its + -- GetLeft/GetRight return live values. Cache is reused for + -- subsequent shows if Blizzard's panel isn't visible later. + if panel.MeasureBlizzardSearch then panel.MeasureBlizzardSearch() end + -- Call SetDisplayMode() with nil so QuestMapFrame formally leaves + -- its current official mode. This is the pattern LibWorldMapTabs + -- uses. Without it, clicking a Blizzard tab afterwards is a no-op + -- (same-mode transition) and the panel stays hidden. + if qmf.SetDisplayMode then + local ok, err = pcall(qmf.SetDisplayMode, qmf) + if not ok then + if qmf.QuestsFrame then qmf.QuestsFrame:Hide() end + if qmf.EventsFrame then qmf.EventsFrame:Hide() end + if qmf.MapLegend then qmf.MapLegend:Hide() end + if Utils and Utils.DebugPrint then + Utils.DebugPrint("MapTab SetDisplayMode failed: " .. tostring(err)) + end + end + else + if qmf.QuestsFrame then qmf.QuestsFrame:Hide() end + if qmf.EventsFrame then qmf.EventsFrame:Hide() end + if qmf.MapLegend then qmf.MapLegend:Hide() end + end + + -- LibWorldMapTabs (used by WorldQuestTab and similar) doesn't react + -- to a nil SetDisplayMode, so its content frames stay visible and + -- its tabs stay checked. Hide them via the lib's public API. + if LibStub then + local ok, lwmt = pcall(LibStub, "LibWorldMapTabs", true) + if ok and lwmt and lwmt.SetDisplayMode then + pcall(lwmt.SetDisplayMode, lwmt, nil) + end + end + if panel.outer then panel.outer:Show() else panel:Show() end + if panel.AlignToBlizzardSearch then panel.AlignToBlizzardSearch() end + RefreshSelectGlows() + local sb = panel.searchBox + MapTab:RunSearch(sb and (sb.GetTypedText and sb:GetTypedText() or sb:GetText()) or "") end --- =================================================================== --- Pin/Guide context menu (mirrors UI.lua's popup pattern) --- =================================================================== +local function HideOurPanel() + if not selectedIsOurs then return end + selectedIsOurs = false + if panel then + if panel.outer then panel.outer:Hide() else panel:Hide() end + if panel.searchBox then panel.searchBox:ClearFocus() end + end + if ns.MapSearch and ns.MapSearch.ClearHighlight and ns.MapSearch._previewing then + ns.MapSearch._previewing = nil + ns.MapSearch:ClearHighlight() + end + RefreshSelectGlows() +end +-- --------------------------------------------------------------------------- +-- Pin / Guide right-click popup (mirrors UI.lua) +-- --------------------------------------------------------------------------- local pinPopup local function CreateMenuRow(parent) @@ -160,6 +392,15 @@ local function ShowPopup(isPinned, onPin, onGuide) pinPopup.pinRow = CreateMenuRow(pinPopup) pinPopup.pinRow:SetScript("OnLeave", MenuRowOnLeave) pinPopup.guideRow:SetScript("OnLeave", MenuRowOnLeave) + -- Dismiss on outside click (any button). Same pattern used by + -- the MapSearch filter dropdown. + pinPopup:SetScript("OnUpdate", function(self) + if not self:IsShown() then return end + if (IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton")) + and not self:IsMouseOver() then + self:Hide() + end + end) end pinPopup.pinRow:Show() @@ -197,43 +438,93 @@ local function ShowPopup(isPinned, onPin, onGuide) pinPopup:Show() end --- =================================================================== --- Result rows --- =================================================================== - +-- --------------------------------------------------------------------------- +-- Result rows (pooled) +-- --------------------------------------------------------------------------- local function CreateResultRow(parent) local row = CreateFrame("Button", nil, parent) row:SetHeight(ROW_HEIGHT) - row:RegisterForClicks("LeftButtonUp", "RightButtonUp") + -- Activate on press, not release. With ButtonUp registration, WoW's + -- internal focus-transition machinery (when the search box is + -- focused) absorbs the mouseUp before OnClick fires, requiring a + -- second click to actually activate the row. Down registration runs + -- the action immediately on press so focus changes can't interfere. + row:RegisterForClicks("LeftButtonDown", "RightButtonDown") row:EnableMouse(true) + row:HookScript("OnMouseDown", function() + if panel and panel.searchBox and panel.searchBox.HasFocus and panel.searchBox:HasFocus() then + panel.searchBox:ClearFocus() + end + end) local icon = row:CreateTexture(nil, "ARTWORK") icon:SetSize(ROW_ICON_SIZE, ROW_ICON_SIZE) icon:SetPoint("LEFT", row, "LEFT", 4, 0) row.icon = icon + -- Delete (X) button for recent-search rows only. Hidden by default; + -- shown on hover when data.isRecentSearch is true. Click removes + -- that query from EasyFind.db.mapTabRecentSearches. + local deleteBtn = Utils.CreateClearButton(row, nil) + deleteBtn:ClearAllPoints() + deleteBtn:SetPoint("RIGHT", row, "RIGHT", -6, 0) + deleteBtn:Hide() + deleteBtn:SetScript("OnClick", function(self) + local data = row.data + if not data or not data.isRecentSearch or not data.query then return end + local list = EasyFind.db and EasyFind.db.mapTabRecentSearches + if not list then return end + local lowerQ = data.query:lower() + for i = #list, 1, -1 do + if type(list[i]) == "string" and list[i]:lower() == lowerQ then + tremove(list, i) + end + end + RefreshCurrentSearch() + end) + deleteBtn:SetScript("OnLeave", function(self) + -- Hide only when the mouse has truly left both the row and + -- this button. Without this, moving from button back onto the + -- row would leave a stuck visible X (row OnEnter already fired). + if not row:IsMouseOver() then self:Hide() end + end) + row.deleteBtn = deleteBtn + local text = row:CreateFontString(nil, "OVERLAY", "GameFontNormal") text:SetPoint("LEFT", icon, "RIGHT", 6, 0) - text:SetPoint("RIGHT", row, "RIGHT", -6, 0) + text:SetPoint("RIGHT", deleteBtn, "LEFT", -4, 0) text:SetJustifyH("LEFT") text:SetWordWrap(false) row.text = text - local path = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - path:SetJustifyH("LEFT") - path:SetTextColor(0.55, 0.55, 0.55) - path:Hide() - row.path = path - - row:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD") + -- Single highlight texture covers mouse hover AND keyboard + -- selection via LockHighlight, so both paths look identical — + -- Blizzard's tapered quest-log row glow atlas. Mouse hover shows + -- it automatically; UpdateNavHighlight calls LockHighlight on the + -- keyboard-selected row to pin it on. + row:SetHighlightAtlas("QuestLog-quest-glow-yellow") local hl = row:GetHighlightTexture() - hl:SetVertexColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.25) + if hl then + hl:SetBlendMode("ADD") + hl:SetAllPoints(row) + end return row end local function SetRowIcon(row, data) + -- Reset size on every placement. Pooled rows carry whatever size + -- their last occupant set (recent-search rows shrink to match text + -- height), so without this reset a recycled row keeps the shrunken + -- icon when it's reused for a regular result. + row.icon:SetSize(ROW_ICON_SIZE, ROW_ICON_SIZE) local icon = data.icon + -- Dungeon/raid/delve/rare entries leave data.icon nil and rely on + -- the category icon. Resolve it here so SetRowIcon renders the + -- same texture the MapSearch dropdown does. + if icon == nil and data.category and ns.MapSearch and ns.MapSearch.GetCategoryIcon then + icon = ns.MapSearch.GetCategoryIcon(data.category) + end row.icon:SetTexCoord(0, 1, 0, 1) if type(icon) == "string" and icon:sub(1, 6) == "atlas:" then row.icon:SetTexture(nil) @@ -255,35 +546,50 @@ local function SetRowIcon(row, data) end end -local function HoverPreview(data) +-- Timestamp of the most recent keystroke in the search box. Hover +-- previews are suppressed for a short window afterwards, because every +-- keystroke re-renders the result rows and WoW fires OnEnter for any +-- row that appears under a stationary cursor — which was showing preview +-- pins and highlighting the map without the user moving their mouse. +local lastTypeTime = 0 +local HOVER_PREVIEW_TYPING_GUARD = 0.3 + +-- fromKeyboard = true: bypass the typing guard. Keyboard navigation is +-- explicit user intent so we always preview, unlike stray OnEnter +-- events that fire when a re-rendered row lands under a stationary +-- cursor during typing. +local function HoverPreview(data, fromKeyboard) + if not fromKeyboard and GetTime() - lastTypeTime < HOVER_PREVIEW_TYPING_GUARD then return end local MapSearch = ns.MapSearch - if not MapSearch or not MapSearch.GetPreviewCoords then return end - local coords = MapSearch:GetPreviewCoords(data) - if not coords then return end - MapSearch._savedPinState = MapSearch._savedPinState - MapSearch._previewing = true - if coords.instances then - MapSearch:ShowMultipleWaypoints(coords.instances) - elseif coords.pin and coords.pin:IsShown() then - MapSearch:HighlightPin(coords.pin, coords.x, coords.y, coords.icon, coords.category) - else - MapSearch:ShowWaypointAt(coords.x, coords.y, coords.icon, coords.category) + if MapSearch and MapSearch.RunHoverPreview then + MapSearch:RunHoverPreview(data) end end local function ClearHoverPreview() local MapSearch = ns.MapSearch - if not MapSearch then return end - if MapSearch._previewing then - MapSearch._previewing = nil - if MapSearch.ClearHighlight then MapSearch:ClearHighlight() end + if MapSearch and MapSearch.EndHoverPreview then + MapSearch:EndHoverPreview() end end --- Refresh the current search without stealing focus. Called after pin toggle. -local function RefreshCurrentSearch() +RefreshCurrentSearch = function() if not panel or not panel.searchBox then return end - MapTab:RunSearch(panel.searchBox:GetText() or "") + local sb = panel.searchBox + MapTab:RunSearch(sb.GetTypedText and sb:GetTypedText() or sb:GetText() or "") +end + +-- directOverride: optional bool forwarded to MapSearch:SelectResult. +-- Passed as `false` by the right-click Guide menu to force breadcrumb/ +-- teaching mode regardless of the user's default left-click setting. +local function TriggerResultSelect(data, directOverride) + local MapSearch = ns.MapSearch + if MapSearch and MapSearch.SelectResult then MapSearch:SelectResult(data, directOverride) end + -- Intentionally do NOT re-render or hide results here. Unlike UI + -- search, the MapTab keeps its list visible after activation so + -- the user can click/preview adjacent results without losing the + -- search state. The OnMapChanged hook handles the "This Zone" + -- label refresh when navigation actually changes the map. end local function RowOnClick(row, button) @@ -291,51 +597,90 @@ local function RowOnClick(row, button) if not data then return end local MapSearch = ns.MapSearch - if data.isPinHeader then - EasyFind.db.mapPinsCollapsed = not EasyFind.db.mapPinsCollapsed - RefreshCurrentSearch() + if data.isRecentSearch then + if data.query and panel and panel.searchBox then + -- Populate the search box exactly as if the user had typed + -- and submitted the recent query. OnTextChanged fires from + -- SetText and runs the search; the editbox stays unfocused + -- so the player can keep moving with WASD. Crucially the + -- clear button now has text to act on. + panel.searchBox:SetText(data.query) + panel.searchBox:ClearFocus() + end return end if button == "RightButton" then local isPinned = MapSearch and MapSearch:IsMapItemPinned(data) local isGlobal = data.isZone or data.isDungeonEntrance - local onGuide = isGlobal and function() - if MapSearch and MapSearch.SelectResult then - MapSearch:SelectResult(data) - end - end or nil + local onGuide = isGlobal and function() TriggerResultSelect(data, false) end or nil ShowPopup(isPinned, function() - if isPinned then - MapSearch:UnpinMapItem(data) - else - MapSearch:PinMapItem(data) - end + if isPinned then MapSearch:UnpinMapItem(data) else MapSearch:PinMapItem(data) end RefreshCurrentSearch() end, onGuide) return end - if MapSearch and MapSearch.SelectResult then - MapSearch:SelectResult(data) + -- Left-click on a real result: commit the current query to recents, + -- then navigate. Ensures recents only accumulate on intentional use + -- (Enter key or result click), not on incidental focus loss. + MapTab:PushRecentSearch(currentQuery) + TriggerResultSelect(data) +end + +-- Walk a result's parent-map chain and return a full breadcrumb string +-- " > > > ". Skips a leading ancestor +-- whose name matches the POI itself (zone results' own mapID resolves +-- to the same name, so we'd otherwise render "Tol Barad > Tol Barad"). +local function BuildFullBreadcrumb(data) + local mapID = data.mapID or data.zoneMapID or data.entranceMapID or data.parentMapID + if not mapID or not C_Map or not C_Map.GetMapInfo then return data.name end + local parts = {} + local current = mapID + local leafName = data.name and data.name:lower() or "" + for _ = 1, 20 do + local info = C_Map.GetMapInfo(current) + if not info then break end + if not info.name or info.name:lower() ~= leafName then + parts[#parts + 1] = info.name + end + if not info.parentMapID or info.parentMapID == 0 then break end + current = info.parentMapID end + if #parts == 0 then return data.name end + return data.name .. " > " .. table.concat(parts, " > ") end local function RowOnEnter(row) - if row.data then HoverPreview(row.data) end + if row.data then + if row.data.isRecentSearch and row.deleteBtn then + row.deleteBtn:Show() + else + HoverPreview(row.data) + local crumb = BuildFullBreadcrumb(row.data) + if crumb and crumb ~= "" then + GameTooltip:SetOwner(row, "ANCHOR_RIGHT") + GameTooltip:ClearLines() + GameTooltip:AddLine(crumb, 1, 1, 1, true) + GameTooltip:Show() + end + end + end end - -local function RowOnLeave() +local function RowOnLeave(row) + -- Don't hide the delete button while the cursor is actually on it + -- (child-frame mouse capture would otherwise flicker the button). + if row and row.deleteBtn and not row.deleteBtn:IsMouseOver() then + row.deleteBtn:Hide() + end ClearHoverPreview() + GameTooltip:Hide() end local function AcquireRow(parent) for i = 1, #rowPool do local row = rowPool[i] - if not row:IsShown() then - row:SetParent(parent) - return row - end + if not row:IsShown() then row:SetParent(parent); return row end end if #rowPool >= MAX_ROW_POOL then return nil end local row = CreateResultRow(parent) @@ -347,99 +692,390 @@ local function AcquireRow(parent) end local function ReleaseAllRows() - for i = 1, #rowPool do - rowPool[i]:Hide() - rowPool[i].data = nil - end + for i = 1, #rowPool do rowPool[i]:Hide(); rowPool[i].data = nil end end --- =================================================================== --- Section header (between Pinned / Local / Global) --- =================================================================== - -local function CreateSectionHeader(parent, labelText) +-- Section label (non-clickable): "In This Zone" / "Across Azeroth". +local function CreateSectionLabel(parent) local hdr = CreateFrame("Frame", nil, parent) hdr:SetHeight(SECTION_HEADER_H) local fs = hdr:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - fs:SetPoint("LEFT", hdr, "LEFT", 6, 0) - fs:SetText(labelText or "") + fs:SetPoint("CENTER", hdr, "CENTER", 0, 0) fs:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) hdr.label = fs - local line = hdr:CreateTexture(nil, "ARTWORK") - line:SetHeight(1) - line:SetPoint("LEFT", fs, "RIGHT", 6, 0) - line:SetPoint("RIGHT", hdr, "RIGHT", -6, 0) - line:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.4) + local lineLeft = hdr:CreateTexture(nil, "ARTWORK") + lineLeft:SetHeight(1) + lineLeft:SetPoint("LEFT", hdr, "LEFT", 6, 0) + lineLeft:SetPoint("RIGHT", fs, "LEFT", -6, 0) + lineLeft:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.4) + local lineRight = hdr:CreateTexture(nil, "ARTWORK") + lineRight:SetHeight(1) + lineRight:SetPoint("LEFT", fs, "RIGHT", 6, 0) + lineRight:SetPoint("RIGHT", hdr, "RIGHT", -6, 0) + lineRight:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.4) return hdr end --- Section header pool (pinned / local / global) -local headerPool = {} -local function AcquireHeader(parent, labelText) +local sectionLabelPool = {} + +local function AcquireSectionLabel(parent, labelText) + local hdr + for i = 1, #sectionLabelPool do + local h = sectionLabelPool[i] + if not h:IsShown() then hdr = h; h:SetParent(parent); break end + end + if not hdr then + hdr = CreateSectionLabel(parent) + tinsert(sectionLabelPool, hdr) + end + hdr.label:SetText(labelText or "") + return hdr +end + +local function ReleaseAllSectionLabels() + for i = 1, #sectionLabelPool do sectionLabelPool[i]:Hide() end +end + +-- Group header mirrors the UI search's quest-log tab style: QuestLog-tab +-- atlas background, hover overlay, right-side +/- toggle button. Left +-- click on the body navigates to the header's linked zone (when one +-- was attached); clicking the +/- button toggles collapse. +local GROUP_HEADER_H = 28 + +local function CreateGroupHeader(parent) + local hdr = CreateFrame("Button", nil, parent) + hdr:SetHeight(GROUP_HEADER_H) + -- See CreateResultRow: activate on press to bypass focus-transition + -- mouseUp absorption. + hdr:RegisterForClicks("LeftButtonDown", "RightButtonDown") + hdr:HookScript("OnMouseDown", function() + if panel and panel.searchBox and panel.searchBox.HasFocus and panel.searchBox:HasFocus() then + panel.searchBox:ClearFocus() + end + end) + + local bg = hdr:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints() + bg:SetAtlas("QuestLog-tab") + hdr.bg = bg + + local hoverOverlay = hdr:CreateTexture(nil, "ARTWORK", nil, -1) + hoverOverlay:SetAllPoints() + hoverOverlay:SetAtlas("QuestLog-tab") + hoverOverlay:SetBlendMode("ADD") + hoverOverlay:SetAlpha(0.40) + hoverOverlay:Hide() + hdr.hoverOverlay = hoverOverlay + + local toggleBtn = CreateFrame("Button", nil, hdr) + toggleBtn:SetSize(26, 25) + toggleBtn:SetPoint("RIGHT", hdr, "RIGHT", -8, 0) + toggleBtn:SetFrameLevel(hdr:GetFrameLevel() + 2) + toggleBtn:RegisterForClicks("LeftButtonDown") + toggleBtn:HookScript("OnMouseDown", function() + if panel and panel.searchBox and panel.searchBox.HasFocus and panel.searchBox:HasFocus() then + panel.searchBox:ClearFocus() + end + end) + -- Expand hit rect so near-miss clicks still register on the toggle + -- instead of falling through to the header body's navigate action. + toggleBtn:SetHitRectInsets(-10, -10, -6, -6) + local toggleBtnBg = toggleBtn:CreateTexture(nil, "ARTWORK") + toggleBtnBg:SetAllPoints() + toggleBtnBg:SetTexture(796424) + toggleBtnBg:Hide() + toggleBtn.btnBg = toggleBtnBg + local toggleIcon = toggleBtn:CreateTexture(nil, "OVERLAY") + toggleIcon:SetSize(18, 17) + toggleIcon:SetPoint("CENTER") + toggleIcon:SetAtlas("QuestLog-icon-expand") + toggleBtn.icon = toggleIcon + toggleBtn:SetHighlightTexture(130757) + toggleBtn:SetScript("OnEnter", function(self) + self.btnBg:Show() + hdr.hoverOverlay:Show() + end) + toggleBtn:SetScript("OnLeave", function(self) + self.btnBg:Hide() + if not hdr:IsMouseOver() then hdr.hoverOverlay:Hide() end + end) + hdr.toggleBtn = toggleBtn + + local fs = hdr:CreateFontString(nil, "OVERLAY", "Game15Font_Shadow") + fs:SetPoint("LEFT", hdr, "LEFT", 10, 0) + fs:SetPoint("RIGHT", toggleBtn, "LEFT", -4, 0) + fs:SetJustifyH("LEFT") + fs:SetMaxLines(1) + fs:SetTextColor(0.60, 0.58, 0.55, 1.0) + hdr.label = fs + + hdr:SetScript("OnEnter", function(self) + self.hoverOverlay:Show() + self.label:SetTextColor(0.90, 0.88, 0.85, 1.0) + -- Group headers represent zone results (or pinned-zone roots) and + -- need the same hover preview as plain rows. Without this, hovering + -- a "Durotar" header while viewing Kalimdor produced no zone-area + -- highlight even though hovering a leaf row did. + if self.navigateData then HoverPreview(self.navigateData) end + end) + hdr:SetScript("OnLeave", function(self) + if not self.toggleBtn:IsMouseOver() then + self.hoverOverlay:Hide() + self.label:SetTextColor(self._matchColor and GOLD_COLOR[1] or 0.60, + self._matchColor and GOLD_COLOR[2] or 0.58, + self._matchColor and GOLD_COLOR[3] or 0.55, 1.0) + ClearHoverPreview() + end + end) + + return hdr +end + +-- onToggle: called when the +/- button is clicked. Defaults to +-- mutating sessionCollapsed[groupKey] for query-scoped collapse state; +-- pinned-parent headers override with a callback that mutates the +-- pin's stored `collapsed` flag instead. +-- onRightClick: called when the header body is right-clicked. Used to +-- open the pin/unpin popup for the header's navigateData. +local function AcquireGroupHeader(parent, labelText, groupKey, count, collapsed, navigateData, hasChildren, onToggle, onRightClick) + local hdr for i = 1, #headerPool do local h = headerPool[i] - if not h:IsShown() then - h:SetParent(parent) - h.label:SetText(labelText or "") - return h - end + if not h:IsShown() then hdr = h; h:SetParent(parent); break end + end + if not hdr then + hdr = CreateGroupHeader(parent) + tinsert(headerPool, hdr) + end + hdr.label:SetText(labelText or "") + hdr.toggleBtn.icon:SetAtlas(collapsed and "QuestLog-icon-expand" or "QuestLog-icon-shrink") + -- Hide the toggle button entirely when there are no children to + -- expand — clicking it would otherwise look broken. + hdr.toggleBtn:SetShown(hasChildren and true or false) + hdr.groupKey = groupKey + hdr.navigateData = navigateData + hdr._matchColor = navigateData ~= nil + if navigateData then + hdr.label:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) + else + hdr.label:SetTextColor(0.60, 0.58, 0.55, 1.0) end - local h = CreateSectionHeader(parent, labelText) - tinsert(headerPool, h) - return h + hdr.toggleBtn:SetScript("OnClick", function(_) + if onToggle then + onToggle() + elseif hdr.groupKey then + sessionCollapsed[hdr.groupKey] = not sessionCollapsed[hdr.groupKey] + RefreshCurrentSearch() + end + end) + hdr:SetScript("OnClick", function(self, button) + -- Body click ONLY navigates — never toggles. Skip entirely if + -- the toggle button is under the cursor (WoW has occasionally + -- surprising click routing with nested clickable children). + if self.toggleBtn and self.toggleBtn:IsMouseOver() then return end + if button == "RightButton" then + if onRightClick then onRightClick() end + return + end + if self.navigateData then + TriggerResultSelect(self.navigateData) + end + end) + return hdr end local function ReleaseAllHeaders() for i = 1, #headerPool do headerPool[i]:Hide() end + ReleaseAllSectionLabels() end --- =================================================================== --- Render pipeline --- =================================================================== - +-- --------------------------------------------------------------------------- +-- Render +-- --------------------------------------------------------------------------- +-- Returns the stored SavedVariables pin list directly (not a copy) so +-- mutations to fields like `collapsed` on a pinned parent persist. local function BuildPinnedSection() local pins = EasyFind.db.pinnedMapItems if not pins or #pins == 0 then return nil end - local list = { { isPinHeader = true, name = "Pinned" } } - if not EasyFind.db.mapPinsCollapsed then - for _, pin in ipairs(pins) do - local copy = {} - for k, v in pairs(pin) do copy[k] = v end - copy.isPinned = true - list[#list + 1] = copy + return pins +end + +-- Group a result list by pathPrefix (immediate parent zone name). +-- Groups with <2 members render flat (pathPrefix stays inline); groups +-- with 2+ members promote to a collapsible parent header with indented +-- children (pathPrefix suppressed on the children). +-- Returns an ordered array: { {type="flat", data=r} | {type="group", name=p, items={...}} } +local GROUP_THRESHOLD = 2 + +local function GroupBySharedParent(results) + if not results or #results == 0 then return {} end + local order, groups = {}, {} + for i = 1, #results do + local r = results[i] + local rMapID = r.mapID or r.zoneMapID or r.entranceMapID or r.parentMapID + local groupName, groupMapID = GetTopAncestor(rMapID) + if not groupName or groupName == "" then + order[#order + 1] = { type = "flat", data = r } + else + local g = groups[groupName] + if not g then + g = { + type = "group", name = groupName, items = {}, + ancestorMapID = groupMapID, navigateData = nil, + } + groups[groupName] = g + order[#order + 1] = g + end + if r.name == groupName then + -- Result IS the parent zone itself: attach as the header's + -- navigation target instead of rendering a duplicate row. + g.navigateData = r + else + g.items[#g.items + 1] = r + end end end - return list + -- Synthesize navigateData for groups whose parent zone wasn't itself + -- in the result set (e.g. "Northrend" filtered from local as a + -- Continent). The header still needs something to click-navigate to. + for _, e in ipairs(order) do + if e.type == "group" and not e.navigateData and e.ancestorMapID then + e.navigateData = { + name = e.name, + category = "zone", + isZone = true, + zoneMapID = e.ancestorMapID, + synthesized = true, + } + end + end + -- Demotion rule: a group keeps its header whenever it has a parent + -- zone (navigateData) — even with zero children in the current + -- result set — so a continent with children in the world hierarchy + -- never displays as a solitary flat row. Single-child groups + -- without a matching parent still collapse to flat so standalone + -- leaves don't gain a useless wrapping header. + for i = 1, #order do + local e = order[i] + if e.type == "group" and not e.navigateData and #e.items == 1 then + order[i] = { type = "flat", data = e.items[1] } + end + end + -- Second-level: within each continent group, sub-bucket items by the + -- direct-child zone of the continent that contains them. A real zone + -- result (category="zone") becomes the subgroup header; everything + -- else (FMs, vendors, etc.) becomes children indented under it. + -- Items whose parent chain doesn't pass through any direct child of + -- the continent stay loose at the continent level. + for _, e in ipairs(order) do + if e.type == "group" and e.items and #e.items > 0 and e.ancestorMapID then + local subgroups, subgroupOrder, looseItems = {}, {}, {} + for _, item in ipairs(e.items) do + local itemMapID = item.mapID or item.zoneMapID or item.entranceMapID or item.parentMapID + local zoneName, zoneMapID = GetZoneUnderAncestor(itemMapID, e.ancestorMapID) + if zoneName then + local sub = subgroups[zoneName] + if not sub then + sub = { name = zoneName, mapID = zoneMapID, items = {}, headerData = nil } + subgroups[zoneName] = sub + subgroupOrder[#subgroupOrder + 1] = sub + end + if itemMapID == zoneMapID and item.isZone and item.category == "zone" then + sub.headerData = item + else + sub.items[#sub.items + 1] = item + end + else + looseItems[#looseItems + 1] = item + end + end + -- Any subgroup with at least one child gets a collapsible + -- header (synthesized if the zone itself wasn't matched). + -- A zone match with zero children renders as a plain loose + -- row — no point in a collapsible header for nothing. + local keptOrder = {} + for _, sub in ipairs(subgroupOrder) do + if #sub.items == 0 and sub.headerData then + looseItems[#looseItems + 1] = sub.headerData + elseif #sub.items >= 1 then + if not sub.headerData then + sub.headerData = { + name = sub.name, + category = "zone", + isZone = true, + zoneMapID = sub.mapID, + synthesized = true, + } + end + keptOrder[#keptOrder + 1] = sub + end + end + if #keptOrder > 0 then + e.subgroups = keptOrder + e.looseItems = looseItems + end + end + end + return order end -local function RenderRows(scrollChild, pinned, localResults, globalResults) +local function RenderRows(scrollChild, pinned, localEntries, globalEntries, recentList) ReleaseAllRows() ReleaseAllHeaders() - + -- Reset keyboard-nav selection on every render. The underlying row + -- pool is recycled so a stale frame ref in visibleNavRows could + -- silently point at a frame that's been repositioned or released. + -- Also release navFrame's keyboard capture so we don't keep + -- swallowing keys when there's no highlighted row. + navRowIndex = 0 + wipe(visibleNavRows) + if navFrame then + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + end local y = 4 + local collapsedDb = sessionCollapsed - local function placeRow(data) + local function placeRow(data, indent, groupName) local row = AcquireRow(scrollChild) if not row then return end + local leftInset = 4 + (indent or 0) row:ClearAllPoints() - row:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 4, -y) + row:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", leftInset, -y) row:SetPoint("TOPRIGHT", scrollChild, "TOPRIGHT", -4, -y) row.data = data + if row.UnlockHighlight then row:UnlockHighlight() end + visibleNavRows[#visibleNavRows + 1] = row + if row.deleteBtn then row.deleteBtn:Hide() end SetRowIcon(row, data) local name = data.name or "" - if data.isPinHeader then - row.text:SetTextColor(0.7, 0.7, 0.7) - row.text:SetText((EasyFind.db.mapPinsCollapsed and "> " or "v ") .. name) - row.icon:Hide() + row.icon:Show() + if data.isZone then + row.text:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) else - row.icon:Show() - if data.isZone then - row.text:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) - else - row.text:SetTextColor(1, 1, 1) + row.text:SetTextColor(1, 1, 1) + end + do + local pathText = FormatPathPrefix(data.pathPrefix) or "" + -- Under a group header, strip the redundant group prefix so + -- a Dalaran row under "Northrend" shows only its immediate + -- parent without repeating the group name. + if groupName and pathText ~= "" then + local prefix = groupName .. " > " + if pathText:sub(1, #prefix) == prefix then + pathText = pathText:sub(#prefix + 1) + elseif pathText == groupName then + pathText = "" + end + end + -- Collapse to just the immediate parent name. Breadcrumb + -- chains ("Northrend > Crystalsong Forest") become + -- single-segment ("Crystalsong Forest") for a quieter row. + if pathText ~= "" then + local last = pathText:match("[^>]+$") + if last then pathText = last:gsub("^%s+", ""):gsub("%s+$", "") end end - if data.pathPrefix and data.pathPrefix ~= "" then - row.text:SetText(name .. " |cff808080" .. data.pathPrefix .. "|r") + if pathText ~= "" then + row.text:SetText(name .. " |cff808080" .. pathText .. "|r") else row.text:SetText(name) end @@ -448,8 +1084,8 @@ local function RenderRows(scrollChild, pinned, localResults, globalResults) y = y + ROW_HEIGHT end - local function placeHeader(labelText) - local hdr = AcquireHeader(scrollChild, labelText) + local function placeSectionLabel(text) + local hdr = AcquireSectionLabel(scrollChild, text) hdr:ClearAllPoints() hdr:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 4, -y) hdr:SetPoint("TOPRIGHT", scrollChild, "TOPRIGHT", -4, -y) @@ -457,161 +1093,1033 @@ local function RenderRows(scrollChild, pinned, localResults, globalResults) y = y + SECTION_HEADER_H end - if pinned and #pinned > 0 then - for _, data in ipairs(pinned) do placeRow(data) end + local function placeGroupHeader(name, groupKey, count, collapsed, navigateData, hasChildren, onToggle, onRightClick, indent) + local hdr = AcquireGroupHeader(scrollChild, name, groupKey, count, collapsed, navigateData, hasChildren, onToggle, onRightClick) + hdr:ClearAllPoints() + local leftInset = 4 + (indent or 0) + hdr:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", leftInset, -y) + hdr:SetPoint("TOPRIGHT", scrollChild, "TOPRIGHT", -4, -y) + if hdr.hoverOverlay then hdr.hoverOverlay:Hide() end + visibleNavRows[#visibleNavRows + 1] = hdr + hdr:Show() + y = y + GROUP_HEADER_H end - if localResults and #localResults > 0 then - placeHeader("In This Zone") - for _, data in ipairs(localResults) do placeRow(data) end + -- Right-click handler for regular result group headers: open the + -- pin popup bound to the header's zone (navigateData). Pinning a + -- parent here promotes it into the Pinned section on refresh, + -- carrying over the header's current collapsed state so the pinned + -- copy opens the same way the user was viewing it. + local function headerRightClick(navigateData, capturedCollapsed) + if not navigateData then return end + local MapSearch = ns.MapSearch + if not MapSearch then return end + local isPinned = MapSearch:IsMapItemPinned(navigateData) + ShowPopup(isPinned, function() + if isPinned then + MapSearch:UnpinMapItem(navigateData) + else + navigateData.collapsed = capturedCollapsed or nil + MapSearch:PinMapItem(navigateData) + navigateData.collapsed = nil + end + RefreshCurrentSearch() + end, function() TriggerResultSelect(navigateData, false) end) end - if globalResults and #globalResults > 0 then - placeHeader("Across Azeroth") - for _, data in ipairs(globalResults) do placeRow(data) end + local function renderEntries(entries, sectionKey) + for _, e in ipairs(entries) do + if e.type == "flat" then + placeRow(e.data, 0, nil) + else + local groupKey = sectionKey .. ":" .. e.name + local collapsed = collapsedDb[groupKey] == true + -- Two cases for what shows under a group: + -- (1) Parent itself matches the query (real, not + -- synthesized, navigateData): show ALL world- + -- hierarchy children. "I asked for Eastern + -- Kingdoms, give me the whole continent." + -- (2) Parent didn't match — group exists only because + -- multiple children matched (or one child + a + -- synthesized parent header for click-to-navigate): + -- show just the matched children. Surfacing every + -- sibling under EK because the user typed "fp" + -- (matching Founder's Point inside EK) was wrong. + local items + local usingWorldChildren = false + local parentMatched = e.navigateData and not e.navigateData.synthesized + if parentMatched and e.ancestorMapID then + local world = GetWorldChildren(e.ancestorMapID) + items = world or e.items + usingWorldChildren = world and #world > 0 + else + items = e.items + end + local hasChildren = items and #items > 0 + local nav = e.navigateData + local capturedCollapsed = collapsed + local onRClick = nav and function() headerRightClick(nav, capturedCollapsed) end or nil + placeGroupHeader(e.name, groupKey, nil, collapsed, nav, hasChildren, nil, onRClick) + if hasChildren and not collapsed then + if not usingWorldChildren and e.subgroups then + for _, sub in ipairs(e.subgroups) do + local subKey = groupKey .. ":" .. sub.name + local subCollapsed = collapsedDb[subKey] == true + local subHasChildren = #sub.items > 0 + local subNav = sub.headerData + local subCaptured = subCollapsed + local subRClick = subNav and function() headerRightClick(subNav, subCaptured) end or nil + placeGroupHeader(sub.name, subKey, nil, subCollapsed, subNav, + subHasChildren, nil, subRClick, 18) + if subHasChildren and not subCollapsed then + for _, item in ipairs(sub.items) do + placeRow(item, 36, sub.name) + end + end + end + if e.looseItems then + for _, item in ipairs(e.looseItems) do + placeRow(item, 18, e.name) + end + end + else + for _, item in ipairs(items) do + placeRow(item, 18, e.name) + end + end + end + end + end end + if recentList and #recentList > 0 then + placeSectionLabel("Recent Searches") + for _, query in ipairs(recentList) do + local row = AcquireRow(scrollChild) + if row then + row:ClearAllPoints() + row:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 4, -y) + row:SetPoint("TOPRIGHT", scrollChild, "TOPRIGHT", -4, -y) + if row.UnlockHighlight then row:UnlockHighlight() end + visibleNavRows[#visibleNavRows + 1] = row + row.data = { + isRecentSearch = true, + query = query, + name = query, + icon = "atlas:common-search-magnifyingglass", + } + SetRowIcon(row, row.data) + row.icon:Show() + if row.deleteBtn then row.deleteBtn:Hide() end + row.text:SetTextColor(0.85, 0.85, 0.85) + row.text:SetText(query) + -- Match the magnifying glass to the text's rendered + -- height so the icon's top/bottom line up with the text + -- rather than overshooting like the result-row icons. + local textH = row.text:GetStringHeight() or 0 + if textH < 8 then textH = 12 end + row.icon:SetSize(textH, textH) + row:Show() + y = y + ROW_HEIGHT + end + end + end + if pinned and #pinned > 0 then + placeSectionLabel("Pinned") + for _, d in ipairs(pinned) do + local renderAsParent = d.isZone and d.zoneMapID + local children = renderAsParent and GetWorldChildren(d.zoneMapID) or nil + if renderAsParent and children and #children > 0 then + local pinRef = d + local collapsed = pinRef.collapsed == true + local onToggle = function() + pinRef.collapsed = not collapsed + RefreshCurrentSearch() + end + local onRightClick = function() + local MapSearch = ns.MapSearch + if not MapSearch then return end + ShowPopup(true, function() + MapSearch:UnpinMapItem(pinRef) + RefreshCurrentSearch() + end, function() TriggerResultSelect(pinRef, false) end) + end + placeGroupHeader(pinRef.name, "pinned:" .. pinRef.zoneMapID, + nil, collapsed, pinRef, true, onToggle, onRightClick) + if not collapsed then + for _, child in ipairs(children) do + placeRow(child, 18, pinRef.name) + end + end + else + placeRow(d, 0, nil) + end + end + end + if localEntries and #localEntries > 0 then + local currentMapID = WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID() + local currentMapInfo = currentMapID and C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(currentMapID) + local sectionLabel = currentMapInfo and ("This Zone (" .. currentMapInfo.name .. ")") or "This Zone" + placeSectionLabel(sectionLabel) + renderEntries(localEntries, "local") + end + if globalEntries and #globalEntries > 0 then + placeSectionLabel("Across the World") + renderEntries(globalEntries, "global") + end scrollChild:SetHeight(math.max(1, y + 4)) end --- =================================================================== --- Search runner --- =================================================================== +-- mapType values that, when a ZONE result belongs to them, should not +-- appear in the "This Zone" local section. These are too coarse to be +-- meaningfully "local" (e.g. when the user is on the Azeroth world map, +-- Northrend is technically a direct child but is its own continent). +local EXCLUDE_FROM_LOCAL_MAPTYPES = {} +if Enum and Enum.UIMapType then + EXCLUDE_FROM_LOCAL_MAPTYPES[Enum.UIMapType.Cosmic] = true + EXCLUDE_FROM_LOCAL_MAPTYPES[Enum.UIMapType.World] = true + EXCLUDE_FROM_LOCAL_MAPTYPES[Enum.UIMapType.Continent] = true +end + +-- Filter by mapTabFilters (category bucket) and strip duplicates that +-- already appear in a previous list. Preserves result ordering from +-- BuildResults. When isLocal is true, also excludes continent-level and +-- broader zone results so the "This Zone" section doesn't list entire +-- continents as local content. +local function FilterAndDedupe(results, seen, isLocal) + local MapSearch = ns.MapSearch + local getBucket = MapSearch and MapSearch.GetFilterBucket + local filters = EasyFind.db.mapTabFilters or {} + local out = {} + if not results then return out end + for i = 1, #results do + local r = results[i] + if r and not r.isPinHeader then + local excludeLocal = isLocal and r.isZone and r.zoneMapType + and EXCLUDE_FROM_LOCAL_MAPTYPES[r.zoneMapType] + if not excludeLocal then + -- FMs are now scanned both locally (current map) and globally + -- (every zone). The local scan uses the viewed map's coords + -- while the global scan uses each home zone's own coords — + -- so the same FM has different (x,y) in each set. Key FMs + -- by name alone so the local pass claims it first and the + -- global pass dedupes cleanly. + local key + if r.category == "flightmaster" then + key = "fm:" .. (r.name or "") + else + key = (r.mapID or 0) .. ":" .. (r.name or "") .. ":" .. (r.x or 0) .. ":" .. (r.y or 0) + end + if not seen[key] then + seen[key] = true + local bucket = getBucket and getBucket(r) or "other" + if filters[bucket] ~= false then + out[#out + 1] = r + end + end + end + end + end + return out +end + +-- Walk the parent chain of mapID and return an array of the names at +-- every level (lowercased). Used to let a token like "northrend" match +-- any POI whose ancestor chain includes Northrend, so "northrend raid" +-- picks out raids scoped to that continent. +local ancestorNamesCache = {} +local function GetAncestorNames(mapID) + if not mapID or mapID == 0 then return {} end + local cached = ancestorNamesCache[mapID] + if cached then return cached end + local names = {} + local current = mapID + for _ = 1, 20 do + local info = C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(current) + if not info then break end + if info.name and info.name ~= "" then + names[#names + 1] = info.name:lower() + end + local parentID = info.parentMapID + if not parentID or parentID == 0 then break end + current = parentID + end + ancestorNamesCache[mapID] = names + return names +end + +-- Expand a token through the shared zone-abbreviation table so shortcut +-- queries like "nr" resolve to "northrend" during multi-token matching. +-- Returns the expansion string (lowercase) or nil if no abbreviation +-- exists. Cached lookup — the table is a plain dict. +local function ExpandZoneAbbrev(t) + local tbl = ns.MapSearch and ns.MapSearch.ZONE_ABBREVIATIONS + if not tbl then return nil end + return tbl[t] +end + +-- Does a single token match a POI via any available facet? Token must +-- already be lowercase. Checks name, keywords, category (with plural/ +-- singular flex), pathPrefix, ancestor zone names, and — for ancestors — +-- falls back to expanding the token through ZONE_ABBREVIATIONS so "nr" +-- resolves against "northrend". +local function POIMatchesToken(poi, t) + if poi.name and poi.name:lower():find(t, 1, true) then return true end + if poi.keywords then + for i = 1, #poi.keywords do + if poi.keywords[i]:lower():find(t, 1, true) then return true end + end + end + if poi.category then + local c = poi.category:lower() + if c == t or c == t .. "s" then return true end + if #t > 1 and t:sub(-1) == "s" and c == t:sub(1, -2) then return true end + end + if poi.pathPrefix and poi.pathPrefix:lower():find(t, 1, true) then return true end + local mapID = poi.mapID or poi.zoneMapID or poi.entranceMapID + local names = GetAncestorNames(mapID) + for i = 1, #names do + if names[i]:find(t, 1, true) then return true end + end + local expanded = ExpandZoneAbbrev(t) + if expanded then + if poi.name and poi.name:lower():find(expanded, 1, true) then return true end + for i = 1, #names do + if names[i]:find(expanded, 1, true) then return true end + end + end + return false +end + +-- Multi-token search: for each token, call BuildResults to get a broad +-- candidate set, union them by identity, then keep only POIs where every +-- token matches some facet. Enables queries like "northrend raid", +-- "org fp", "dalaran bank" where each word narrows the result set. +local function BuildMultiTokenResults(tokens, isGlobal) + local MapSearch = ns.MapSearch + if not MapSearch or not MapSearch.BuildResults then return {} end + local seenKeys = {} + local candidates = {} + for _, tok in ipairs(tokens) do + local perRef = MapSearch:BuildResults(tok, isGlobal, true) + for i = 1, #perRef do + local r = perRef[i] + local k = (r.mapID or 0) .. ":" .. (r.name or "") + .. ":" .. (r.x or 0) .. ":" .. (r.y or 0) + .. ":" .. (r.category or "") + if not seenKeys[k] then + seenKeys[k] = true + candidates[#candidates + 1] = r + end + end + end + local tokensLower = {} + for i, tok in ipairs(tokens) do tokensLower[i] = tok:lower() end + local out = {} + for _, r in ipairs(candidates) do + local matches = true + for i = 1, #tokensLower do + if not POIMatchesToken(r, tokensLower[i]) then + matches = false + break + end + end + if matches then out[#out + 1] = r end + end + return out +end function MapTab:RunSearch(text) if not panel then return end + text = text or "" + currentQuery = text lastQueryGen = lastQueryGen + 1 local myGen = lastQueryGen - local scrollChild = panel.scrollChild + local scrollFrame = panel.scrollFrame local MapSearch = ns.MapSearch local pinned = BuildPinnedSection() - if not text or #text < 2 then - -- No query: show pinned section only (if any) - if pinned then - RenderRows(scrollChild, pinned, nil, nil) - panel.emptyMsg:Hide() + -- Preserve scroll when the query is unchanged (refresh path: pin + -- toggles, group collapse, filter changes). Reset to the top on a + -- fresh query so new result sets always start from the first row. + local preserveScroll = scrollFrame and text == lastRenderedQuery + local savedScroll = preserveScroll and scrollFrame:GetVerticalScroll() or 0 + lastRenderedQuery = text + local function restoreScroll() + if not scrollFrame then return end + local maxScroll = math.max(0, (scrollChild:GetHeight() or 0) - (scrollFrame:GetHeight() or 0)) + scrollFrame:SetVerticalScroll(math.min(savedScroll, maxScroll)) + end + + -- Reset ephemeral collapse state when the query text changes so + -- each new search starts with every matched parent auto-expanded. + -- Same-text refreshes (e.g. toggle button → RefreshCurrentSearch) + -- preserve the state. + if text ~= sessionCollapsedQuery then + wipe(sessionCollapsed) + sessionCollapsedQuery = text + end + + if #text < 2 then + local showRecent = EasyFind.db.mapTabShowRecent + local recentList = showRecent and EasyFind.db.mapTabRecentSearches + local limit = MapTab.GetRecentLimit and MapTab.GetRecentLimit() or 3 + -- Respect the tunable display cap even if the saved list got + -- larger (e.g., user shrank the cap after it was already full). + if recentList and #recentList > limit then + local trimmed = {} + for i = 1, limit do trimmed[i] = recentList[i] end + recentList = trimmed + end + local hasRecent = recentList and #recentList > 0 + if pinned or hasRecent then + RenderRows(scrollChild, pinned, nil, nil, hasRecent and recentList or nil) else - ReleaseAllRows() - ReleaseAllHeaders() + ReleaseAllRows(); ReleaseAllHeaders() scrollChild:SetHeight(1) - panel.emptyMsg:SetText("|cff999999Start typing to search for POIs, zones, dungeons, and raids.|r") - panel.emptyMsg:Show() end + panel.emptyMsg:Hide() + panel.topResultName = nil + panel.topResultCandidates = nil + restoreScroll() return end - panel.emptyMsg:Hide() - if not MapSearch or not MapSearch.BuildResults then - ReleaseAllRows() - ReleaseAllHeaders() - return + ReleaseAllRows(); ReleaseAllHeaders(); return end - -- Run both local and global searches. Shallow-copy each list because - -- BuildResults uses module-level scratch tables that are wiped on the - -- next call. - local localResults = ShallowCopyList(MapSearch:BuildResults(text, false, true)) + -- Split query into whitespace-separated tokens; multi-token queries + -- switch to per-token matching with ancestor awareness so "northrend + -- raid" means "raids under Northrend" rather than a literal name + -- match on the full string. + local tokens = {} + for tok in text:gmatch("%S+") do tokens[#tokens + 1] = tok end + local multiToken = #tokens > 1 + + -- BuildResults returns a reference to a reusable module-level + -- table. The second call wipes it before refilling, so we must + -- shallow-copy the first result set before calling again. + local seen = {} + local localRaw + if multiToken then + localRaw = BuildMultiTokenResults(tokens, false) + else + local localRawRef = MapSearch:BuildResults(text, false, true) + localRaw = {} + for i = 1, #localRawRef do localRaw[i] = localRawRef[i] end + end if myGen ~= lastQueryGen then return end - local globalResults = ShallowCopyList(MapSearch:BuildResults(text, true, true)) + -- Local-first dedup (so a POI that's truly in the current zone + -- wins local ownership over global). Continent-level zone + -- results are filtered out of local via isLocal=true so they + -- don't pollute "This Zone" on a world/continent map. + local localFiltered = FilterAndDedupe(localRaw, seen, true) + local localEntries = GroupBySharedParent(localFiltered) + local globalRaw + if multiToken then + globalRaw = BuildMultiTokenResults(tokens, true) + else + globalRaw = MapSearch:BuildResults(text, true, true) + end if myGen ~= lastQueryGen then return end + local globalFiltered = FilterAndDedupe(globalRaw, seen, false) + local globalEntries = GroupBySharedParent(globalFiltered) + + -- Stash top N result names (locals first, then globals) for + -- autocomplete. Ghost/Tab picks the first name whose lowercase + -- prefix matches the typed text — so a fuzzy-winning non-prefix + -- match doesn't prevent a slightly-lower-scored prefix match from + -- being suggested. Covers cases like typing "dragon i" when the + -- top scorer is "Dragonblight" (no space) but "Dragon Isles" is + -- further down. + local CANDIDATE_CAP = 12 + local candidates = {} + if localFiltered then + for i = 1, math.min(#localFiltered, CANDIDATE_CAP) do + if localFiltered[i] and localFiltered[i].name then + candidates[#candidates + 1] = localFiltered[i].name + end + end + end + for i = 1, math.min(#globalFiltered, CANDIDATE_CAP) do + if globalFiltered[i] and globalFiltered[i].name then + candidates[#candidates + 1] = globalFiltered[i].name + end + end + panel.topResultCandidates = candidates + panel.topResultName = candidates[1] - RenderRows(scrollChild, pinned, localResults, globalResults) + RenderRows(scrollChild, pinned, localEntries, globalEntries) if (not pinned or #pinned == 0) - and (not localResults or #localResults == 0) - and (not globalResults or #globalResults == 0) then + and (not localFiltered or #localFiltered == 0) and #globalFiltered == 0 then panel.emptyMsg:SetText("|cff999999No matches.|r") panel.emptyMsg:Show() end + restoreScroll() end --- =================================================================== --- Panel state --- =================================================================== +-- --------------------------------------------------------------------------- +-- Keyboard navigation helpers. Wiring: the search box captures arrow / +-- Ctrl+J/K / Enter / Esc while focused and consumes them, falling back +-- to typing for anything else. When the user navigates down from the +-- search box, focus drops onto navFrame, which captures j/k/Enter/Esc +-- directly so the user can keep navigating without reclaiming keyboard +-- focus on the editbox. Re-typing or pressing Esc hands focus back. +-- --------------------------------------------------------------------------- + +-- Forward declaration: EnsureNavFrame's OnKeyDown closure captures +-- HandleNavKey as an upvalue, but HandleNavKey is defined below because +-- it in turn references SetNavRowIndex / MoveNavSelection that are +-- declared between the two. +local HandleNavKey + +local navKeyRepeat +local function EnsureNavFrame() + if navFrame then return navFrame end + if not panel then return nil end + navFrame = CreateFrame("Frame", nil, panel) + navFrame:EnableKeyboard(false) + navFrame:SetPropagateKeyboardInput(true) + navFrame:SetScript("OnKeyDown", function(self, key) + if HandleNavKey(key, false) then + self:SetPropagateKeyboardInput(false) + else + self:SetPropagateKeyboardInput(true) + end + end) + navFrame:SetScript("OnKeyUp", function(_, key) + if navKeyRepeat and navKeyRepeat.IsKey(key) then + navKeyRepeat.Stop(key) + end + end) + navKeyRepeat = Utils.CreateKeyRepeat(navFrame) + return navFrame +end -local function ShowOurPanel() - local qmf = _G["QuestMapFrame"] - if not qmf or not panel then return end - selectedIsOurs = true - HideBlizzPanels(qmf) - panel:Show() - RefreshSelectGlows() - -- Don't auto-focus the search box; clicking the tab just swaps content. - -- User can click the box when they want to type. - MapTab:RunSearch(panel.searchBox and panel.searchBox:GetText() or "") +local function UpdateNavHighlight() + for i = 1, #visibleNavRows do + local f = visibleNavRows[i] + if f then + local selected = (i == navRowIndex) + -- Group headers (hoverOverlay present): mirror mouse hover + -- exactly — show the same hoverOverlay atlas and brighten + -- the label. Same pattern UI.lua uses for its headerTab. + if f.hoverOverlay then + f.hoverOverlay:SetShown(selected) + if f.label then + if selected then + f.label:SetTextColor(0.90, 0.88, 0.85, 1.0) + elseif not f:IsMouseOver() + and not (f.toggleBtn and f.toggleBtn:IsMouseOver()) then + local g = f._matchColor + f.label:SetTextColor( + g and GOLD_COLOR[1] or 0.60, + g and GOLD_COLOR[2] or 0.58, + g and GOLD_COLOR[3] or 0.55, 1.0) + end + end + elseif f.LockHighlight then + -- Leaf row: reuse the button's built-in hover texture + -- (set via SetHighlightAtlas in CreateResultRow) so + -- keyboard and mouse share one texture. + if selected then f:LockHighlight() else f:UnlockHighlight() end + end + end + end + if navRowIndex > 0 and panel and panel.scrollFrame then + local f = visibleNavRows[navRowIndex] + if f then Utils.ScrollToButton(panel.scrollFrame, f) end + end + -- Map preview mirrors the mouse-hover path: whichever row the + -- user is focused on (keyboard or mouse) pops its waypoint/zone + -- highlight on the world map. Keyboard passes fromKeyboard=true to + -- bypass the typing-guard that suppresses spurious OnEnter events. + if navRowIndex > 0 then + local f = visibleNavRows[navRowIndex] + local data = f and (f.data or f.navigateData) + if data then + HoverPreview(data, true) + else + ClearHoverPreview() + end + else + ClearHoverPreview() + end end -local function HideOurPanel() - if not selectedIsOurs then return end - selectedIsOurs = false - if panel then - panel:Hide() - if panel.searchBox then panel.searchBox:ClearFocus() end +local function SetNavFrameCapture(on) + local nf = EnsureNavFrame() + if not nf then return end + Utils.SafeCallMethod(nf, "EnableKeyboard", on and true or false) +end + +local function SetNavRowIndex(i) + if i < 0 then i = 0 end + if i > #visibleNavRows then i = #visibleNavRows end + navRowIndex = i + UpdateNavHighlight() +end + +local function MoveNavSelection(delta) + if #visibleNavRows == 0 then return end + local newIdx = navRowIndex + delta + if newIdx < 1 then newIdx = 1 end + if newIdx > #visibleNavRows then newIdx = #visibleNavRows end + SetNavRowIndex(newIdx) +end + +local function ActivateNavSelection() + if navRowIndex == 0 then return false end + local f = visibleNavRows[navRowIndex] + if not f then return false end + local handler = f:GetScript("OnClick") + if handler then + handler(f, "LeftButton") + return true end - local qmf = _G["QuestMapFrame"] - if qmf then RestoreBlizzPanels(qmf) end - ClearHoverPreview() - RefreshSelectGlows() + return false end --- =================================================================== --- Search box / filter cog --- =================================================================== +-- Handle a nav key arriving from either the editbox OnKeyDown (search +-- box focused) or the navFrame OnKeyDown (search box unfocused). Returns +-- true if the key was consumed. `keepSearchFocus` is true when the call +-- site is the editbox; false when it's the navFrame. +HandleNavKey = function(key, keepSearchFocus) + local ctrl = IsControlKeyDown() + if key == "DOWN" or (ctrl and key == "J") then + if #visibleNavRows == 0 then return false end + -- Keying into results always drops editbox focus and hands + -- keyboard capture to navFrame, so subsequent keys move the + -- selection without retyping in the box. Applies to both + -- arrow and Ctrl+J — they behave identically. + if keepSearchFocus and panel and panel.searchBox then + panel.searchBox:ClearFocus() + end + SetNavFrameCapture(true) + -- Hold-to-step via the shared key-repeat helper. Start fires + -- the action immediately, then again after initialDelay, + -- accelerating toward fastDelay the longer the key is held. + if navKeyRepeat then + navKeyRepeat.Start(key, function() MoveNavSelection(1) end) + else + MoveNavSelection(1) + end + return true + elseif key == "UP" or (ctrl and key == "K") then + if #visibleNavRows == 0 then return false end + -- Up from the first row (or from no selection): exit back to + -- the search box so the user can resume typing symmetrically + -- with how Down enters the results from the editbox. + if navRowIndex <= 1 then + SetNavRowIndex(0) + SetNavFrameCapture(false) + if panel and panel.searchBox then panel.searchBox:SetFocus() end + return true + end + if keepSearchFocus and panel and panel.searchBox then + panel.searchBox:ClearFocus() + end + SetNavFrameCapture(true) + if navKeyRepeat then + navKeyRepeat.Start(key, function() MoveNavSelection(-1) end) + else + MoveNavSelection(-1) + end + return true + elseif key == "SPACE" then + -- Space inside the search box is a literal character and must + -- not be consumed. While the navFrame is active (focus on a + -- results row), Space toggles the highlighted group header's + -- collapse. On a leaf row it does nothing and is still + -- consumed so it doesn't leak through to a player keybind. + if keepSearchFocus then return false end + if navRowIndex > 0 then + local f = visibleNavRows[navRowIndex] + if f and f.toggleBtn and f.toggleBtn:IsShown() then + -- The toggle click triggers a full RefreshCurrentSearch + -- which wipes visibleNavRows and navRowIndex. Snapshot + -- the header's groupKey, fire the click, then re-find + -- the same header in the freshly-rendered set so the + -- user can spam Space to collapse/expand repeatedly + -- without losing their selection. + local savedGroupKey = f.groupKey + local handler = f.toggleBtn:GetScript("OnClick") + if handler then handler(f.toggleBtn) end + if savedGroupKey then + for i = 1, #visibleNavRows do + if visibleNavRows[i].groupKey == savedGroupKey then + SetNavRowIndex(i) + SetNavFrameCapture(true) + break + end + end + end + end + return true + end + return false + elseif key == "ENTER" then + if navRowIndex > 0 then + ActivateNavSelection() + return true + end + return false + elseif key == "ESCAPE" then + if not keepSearchFocus then + -- In navFrame: first Esc clears selection and refocuses + -- search. A second Esc (with nothing highlighted) propagates + -- to WoW so the map closes. + if navRowIndex > 0 then + SetNavRowIndex(0) + SetNavFrameCapture(false) + if panel and panel.searchBox then panel.searchBox:SetFocus() end + return true + end + return false + else + -- In editbox: drop keyboard focus but keep text + results + -- visible so the player can still see what they searched. + if panel and panel.searchBox then panel.searchBox:ClearFocus() end + return true + end + end + return false +end +-- --------------------------------------------------------------------------- +-- Search box + filter cog (styled like QuestScrollFrame.SearchBox + +-- SettingsDropdown) +-- --------------------------------------------------------------------------- local function CreateSearchBox(parent) - local editBox = CreateFrame("EditBox", nil, parent) + -- Use Blizzard's SearchBoxTemplate so chrome, magnifying glass, and + -- clear button are pixel-identical to the Quest Log search bar. + local editBox = CreateFrame("EditBox", nil, parent, "SearchBoxTemplate") editBox:SetSize(301, 20) - editBox:SetFontObject("GameFontHighlightSmall") editBox:SetAutoFocus(false) editBox:SetMaxLetters(60) - editBox:SetTextInsets(22, 8, 2, 2) - - local left = editBox:CreateTexture(nil, "BACKGROUND") - left:SetAtlas("common-search-border-left", false) - left:SetSize(8, 20) - left:SetPoint("LEFT") + if editBox.Instructions then + editBox.Instructions:SetText("Search for POIs, zones, instances...") + end - local right = editBox:CreateTexture(nil, "BACKGROUND") - right:SetAtlas("common-search-border-right", false) - right:SetSize(7, 20) - right:SetPoint("RIGHT") + local function UpdateClear(self) + if self.clearButton then + self.clearButton:SetShown(self:HasFocus() or self:GetText() ~= "") + end + end - local mid = editBox:CreateTexture(nil, "BACKGROUND") - mid:SetAtlas("common-search-border-middle", false) - mid:SetPoint("LEFT", left, "RIGHT") - mid:SetPoint("RIGHT", right, "LEFT") - mid:SetHeight(20) + -- SearchBoxTemplate's built-in clear button only clears the text + + -- focus. Without this hook the result list would re-render in its + -- "empty query" state (pinned + recent), which feels like the + -- clear didn't take. Wipe everything visible so the results frame + -- is truly empty after pressing X. + if editBox.clearButton then + editBox.clearButton:HookScript("OnClick", function() + if pendingSearchTimer then + pendingSearchTimer:Cancel(); pendingSearchTimer = nil + end + ReleaseAllRows() + ReleaseAllHeaders() + if panel and panel.scrollChild then panel.scrollChild:SetHeight(1) end + if panel and panel.emptyMsg then panel.emptyMsg:Hide() end + navRowIndex = 0 + if visibleNavRows then wipe(visibleNavRows) end + if navFrame then Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) end + end) + end - local icon = editBox:CreateTexture(nil, "OVERLAY") - icon:SetAtlas("common-search-magnifyingglass", false) - icon:SetSize(10, 10) - icon:SetPoint("LEFT", editBox, "LEFT", 8, 0) - icon:SetVertexColor(0.6, 0.6, 0.6, 1) + -- Walks panel.topResultCandidates in scoring order and returns the + -- first name whose lowercase prefix matches the query — lets + -- autocomplete fall back past a fuzzy winner that doesn't prefix- + -- match (e.g. "dragon i" skipping "Dragonblight" to suggest + -- "Dragon Isles"). + local function FindPrefixCandidate(q) + if not panel or q == "" then return nil end + local qLower = q:lower() + local list = panel.topResultCandidates + if not list then return nil end + for i = 1, #list do + local name = list[i] + if name and #name >= #q + and name:sub(1, #q):lower() == qLower + and name:lower() ~= qLower then + return name + end + end + return nil + end + editBox.FindPrefixCandidate = FindPrefixCandidate + + -- Inline autocomplete using SetText + HighlightText (Chrome-style). + -- The displayed text is the full suggestion, the cursor sits at the + -- end of the user's typed prefix, and the trailing suggestion is + -- selected. WoW's native caret renders at the cursor position with + -- nothing layered over it, so the caret is always visible at the + -- end of what the user typed. Tab confirms; typing replaces the + -- selection (advancing through the suggestion); backspace deletes + -- the selection without re-applying autocomplete. + -- + -- typedText: the user's actual typed prefix (search query) + -- programmatic: re-entrancy guard for our own SetText calls + -- lastWasAddition: true if the last text change extended the typed + -- prefix; gates whether we re-apply autocomplete + -- after the next RunSearch settles + local typedText = "" + local programmatic = false + local lastWasAddition = false + + local function StripAutocomplete() + local current = editBox:GetText() or "" + if current == typedText then return end + programmatic = true + editBox:SetText(typedText) + editBox:SetCursorPosition(#typedText) + editBox:HighlightText(0, 0) + programmatic = false + end - local placeholder = editBox:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") - placeholder:SetPoint("LEFT", icon, "RIGHT", 4, 0) - placeholder:SetPoint("RIGHT", editBox, "RIGHT", -4, 0) - placeholder:SetJustifyH("LEFT") - placeholder:SetText("Search for POIs, zones, instances...") - editBox.placeholder = placeholder + local function ApplyAutocomplete() + if programmatic or typedText == "" or not editBox:HasFocus() then + return + end + local candidate = FindPrefixCandidate(typedText) + if not candidate or candidate:lower() == typedText:lower() then + StripAutocomplete() + return + end + local suffix = candidate:sub(#typedText + 1):lower() + if suffix == "" then StripAutocomplete(); return end + local fullText = typedText .. suffix + if editBox:GetText() == fullText then + -- Already showing this suggestion. Just reassert the highlight + -- in case focus loss / regain dropped it. + editBox:SetCursorPosition(#typedText) + editBox:HighlightText(#typedText, #fullText) + return + end + programmatic = true + editBox:SetText(fullText) + editBox:SetCursorPosition(#typedText) + editBox:HighlightText(#typedText, #fullText) + programmatic = false + end + editBox.UpdateAutocomplete = ApplyAutocomplete + editBox.GetTypedText = function() return typedText end editBox:HookScript("OnTextChanged", function(self) - placeholder:SetShown(self:GetText() == "") - if pendingSearchTimer then pendingSearchTimer:Cancel() end - -- Debounce so we don't rebuild per keystroke - local snapshot = self:GetText() or "" - pendingSearchTimer = C_Timer.NewTimer(0.08, function() + if programmatic then return end + -- Hover-preview suppression: re-rendering rows under a stationary + -- cursor would otherwise fire spurious OnEnter events as the user + -- types. + lastTypeTime = GetTime() + UpdateClear(self) + + local current = self:GetText() or "" + local cursorPos = self:GetCursorPosition() + -- The "typed" prefix is everything up to the cursor; anything + -- after is autocomplete suffix the user hasn't accepted. + local typed = current:sub(1, cursorPos) + lastWasAddition = #typed > #typedText + typedText = typed + + if pendingSearchTimer then pendingSearchTimer:Cancel(); pendingSearchTimer = nil end + local snapshot = typed + pendingSearchTimer = C_Timer.NewTimer(0, function() + pendingSearchTimer = nil MapTab:RunSearch(snapshot) + -- Re-apply autocomplete only when the user added characters. + -- For deletions / no-ops, leave the text alone so backspace + -- actually removes the suggestion instead of re-conjuring it. + if lastWasAddition then ApplyAutocomplete() end + lastWasAddition = false end) end) - editBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + editBox:HookScript("OnEditFocusGained", UpdateClear) + editBox:HookScript("OnEditFocusLost", function(self) + UpdateClear(self) + -- Strip any pending autocomplete suffix so the editbox shows the + -- user's actual typed text when unfocused. + StripAutocomplete() + end) + + -- Keyboard nav: consume arrow / Ctrl+J/K / Esc while the editbox + -- is focused. WoW editboxes default to propagating every keystroke + -- to the binding system, which fires player keybinds while the user + -- is typing — so we unconditionally clamp propagation to false at + -- the end. ENTER routes through HandleNavKey, which activates a + -- highlighted row or falls through to push-to-recents below. + editBox:HookScript("OnKeyDown", function(self, key) + HandleNavKey(key, true) + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + end) + + -- Force focus on click. SearchBoxTemplate doesn't always grab focus + -- back cleanly after the user has been clicking around in result + -- rows or hovering pins on the map; the explicit SetFocus + nav + -- keyboard release here makes the caret reappear every time. + editBox:HookScript("OnMouseDown", function(self) + self:SetFocus() + if navFrame then + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + end + navRowIndex = 0 + end) + editBox:HookScript("OnEditFocusGained", function() + if navFrame then + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + end + end) + editBox:HookScript("OnEnterPressed", function(self) + -- If a row is highlighted, HandleNavKey already activated it on + -- OnKeyDown(ENTER) and we shouldn't also commit to recents. + if navRowIndex > 0 then return end + -- Enter accepts any active autocomplete suggestion the same way + -- Tab does, then commits the full text. + local current = self:GetText() or "" + if current ~= "" and current ~= typedText then + programmatic = true + self:SetCursorPosition(#current) + self:HighlightText(0, 0) + programmatic = false + typedText = current + end + MapTab:PushRecentSearch(current) + self:ClearFocus() + end) + -- Tab confirms the highlighted autocomplete: cursor jumps to the end + -- of the full text and the highlight is cleared, locking in the + -- suggestion. The typed prefix is updated to match the now-accepted + -- text so subsequent typing extends it cleanly. + editBox:HookScript("OnTabPressed", function(self) + local current = self:GetText() or "" + if current == "" or current == typedText then return end + programmatic = true + self:SetCursorPosition(#current) + self:HighlightText(0, 0) + programmatic = false + typedText = current + MapTab:PushRecentSearch(current) + end) return editBox end +-- Push a query into the recent-searches list. Deduped (case-insensitive), +-- most-recent-first, capped at RECENT_MAX. Persists via SavedVariables. +local RECENT_ABSOLUTE_MAX = 20 +local function GetRecentLimit() + local n = EasyFind.db and EasyFind.db.mapTabRecentCount + if type(n) ~= "number" or n < 1 then n = 3 end + if n > RECENT_ABSOLUTE_MAX then n = RECENT_ABSOLUTE_MAX end + return n +end +MapTab.GetRecentLimit = GetRecentLimit + +-- Re-render the Map Tab if its panel is currently visible. Used by +-- Options callbacks that mutate settings affecting the rendered list +-- (e.g., recent-search count, show-recent toggle) so the change is +-- reflected without waiting for the next keystroke or map change. +function MapTab:RefreshIfOpen() + if panel and panel:IsShown() and RefreshCurrentSearch then + RefreshCurrentSearch() + end +end + +function MapTab:PushRecentSearch(text) + if type(text) ~= "string" then return end + text = text:match("^%s*(.-)%s*$") or "" + if #text < 2 then return end + local db = EasyFind.db + if not db then return end + db.mapTabRecentSearches = db.mapTabRecentSearches or {} + local list = db.mapTabRecentSearches + local lowerNew = text:lower() + for i = #list, 1, -1 do + if type(list[i]) == "string" and list[i]:lower() == lowerNew then + tremove(list, i) + end + end + tinsert(list, 1, text) + local limit = GetRecentLimit() + while #list > limit do tremove(list) end +end + +local FILTER_OPTIONS = { + { key = "zones", label = "Zones" }, + { key = "instances", label = "Instances" }, + { key = "flightpath", label = "Flight Paths" }, + { key = "travel", label = "Travel" }, + { key = "services", label = "Services" }, + { key = "rares", label = "Rares" }, +} + +-- Attach the Auto-track Rares sub-row under the Rares filter. The row +-- appears only while the parent Rares filter is checked, and mirrors +-- `alwaysShowRares` in SavedVariables (shared with Options.lua). +local AUTO_TRACK_ROW_H = 18 +local function AttachAutoTrackRow(dropdown) + local raresRow + for _, row in ipairs(dropdown.rows) do + if row.optKey == "rares" then raresRow = row; break end + end + if not raresRow then return end + + local subRow = CreateFrame("CheckButton", nil, dropdown) + subRow:SetSize(raresRow:GetWidth() - 20, AUTO_TRACK_ROW_H) + subRow:SetPoint("TOPLEFT", raresRow, "BOTTOMLEFT", 20, 0) + subRow:SetHitRectInsets(0, 0, 0, 0) + subRow:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") + subRow:GetNormalTexture():SetSize(14, 14) + subRow:GetNormalTexture():ClearAllPoints() + subRow:GetNormalTexture():SetPoint("LEFT", 4, 0) + subRow:SetCheckedTexture("Interface\\Buttons\\UI-CheckBox-Check") + subRow:GetCheckedTexture():SetSize(14, 14) + subRow:GetCheckedTexture():ClearAllPoints() + subRow:GetCheckedTexture():SetPoint("LEFT", 4, 0) + + local label = subRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + label:SetPoint("LEFT", subRow:GetNormalTexture(), "RIGHT", 4, 0) + label:SetText("Auto-track") + + local hl = subRow:CreateTexture(nil, "HIGHLIGHT") + hl:SetAllPoints() + hl:SetColorTexture(1, 1, 1, 0.1) + + subRow:SetScript("OnClick", function(self) + EasyFind.db.alwaysShowRares = self:GetChecked() and true or false + local MapSearch = ns.MapSearch + if MapSearch and MapSearch.UpdateRareTracking then + MapSearch:UpdateRareTracking() + end + if ns.optionsFrame and ns.optionsFrame.rareTrackCheckbox then + ns.optionsFrame.rareTrackCheckbox:SetChecked(EasyFind.db.alwaysShowRares) + end + end) + subRow:SetScript("OnEnter", function(self) + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText("Auto-track Rares") + GameTooltip:AddLine("Keep active rares shown on the map continuously.", 1, 1, 1, true) + GameTooltip:Show() + end) + subRow:SetScript("OnLeave", GameTooltip_Hide) + + dropdown.autoTrackRow = subRow + local baseHeight = dropdown:GetHeight() + + function dropdown:UpdateAutoTrackRow() + local filters = EasyFind.db.mapTabFilters or {} + local raresOn = filters.rares ~= false + subRow:SetShown(raresOn) + self:SetHeight(baseHeight + (raresOn and AUTO_TRACK_ROW_H or 0)) + subRow:SetChecked(EasyFind.db.alwaysShowRares and true or false) + end + + dropdown:HookScript("OnShow", function(self) self:UpdateAutoTrackRow() end) + dropdown:UpdateAutoTrackRow() +end + local function CreateFilterCog(parent) local cog = CreateFrame("Button", nil, parent) cog:SetSize(14, 16) @@ -624,39 +2132,38 @@ local function CreateFilterCog(parent) hoverTex:SetAlpha(0.4) cog:SetScript("OnEnter", function(self) GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - GameTooltip:SetText("Filter options") - GameTooltip:AddLine("Category filters coming soon.", 0.7, 0.7, 0.7, true) + GameTooltip:SetText("Filter Results") + GameTooltip:AddLine("Toggle category filters.", 0.7, 0.7, 0.7, true) GameTooltip:Show() end) cog:SetScript("OnLeave", GameTooltip_Hide) return cog end --- =================================================================== --- Tab creation --- =================================================================== - +-- --------------------------------------------------------------------------- +-- Side tab button +-- --------------------------------------------------------------------------- local function CreateTabFrame(qmf) local tab = CreateFrame("Frame", "EasyFindMapSearchTab", qmf) - tab:SetSize(TAB_W, TAB_H) + -- Match the Blizzard side-tab size exactly by copying MapLegendTab. + -- WQT achieves this by inheriting LargeSideTabButtonTemplate; we + -- don't use the template (for control over our custom icon), so we + -- mirror its resolved size here. + local refW, refH = qmf.MapLegendTab:GetSize() + if not refW or refW == 0 then refW, refH = TAB_W, TAB_H end + tab:SetSize(refW, refH) tab:SetFrameStrata("HIGH") tab:SetFrameLevel(qmf.MapLegendTab:GetFrameLevel()) - -- Stack under MapLegendTab with a small vertical gap so the leather - -- edges of the two tabs don't collide. - tab:SetPoint("TOPLEFT", qmf.MapLegendTab, "BOTTOMLEFT", 0, TAB_STACK_GAP) + tab.displayMode = "EasyFindMapSearch" tab:EnableMouse(true) - -- Leather side piece. The atlas (51x59) is wider/taller than the - -- clickable area (42x55); centering places the overhang symmetrically - -- around the tab so it matches the visual extent of Blizzard's own - -- QuestsTab and MapLegendTab. + -- Background, select glow, and hover glow use Blizzard atlases at + -- their native sizes (useAtlasSize=true) so they match the side + -- tabs regardless of what hardcoded constants we might drift from. local bg = tab:CreateTexture(nil, "BACKGROUND") - bg:SetAtlas("QuestLog-tab-side", false) - bg:SetSize(TAB_BG_W, TAB_BG_H) + bg:SetAtlas("QuestLog-tab-side", true) bg:SetPoint("CENTER", tab, "CENTER", 0, 0) - -- Icon: common-search-magnifyingglass atlas tinted gold. RefreshSelectGlows - -- toggles between dim (inactive) and bright (active) gold. local icon = tab:CreateTexture(nil, "ARTWORK") icon:SetAtlas("common-search-magnifyingglass", false) icon:SetSize(TAB_ICON_SIZE, TAB_ICON_SIZE) @@ -665,15 +2172,13 @@ local function CreateTabFrame(qmf) tab._efIcon = icon local selectGlow = tab:CreateTexture(nil, "OVERLAY") - selectGlow:SetAtlas("QuestLog-Tab-side-Glow-Select", false) - selectGlow:SetSize(TAB_BG_W, TAB_BG_H) + selectGlow:SetAtlas("QuestLog-Tab-side-Glow-Select", true) selectGlow:SetPoint("CENTER", bg, "CENTER", 0, 0) selectGlow:Hide() tab._efSelectGlow = selectGlow local hoverGlow = tab:CreateTexture(nil, "HIGHLIGHT") - hoverGlow:SetAtlas("QuestLog-Tab-side-Glow-hover", false) - hoverGlow:SetSize(TAB_BG_W, TAB_BG_H) + hoverGlow:SetAtlas("QuestLog-Tab-side-Glow-hover", true) hoverGlow:SetPoint("CENTER", bg, "CENTER", 0, 0) tab:SetScript("OnMouseUp", function(_, button) @@ -690,65 +2195,139 @@ local function CreateTabFrame(qmf) return tab end --- =================================================================== --- Panel creation --- =================================================================== - +-- --------------------------------------------------------------------------- +-- Content panel: sibling of QuestsFrame / MapLegend / EventsFrame. +-- Replicates the Quests-tab chrome: paper backdrop, gold decorative +-- border, search bar + cog at top, scrollable content below. +-- --------------------------------------------------------------------------- local function CreatePanel(qmf) - -- Anchor to QuestsFrame (the right-column container that spans both - -- the header area where SearchBox sits and the paper scroll area). - -- This matches exactly what Blizzard's Quests panel covers, so we - -- naturally occlude their whole rect (including any scrollbar). - local host = qmf.QuestsFrame or _G["QuestScrollFrame"] or qmf - local p = CreateFrame("Frame", "EasyFindMapSearchPanel", qmf) - p:SetFrameStrata(qmf:GetFrameStrata()) - -- Very high level so we sit above any FontStrings Blizzard anchors - -- to QuestMapFrame (like the "Map Legend" title text) and above - -- any scrollbar children anchored at higher sublevels. - p:SetFrameLevel(qmf:GetFrameLevel() + 50) - -- A touch of right-edge extension just in case a child scrollbar - -- peeks outside QuestsFrame's own bounds. - p:SetPoint("TOPLEFT", host, "TOPLEFT", 0, 0) - p:SetPoint("BOTTOMRIGHT", host, "BOTTOMRIGHT", 8, 0) + -- Match WorldQuestTab's pattern (LibWorldMapTabs): anchor to + -- QuestMapFrame.ContentsAnchor with top inset for the top bar and + -- right inset so the MinimalScrollBar template sits outside the + -- bordered content area. Paper texture fills the frame; the border + -- comes from QuestLogBorderFrameTemplate (same one Blizzard uses). + local anchor = qmf.ContentsAnchor or qmf.QuestsFrame or qmf + + -- Outer container spans the whole ContentsAnchor; the bordered + -- result area (p) starts below the search bar. Matches Blizzard's + -- layout where the SearchBox sits above the parchment. + local outer = CreateFrame("Frame", "EasyFindMapSearchOuter", qmf) + outer:SetAllPoints(anchor) + outer:EnableMouse(false) + + -- p is the ListContainer equivalent. Sized like WQT_WorldQuestFrame: + -- ContentsAnchor with insets y=-29 top, x=-22 right. This is a + -- larger rect than QuestScrollFrame, which is why WQT's paper + -- pattern renders at the same scale as Blizzard's — the atlas + -- stretches over a larger area. + local p = CreateFrame("Frame", "EasyFindMapSearchPanel", outer) + p:SetPoint("TOPLEFT", anchor, "TOPLEFT", 0, -29) + p:SetPoint("BOTTOMRIGHT", anchor, "BOTTOMRIGHT", -22, 0) p:EnableMouse(true) - p:Hide() - - -- Approximate height of the header area inside QuestsFrame where the - -- search bar + settings cog sit (above the paper). Measured against - -- QuestScrollFrame (the paper area) so subsequent texture sizing - -- adapts if Blizzard tweaks the layout. - local HEADER_H = 34 - - -- Opaque dark header block covering the area above the paper. - -- Occludes any Blizzard title text that would bleed through. - local headerBg = p:CreateTexture(nil, "BACKGROUND", nil, -2) - headerBg:SetColorTexture(0.08, 0.08, 0.09, 1.0) - headerBg:SetPoint("TOPLEFT", p, "TOPLEFT", 0, 0) - headerBg:SetPoint("TOPRIGHT", p, "TOPRIGHT", 0, 0) - headerBg:SetHeight(HEADER_H) - p.headerBg = headerBg - - -- Paper backdrop (QuestLog-main-background atlas) covers the rest. - local paperBg = p:CreateTexture(nil, "BACKGROUND", nil, -1) - paperBg:SetAtlas("QuestLog-main-background", false) - paperBg:SetPoint("TOPLEFT", p, "TOPLEFT", 0, -HEADER_H) - paperBg:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", 0, 0) - p.paperBg = paperBg - - -- Search box + cog sit inside the header region. - local searchBox = CreateSearchBox(p) - searchBox:SetPoint("TOPLEFT", p, "TOPLEFT", 6, -8) + p.outer = outer + outer:Hide() + + -- Paper backdrop fills p exactly — matches WQT_ListContainer.Background + -- (TOPLEFT + BOTTOMRIGHT to ListContainer). No bleed past the border. + local paper = p:CreateTexture(nil, "BACKGROUND", nil, -1) + paper:SetAtlas("QuestLog-main-background", true) + paper:SetAllPoints(p) + p.paper = paper + + -- Gold border frame (Blizzard template used by QuestScrollFrame). + -- The template carries its own anchors that extend the frame a few + -- pixels outside its parent so the nineslice interior lines up with + -- the parent's rect. Do NOT call SetAllPoints or we override those + -- anchors and the border draws shrunken inside the parent. This is + -- exactly how WQT's + -- with no anchors works. + local border = CreateFrame("Frame", nil, p, "QuestLogBorderFrameTemplate") + border:SetFrameLevel(p:GetFrameLevel() + 2) + -- The template inherits NineSlicePanel which spans the full rect with + -- mouse enabled. Sitting two levels above the result rows it ate the + -- first click on every row (loss of editbox focus + border absorption + -- meant the user had to click twice). Border is decorative chrome — it + -- never needs mouse input. + border:EnableMouse(false) + p.border = border + + -- Cog at fixed top-right position, matching WQT SettingsButton: + -- TOPRIGHT relative to the list container at (19, 25), size 15x16. + local cog = CreateFilterCog(outer) + cog:SetSize(15, 16) + cog:SetPoint("TOPRIGHT", p, "TOPRIGHT", 19, 25) + p.cog = cog + + -- Search box: fallback position (used if QuestScrollFrame.SearchBox + -- isn't measurable yet). AlignSearchBoxToBlizzard() re-anchors it to + -- exactly mirror the Quests tab search box once the map is shown. + local searchBox = CreateSearchBox(outer) + searchBox:ClearAllPoints() + searchBox:SetHeight(20) + searchBox:SetPoint("TOPLEFT", outer, "TOPLEFT", 4, -5) + searchBox:SetPoint("RIGHT", cog, "LEFT", -6, 0) p.searchBox = searchBox - local cog = CreateFilterCog(p) - cog:SetPoint("LEFT", searchBox, "RIGHT", 6, 0) - p.cog = cog + -- Filter dropdown anchored to the cog. Skip the legacy MapSearch + -- refresh path (searchEditBox=nil) since we're not using the + -- deprecated floating dropdown; our onChanged callback handles it. + if ns.MapSearch and ns.MapSearch.CreateFilterDropdown then + local dropdown + dropdown = ns.MapSearch:CreateFilterDropdown( + "EasyFindMapTabFilterDropdown", FILTER_OPTIONS, + "mapTabFilters", cog, outer, nil, + function(key) + RefreshCurrentSearch() + if key == "rares" and dropdown.UpdateAutoTrackRow then + dropdown:UpdateAutoTrackRow() + end + end + ) + AttachAutoTrackRow(dropdown) + -- Replace the shared dropdown's default anchor (right edge of + -- the outer panel) with Blizzard's convention: TOPLEFT of the + -- menu aligned just below the cog's BOTTOMLEFT. + cog:SetScript("OnClick", function() + if dropdown:IsShown() then + dropdown:Hide() + else + dropdown:ClearAllPoints() + dropdown:SetPoint("TOPLEFT", cog, "BOTTOMLEFT", -2, -2) + dropdown:Show() + end + end) + p.filterDropdown = dropdown + end + + -- Copy QuestScrollFrame.SearchBox's anchors AND explicit size onto + -- our search box. Frame inspector confirms Blizzard uses a fixed + -- 301x20 SetSize, so we re-apply it after ClearAllPoints to + -- guarantee our frame matches — otherwise the last flex-anchored + -- width lingers and we resolve to a different rect. + p.AlignToBlizzardSearch = function() + local qsb = _G["QuestScrollFrame"] and QuestScrollFrame.SearchBox + if not qsb then return end + local n = qsb:GetNumPoints() or 0 + if n == 0 then return end + searchBox:ClearAllPoints() + for i = 1, n do + local point, relTo, relPoint, x, y = qsb:GetPoint(i) + if point then + searchBox:SetPoint(point, relTo, relPoint, x, y) + end + end + local w, h = qsb:GetSize() + if w and w > 0 and h and h > 0 then + searchBox:SetSize(w, h) + end + end + p.MeasureBlizzardSearch = p.AlignToBlizzardSearch - -- Scroll area fills just the paper. Right margin reserved for a - -- future minimal scrollbar. + -- Scroll area covers the paper region. local scrollFrame = CreateFrame("ScrollFrame", nil, p) - scrollFrame:SetPoint("TOPLEFT", paperBg, "TOPLEFT", 6, -6) - scrollFrame:SetPoint("BOTTOMRIGHT", paperBg, "BOTTOMRIGHT", -14, 6) + scrollFrame:SetPoint("TOPLEFT", p, "TOPLEFT", 4, -4) + -- Leaves 28px at the bottom for the "Show recent searches" checkbox. + scrollFrame:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", -4, 28) scrollFrame:EnableMouseWheel(true) p.scrollFrame = scrollFrame @@ -767,6 +2346,37 @@ local function CreatePanel(qmf) end) p.scrollChild = scrollChild + -- MinimalScrollBar template placed outside the panel right edge, + -- matching WQT's anchor offsets (+8 x, +2/-4 y). + local scrollBar = CreateFrame("EventFrame", nil, p, "MinimalScrollBar") + scrollBar:SetFrameStrata("HIGH") + scrollBar:SetPoint("TOPLEFT", p, "TOPRIGHT", 8, 2) + scrollBar:SetPoint("BOTTOMLEFT", p, "BOTTOMRIGHT", 8, -4) + p.scrollBar = scrollBar + + local function SyncScrollBar() + local contentH = scrollChild:GetHeight() or 0 + local viewH = scrollFrame:GetHeight() or 0 + if scrollBar.SetVisibleExtentPercentage then + local extent = contentH > 0 and math.min(1, viewH / contentH) or 1 + scrollBar:SetVisibleExtentPercentage(extent) + end + if scrollBar.SetScrollPercentage then + local maxScroll = math.max(1, contentH - viewH) + scrollBar:SetScrollPercentage((scrollFrame:GetVerticalScroll() or 0) / maxScroll) + end + end + if scrollBar.RegisterCallback and scrollBar.Event and scrollBar.Event.OnScroll then + scrollBar:RegisterCallback(scrollBar.Event.OnScroll, function(_, pct) + local maxScroll = math.max(0, (scrollChild:GetHeight() or 0) - (scrollFrame:GetHeight() or 0)) + scrollFrame:SetVerticalScroll(pct * maxScroll) + end, p) + end + scrollFrame:HookScript("OnSizeChanged", SyncScrollBar) + scrollFrame:HookScript("OnVerticalScroll", SyncScrollBar) + hooksecurefunc(scrollChild, "SetHeight", SyncScrollBar) + p.SyncScrollBar = SyncScrollBar + local emptyMsg = p:CreateFontString(nil, "OVERLAY", "GameFontHighlight") emptyMsg:SetPoint("TOP", scrollFrame, "TOP", 0, -24) emptyMsg:SetWidth(260) @@ -774,24 +2384,93 @@ local function CreatePanel(qmf) emptyMsg:SetText("|cff999999Start typing to search for POIs, zones, dungeons, and raids.|r") p.emptyMsg = emptyMsg + -- "Show recent searches" checkbox pinned at the panel's bottom-left, + -- inside the border. Persists to EasyFind.db.mapTabShowRecent. + local recentCheck = CreateFrame("CheckButton", "EasyFindMapTabRecentCheck", p, "UICheckButtonTemplate") + recentCheck:SetSize(20, 20) + recentCheck:SetPoint("BOTTOMLEFT", p, "BOTTOMLEFT", 4, 4) + recentCheck:SetHitRectInsets(0, -120, 0, 0) + local recentLabel = recentCheck:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + recentLabel:SetPoint("LEFT", recentCheck, "RIGHT", 2, 1) + recentLabel:SetText("Show recent searches") + recentCheck:SetScript("OnShow", function(self) + self:SetChecked(EasyFind.db.mapTabShowRecent ~= false) + end) + recentCheck:SetScript("OnClick", function(self) + EasyFind.db.mapTabShowRecent = self:GetChecked() and true or false + RefreshCurrentSearch() + end) + p.recentCheck = recentCheck + return p end --- =================================================================== --- Initialize --- =================================================================== - --- Focus the MapTab search box, opening the map + selecting our tab if --- not already there. Intended to be called from the "focus map search" --- keybind dispatcher in Core. +-- --------------------------------------------------------------------------- +-- Focus entry for keybind (/ef map search focus) +-- --------------------------------------------------------------------------- function MapTab:Focus() if not initialized then self:Initialize() end - if not panel then return end - if WorldMapFrame and not WorldMapFrame:IsShown() then - ToggleWorldMap() + -- ToggleWorldMap is a global available before Blizzard_WorldMap + -- loads; calling it loads the addon and shows the map. After it + -- returns, WorldMapFrame and QuestMapFrame exist so Initialize + -- can finish wiring up our tab and panel. + if not WorldMapFrame or not WorldMapFrame:IsShown() then + if ToggleWorldMap then ToggleWorldMap() end + if not initialized then self:Initialize() end + end + -- If init still hasn't completed (rare race during first load), + -- the ADDON_LOADED → Initialize callback at the bottom of this + -- file consumes _pendingFocus to retry once the panel exists. + if not panel or not tabFrame then + MapTab._pendingFocus = true + return + end + -- Synchronous tab swap: same path the user's tab click takes. + -- The OnMouseUp handler invokes ShowOurPanel(). + local clickHandler = tabFrame:GetScript("OnMouseUp") + if clickHandler then clickHandler(tabFrame, "LeftButton") end + -- Re-apply on the next frame to defend against any async tab + -- restoration logic (Blizzard or third-party) that fires after + -- this frame's OnShow chain settles. + C_Timer.After(0, function() + if not panel then return end + if not panel:IsShown() and clickHandler then + clickHandler(tabFrame, "LeftButton") + end + if panel.searchBox and panel:IsShown() then + panel.searchBox:SetFocus() + end + end) +end + +-- --------------------------------------------------------------------------- +-- Initialize (hook Blizzard tab clicks + fullscreen-hide) +-- --------------------------------------------------------------------------- +-- Find the lowest shown sibling tab on QuestMapFrame and anchor our +-- tab below it. Mirrors LibWorldMapTabs' PlaceTabs(): discovers tabs +-- dynamically by the `displayMode` field so we don't care which +-- specific Blizzard/third-party tabs exist. +local function PlaceTab() + if not tabFrame then return end + local qmf = _G["QuestMapFrame"] + if not qmf then return end + local lowest, lowestBottom + for _, child in ipairs({ qmf:GetChildren() }) do + if child ~= tabFrame and child.displayMode and child.OnEnter and child:IsShown() then + local b = child:GetBottom() + if b and (not lowestBottom or b < lowestBottom) then + lowest, lowestBottom = child, b + end + end + end + tabFrame:ClearAllPoints() + if lowest then + tabFrame:SetPoint("TOPLEFT", lowest, "BOTTOMLEFT", 0, TAB_STACK_GAP) + elseif qmf.MapLegendTab then + tabFrame:SetPoint("TOPLEFT", qmf.MapLegendTab, "BOTTOMLEFT", 0, TAB_STACK_GAP) + else + tabFrame:SetPoint("LEFT", qmf, "RIGHT", 0, 0) end - ShowOurPanel() - if panel.searchBox then panel.searchBox:SetFocus() end end function MapTab:Initialize() @@ -802,23 +2481,58 @@ function MapTab:Initialize() tabFrame = CreateTabFrame(qmf) panel = CreatePanel(qmf) - - -- Blizzard tab click: just hide our overlay; Blizzard's own OnMouseUp - -- (which runs before this HookScript) handles showing the correct - -- panel. No need to re-do its work since we never disturb Quests / - -- MapLegend visibility ourselves. - if qmf.QuestsTab then - qmf.QuestsTab:HookScript("OnMouseUp", function(_, button) - if button == "LeftButton" then HideOurPanel() end + PlaceTab() + + -- Hide our panel whenever any other tab gets selected. Blizzard + -- tabs call qmf:SetDisplayMode(mode); LibWorldMapTabs tabs call + -- lwmt:SetDisplayMode(mode). Hook both so we catch every switch. + if qmf.SetDisplayMode then + hooksecurefunc(qmf, "SetDisplayMode", function(_, displayMode) + if displayMode and selectedIsOurs then HideOurPanel() end end) end - if qmf.MapLegendTab then - qmf.MapLegendTab:HookScript("OnMouseUp", function(_, button) - if button == "LeftButton" then HideOurPanel() end + if LibStub then + local ok, lwmt = pcall(LibStub, "LibWorldMapTabs", true) + if ok and lwmt and type(lwmt.SetDisplayMode) == "function" then + hooksecurefunc(lwmt, "SetDisplayMode", function(_, displayMode) + if displayMode and selectedIsOurs then HideOurPanel() end + end) + end + end + + -- Re-place on map show and once more on next frame so + -- late-registering third-party tabs are picked up. Also opportunistic: + -- measure Blizzard's SearchBox when the map opens with their panel + -- visible, so our alignCache is populated even if the user hasn't + -- clicked our tab yet. + if WorldMapFrame then + WorldMapFrame:HookScript("OnShow", function() + PlaceTab() + SafeAfter(0, PlaceTab) + SafeAfter(0, function() + if panel and panel.MeasureBlizzardSearch then + panel.MeasureBlizzardSearch() + end + end) + -- If a Focus() request opened the map, switch to our tab + -- after Blizzard's OnShow handlers (which restore the last + -- tab) have all run. SafeAfter(0) puts us at the end of + -- this frame's pending callbacks, AFTER Blizzard's restore. + if MapTab._pendingFocus then + MapTab._pendingFocus = nil + SafeAfter(0, function() + if panel then + ShowOurPanel() + if panel.searchBox and panel:IsShown() then + panel.searchBox:SetFocus() + end + end + end) + end end) end - -- Hide our tab in the maximized map view, matching Blizzard's tabs. + -- Hide our tab in fullscreen map (matches Blizzard's tab behavior). if WorldMapFrame and WorldMapFrame.IsMaximized then local function UpdateTabVisibility() if not tabFrame then return end @@ -827,6 +2541,7 @@ function MapTab:Initialize() tabFrame:Hide() else tabFrame:Show() + PlaceTab() end end hooksecurefunc(WorldMapFrame, "Maximize", UpdateTabVisibility) @@ -834,10 +2549,28 @@ function MapTab:Initialize() WorldMapFrame:HookScript("OnShow", UpdateTabVisibility) UpdateTabVisibility() end + + -- Re-render when the displayed map changes (e.g. the user right- + -- clicks to zoom out to the parent zone). The "This Zone (...)" + -- label and its contents are zone-scoped, so they need to refresh + -- to reflect the new map. + hooksecurefunc(WorldMapFrame, "OnMapChanged", function() + if selectedIsOurs and panel and panel:IsShown() then + RefreshCurrentSearch() + end + end) + + -- If a Focus() request came in before init completed (very first + -- press of the keybind on a fresh login while Blizzard_WorldMap + -- was still loading), consume the pending flag now that the tab + -- and panel exist. + if MapTab._pendingFocus then + MapTab._pendingFocus = nil + SafeAfter(0, function() MapTab:Focus() end) + end end --- Blizzard_WorldMap is on-demand loaded. Hook the moment it loads, plus --- defend against race conditions where it's already loaded at our init. +-- Blizzard_WorldMap is on-demand; hook the moment it loads. local eventFrame = CreateFrame("Frame") eventFrame:RegisterEvent("ADDON_LOADED") eventFrame:RegisterEvent("PLAYER_LOGIN") @@ -845,12 +2578,7 @@ eventFrame:SetScript("OnEvent", function(_, event, addonName) if event == "ADDON_LOADED" and addonName == "Blizzard_WorldMap" then SafeAfter(0, function() MapTab:Initialize() end) elseif event == "PLAYER_LOGIN" then - local isLoaded - if C_AddOns and C_AddOns.IsAddOnLoaded then - isLoaded = C_AddOns.IsAddOnLoaded("Blizzard_WorldMap") - end - if isLoaded then - SafeAfter(0, function() MapTab:Initialize() end) - end + local isLoaded = C_AddOns and C_AddOns.IsAddOnLoaded and C_AddOns.IsAddOnLoaded("Blizzard_WorldMap") + if isLoaded then SafeAfter(0, function() MapTab:Initialize() end) end end end) From df303a2b8a28794a5fe7f63c4664654b68f0c2f0 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Wed, 29 Apr 2026 17:07:03 -0400 Subject: [PATCH 026/103] Search infra: CouldMatch prefilter, shared key-repeat, deferred UI - Database: CouldMatch pre-filter for ScoreName drops candidates whose text doesn't contain every char of the query before they reach the DamerauLevenshtein DP. Eliminates the bulk of scoring work on the ~90%+ of entries that obviously don't match. - Utils: extract CreateKeyRepeat as a shared hold-to-step helper used by both UI search and MapTab navigation. Action fires immediately, then ticks at an accelerating cadence (0.30s -> 0.05s over 1.5s). - UI: defer the search work to the next frame on each keystroke so multiple keys within one frame coalesce to a single search; make the clear button focus-aware (visible while focused even if empty). --- Database.lua | 33 +++ UI.lua | 745 +++++++++++++++++++++++++++++++++++---------------- Utils.lua | 47 ++++ 3 files changed, 591 insertions(+), 234 deletions(-) diff --git a/Database.lua b/Database.lua index d296908..57cd6b6 100644 --- a/Database.lua +++ b/Database.lua @@ -2534,6 +2534,34 @@ function Database:DamerauLevenshtein(s1, s2, len1, len2) return prev[len2] end +-- Fast pre-filter: every distinct character in the query must appear +-- somewhere in `text`. Order and position don't matter so fuzzy +-- matchers tolerant of transpositions (e.g., "acheivement" vs +-- "achievement") still pass through to the real scorers. Missing a +-- single query char is a definitive "cannot match" signal. +-- +-- Builds a small byte-set from the text per call. For stable text +-- (POI names), the caller should cache result at a higher level; for +-- one-off scoring this remains cheap — O(|text| + |query|). +local reuseCouldMatchSet = {} +function Database:CouldMatch(text, query) + local tlen, qlen = #text, #query + if qlen == 0 then return true end + if tlen == 0 then return false end + local seen = reuseCouldMatchSet + for k in pairs(seen) do seen[k] = nil end + for i = 1, tlen do + seen[text:byte(i)] = true + end + for i = 1, qlen do + local qb = query:byte(i) + if qb ~= 32 and not seen[qb] then -- skip spaces (multi-word queries) + return false + end + end + return true +end + -- Unified name scoring: exact → starts-with → word-boundary → substring → initials → fuzzy. -- All search features (UI, map zone, map POI) use this single function. -- Returns a score ≥ 0. Caller decides the minimum threshold. @@ -2545,6 +2573,11 @@ function Database:ScoreName(nameLower, query, queryLen, optQueryWords) if queryLen == 0 then return 0 end end + -- Cheap pre-filter: if the query's chars don't appear in order in + -- the name, no scoring path can match. Saves the subsequence + + -- fuzzy DP pass on the ~90%+ of entries that obviously don't match. + if not Database:CouldMatch(nameLower, query) then return 0 end + local score = 0 -- Whole-string matching (works for single and multi-word queries) diff --git a/UI.lua b/UI.lua index 1eade09..bd57836 100644 --- a/UI.lua +++ b/UI.lua @@ -727,11 +727,22 @@ function UI:CreateSearchFrame() end end) + local pendingUISearchTimer editBox:SetScript("OnTextChanged", function(self) if self:GetText() ~= "" then self.placeholder:Hide() end - UI:OnSearchTextChanged(self:GetText()) + -- Defer the search to the next frame so the keystroke frame + -- only does cheap work (placeholder + clear-button updates). + -- The full Database/Map search runs on the next frame, well + -- under the perception threshold. Multiple keystrokes within + -- one frame coalesce — only the latest text fires. + if pendingUISearchTimer then pendingUISearchTimer:Cancel() end + local snapshot = self:GetText() + pendingUISearchTimer = C_Timer.NewTimer(0, function() + pendingUISearchTimer = nil + UI:OnSearchTextChanged(snapshot) + end) end) editBox:SetScript("OnEnterPressed", function(self) @@ -833,51 +844,26 @@ function UI:CreateSearchFrame() -- navigation without typing /ef c. local function UpdateClearButtonVisibility() local hasText = editBox:GetText() ~= "" + local focused = editBox:HasFocus() local guideActive = ns.Highlight and ns.Highlight:IsActive() local mapActive = ns.MapSearch and ns.MapSearch.HasActiveNavigation and ns.MapSearch:HasActiveNavigation() - clearTextBtn:SetShown(hasText or guideActive or mapActive) + clearTextBtn:SetShown(hasText or focused or guideActive or mapActive) end editBox:HookScript("OnTextChanged", UpdateClearButtonVisibility) + editBox:HookScript("OnEditFocusGained", UpdateClearButtonVisibility) + editBox:HookScript("OnEditFocusLost", 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. - local REPEAT_INITIAL = 0.30 - local REPEAT_FAST = 0.05 - local REPEAT_ACCEL = 1.5 - local repeatKey, repeatAction, repeatHeld, repeatNext - local repeatActive = false - - local function StopKeyRepeat() - repeatKey = nil - repeatAction = nil - repeatActive = false - end - searchFrame.StopKeyRepeat = StopKeyRepeat - searchFrame.IsRepeatKey = function(key) return repeatKey == key end - - local function StartKeyRepeat(key, action) - action() - repeatKey = key - repeatAction = action - repeatHeld = 0 - repeatNext = REPEAT_INITIAL - repeatActive = true - end + -- Shared key-repeat helper (also used by MapTab). Attaches its own + -- OnUpdate to searchFrame; action fires immediately on Start, then + -- at an accelerating cadence while the key is held. + local keyRepeat = Utils.CreateKeyRepeat(searchFrame) + local StartKeyRepeat = keyRepeat.Start + local StopKeyRepeat = keyRepeat.Stop searchFrame.StartKeyRepeat = StartKeyRepeat - - searchFrame:SetScript("OnUpdate", function(_, elapsed) - if not repeatActive then return end - repeatHeld = repeatHeld + elapsed - repeatNext = repeatNext - elapsed - if repeatNext <= 0 then - repeatAction() - local t = repeatHeld / REPEAT_ACCEL - if t > 1 then t = 1 end - repeatNext = REPEAT_INITIAL + (REPEAT_FAST - REPEAT_INITIAL) * t - end - end) + searchFrame.StopKeyRepeat = StopKeyRepeat + searchFrame.IsRepeatKey = keyRepeat.IsKey -- Arrow key / Tab navigation for results dropdown. -- IMPORTANT: Always block propagation while the editbox has focus so that @@ -890,6 +876,16 @@ function UI:CreateSearchFrame() if key == "DOWN" then UI:MoveSelection(1) end end end + -- Ctrl+J/K (and the emacs-equivalent Ctrl+N/P) work from the + -- editbox as down/up aliases. First press drops into the first + -- result; subsequent presses walk the list. Parallels MapTab. + if IsControlKeyDown() then + if key == "J" or key == "N" then + UI:MoveSelection(1) + elseif key == "K" or key == "P" then + UI:MoveSelection(-1) + end + end Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) end) @@ -965,10 +961,76 @@ function UI:CreateSearchFrame() Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) Utils.SafeCallMethod(navFrame, "SetPropagateKeyboardInput", false) + -- Cycle focus through editBox → [clearBtn] → filterBtn → row → + -- toggle button (chevron / pin toggle) and back. Shared by Tab, + -- Shift+Tab, and the Ctrl+L / Ctrl+H vim aliases below. + local function CycleFocus(reverse) + if reverse then + if selectedIndex > 0 and toggleFocused then + toggleFocused = false + UI:UpdateSelectionHighlight() + elseif toolbarFocus > 0 then + if toolbarFocus == 1 then + ClearToolbarFocus() + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + searchFrame.editBox:SetFocus() + else + SetToolbarFocus(toolbarFocus - 1) + end + end + else + if selectedIndex > 0 and not toggleFocused then + local row = resultButtons[selectedIndex] + local hasToggle = row and row.isPathNode and ( + (row.headerTab and row.headerTab:IsShown()) or + (row.isPinHeader and row.pinToggle and row.pinToggle:IsShown()) + ) + if hasToggle then + toggleFocused = true + UI:UpdateSelectionHighlight() + end + elseif toolbarFocus > 0 then + local controls = GetToolbarControls() + if toolbarFocus >= #controls then + ClearToolbarFocus() + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + searchFrame.editBox:SetFocus() + else + SetToolbarFocus(toolbarFocus + 1) + end + end + end + end + local function HandleNavKeyDown(key) if key == "LSHIFT" or key == "RSHIFT" or key == "LCTRL" or key == "RCTRL" or key == "LALT" or key == "RALT" then return end + -- Ctrl+H/J/K/L: vim-style nav aliases. J/K = down/up (also + -- N/P emacs-style); add Shift to jump sections like Shift+Up/Down. + -- H/L = focus cycle (Shift+Tab / Tab). + if IsControlKeyDown() and (key == "J" or key == "N") then + if IsShiftKeyDown() then + UI:JumpToNextSection(1) + else + UI:MoveSelection(1) + end + return + elseif IsControlKeyDown() and (key == "K" or key == "P") then + if IsShiftKeyDown() then + UI:JumpToNextSection(-1) + else + UI:MoveSelection(-1) + end + return + elseif IsControlKeyDown() and key == "L" then + CycleFocus(false) + return + elseif IsControlKeyDown() and key == "H" then + CycleFocus(true) + return + end + if key == "DOWN" then if IsControlKeyDown() then UI:JumpToEnd() @@ -985,6 +1047,54 @@ function UI:CreateSearchFrame() else StartKeyRepeat(key, function() UI:MoveSelection(-1) end) end + elseif key == "SPACE" then + -- SPACE on a highlighted group/pin header toggles collapse. + -- Consumed unconditionally while navigating so it never + -- leaks through to the editbox (which would otherwise + -- refocus on the next UpdateSelectionHighlight and insert a + -- literal space character into the search text). + -- + -- Toggling also rebuilds the result list (via + -- ShowHierarchicalResults), which wipes selectedIndex. + -- Snapshot the row's identity first so the same header can + -- be re-selected after the rebuild — letting the user spam + -- Space to collapse/expand without losing selection. + if selectedIndex > 0 then + local row = resultButtons[selectedIndex] + if row then + local savedPinHeader = row.isPinHeader + local savedPathName = row.isPathNode and row.pathNodeName + local savedPathDepth = row.isPathNode and row.pathNodeDepth + if row.isPinHeader and row.pinToggle and row.pinToggle:IsShown() then + local handler = row.pinToggle:GetScript("OnClick") + if handler then handler(row.pinToggle, "LeftButton") end + elseif row.toggleBtn and row.toggleBtn:IsShown() then + local handler = row.toggleBtn:GetScript("OnClick") + if handler then handler(row.toggleBtn, "LeftButton") end + end + for i = 1, MAX_BUTTON_POOL do + local rb = resultButtons[i] + if not rb then break end + if rb:IsShown() then + local match = false + if savedPinHeader and rb.isPinHeader then + match = true + elseif savedPathName and rb.isPathNode + and rb.pathNodeName == savedPathName + and rb.pathNodeDepth == savedPathDepth then + match = true + end + if match then + selectedIndex = i + toggleFocused = false + UI:UpdateSelectionHighlight() + break + end + end + end + end + end + return elseif key == "PAGEDOWN" then StartKeyRepeat(key, function() UI:MoveSelection(5) end) elseif key == "PAGEUP" then @@ -995,41 +1105,7 @@ function UI:CreateSearchFrame() UI:JumpToEnd() elseif key == "TAB" then -- Ring order: editBox → [clearBtn] → filterBtn → wrap back to editBox - if IsShiftKeyDown() then - if selectedIndex > 0 and toggleFocused then - toggleFocused = false - UI:UpdateSelectionHighlight() - elseif toolbarFocus > 0 then - if toolbarFocus == 1 then - ClearToolbarFocus() - Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) - searchFrame.editBox:SetFocus() - else - SetToolbarFocus(toolbarFocus - 1) - end - end - else - if selectedIndex > 0 and not toggleFocused then - local row = resultButtons[selectedIndex] - local hasToggle = row and row.isPathNode and ( - (row.headerTab and row.headerTab:IsShown()) or - (row.isPinHeader and row.pinToggle and row.pinToggle:IsShown()) - ) - if hasToggle then - toggleFocused = true - UI:UpdateSelectionHighlight() - end - elseif toolbarFocus > 0 then - local controls = GetToolbarControls() - if toolbarFocus >= #controls then - ClearToolbarFocus() - Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) - searchFrame.editBox:SetFocus() - else - SetToolbarFocus(toolbarFocus + 1) - end - end - end + CycleFocus(IsShiftKeyDown()) elseif key == "ENTER" then if toolbarFocus > 0 then local controls = GetToolbarControls() @@ -1087,7 +1163,7 @@ function UI:CreateSearchFrame() HandleNavKeyDown(key) end) navFrame:SetScript("OnKeyUp", function(_, key) - if repeatKey == key then StopKeyRepeat() end + if keyRepeat.IsKey(key) then StopKeyRepeat(key) end end) -- UISpecialFrames fallback: WoW closes these on ESC before opening the @@ -1113,6 +1189,29 @@ function UI:CreateSearchFrame() -- the editbox (clearTextBtn, filterBtn). Shift+Tab wraps to the last. editBox:HookScript("OnKeyDown", function(self, key) if key ~= "TAB" then return end + -- Tab-completion: if there's a candidate result whose name + -- contains the current query, complete to it and keep focus. + -- Fall through to toolbar-focus behavior below only when no + -- completion is applicable. + local q = self:GetText() or "" + if q ~= "" and not IsShiftKeyDown() and cachedHierarchical then + -- Walk in scoring order, take the first prefix-matching + -- candidate. Skips fuzzy winners that don't start with the + -- typed text (e.g. "dragon i" skipping "Dragonblight" to + -- reach "Dragon Isles"). + local qLower = q:lower() + for _, entry in ipairs(cachedHierarchical) do + if entry.isMatch and not entry.isPathNode and entry.name + and #entry.name >= #q + and entry.name:sub(1, #q):lower() == qLower + and entry.name:lower() ~= qLower then + local completed = entry.name:lower() + self:SetText(completed) + self:SetCursorPosition(#completed) + return + end + end + end self:ClearFocus() Utils.SafeCallMethod(navFrame, "EnableKeyboard", true) local controls = GetToolbarControls() @@ -1464,62 +1563,9 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end - -- Map Search: indented local/global sub-options (radio-style, one active at a time) - if opt.key == "map" then - local SUB_INDENT = 24 - local mapSubRows = {} - - for si, sub in ipairs({ { key = true, label = "Local (Zone)" }, { key = false, label = "Global (All Zones)" } }) 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.isLocalKey = sub.key - mapSubRows[si] = subRow - - subRow:SetScript("OnClick", function() - EasyFind.db.uiMapSearchLocal = sub.key - for _, sr in ipairs(mapSubRows) do - sr:SetChecked((EasyFind.db.uiMapSearchLocal ~= false) == sr.isLocalKey) - end - if searchEditBox:GetText() ~= "" then - UI:OnSearchTextChanged(searchEditBox:GetText()) - end - end) - end - - row.mapSubRows = mapSubRows - row.updateMapToggle = function() - local isLocal = EasyFind.db.uiMapSearchLocal ~= false - local mapChecked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.map ~= false - for si, sr in ipairs(mapSubRows) do - sr:SetChecked(isLocal == sr.isLocalKey) - sr:SetShown(mapChecked) - end - end - - -- Wrap the original OnClick to also show/hide sub-rows - local origMapRowIdx = i - row.mapSubRowIdx = origMapRowIdx - end + -- Map Search: was a local/global radio pair, but the MapTab + -- model shows both scopes together and that's what UI search + -- now does too — the toggle above is just on/off. -- Loot: indented sub-options for search mode and spec toggle if opt.key == "loot" then @@ -3082,16 +3128,12 @@ function UI:CreateResultButton(index) resultRow:SetSize(360, 22) resultRow:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 10, -8 - (index - 1) * 22) - resultRow:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD") - - -- Persistent selection highlight (for keyboard navigation) - local selTex = resultRow:CreateTexture(nil, "BACKGROUND") - selTex:SetAllPoints() - selTex:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") - selTex:SetBlendMode("ADD") - selTex:SetVertexColor(0.3, 0.6, 1.0, 0.4) - selTex:Hide() - resultRow.selectionHighlight = selTex + -- Single highlight texture for both mouse hover and keyboard + -- selection via LockHighlight, so the two paths look identical. + -- Uses Blizzard's tapered quest-log row glow atlas. + resultRow:SetHighlightAtlas("QuestLog-quest-glow-yellow") + local hlTex = resultRow:GetHighlightTexture() + if hlTex then hlTex:SetBlendMode("ADD") end -- Retail theme: full-width dark gradient behind headers (Event Schedule style) local headerGrad = resultRow:CreateTexture(nil, "BACKGROUND", nil, 1) @@ -3361,6 +3403,29 @@ function UI:CreateResultButton(index) pinHeaderLine:Hide() resultRow.pinHeaderLine = pinHeaderLine + -- Section-label visuals: centered fontstring flanked by two faint + -- gold rules (matches MapTab's "Pinned" / "This Zone" / etc. style). + -- Used for category headers (UI/Mounts/Toys/...) instead of the + -- chunkier QuestLog-tab parent header so categories take less + -- vertical space and don't waste a parent indent. + local sectionLabelLeft = resultRow:CreateTexture(nil, "ARTWORK") + sectionLabelLeft:SetHeight(1) + sectionLabelLeft:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.4) + sectionLabelLeft:Hide() + resultRow.sectionLabelLeft = sectionLabelLeft + + local sectionLabelRight = resultRow:CreateTexture(nil, "ARTWORK") + sectionLabelRight:SetHeight(1) + sectionLabelRight:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.4) + sectionLabelRight:Hide() + resultRow.sectionLabelRight = sectionLabelRight + + local sectionLabelText = resultRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + sectionLabelText:SetPoint("CENTER", resultRow, "CENTER", 0, 0) + sectionLabelText:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) + sectionLabelText:Hide() + resultRow.sectionLabelText = sectionLabelText + -- Right-aligned currency amount label local amountText = resultRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") amountText:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) @@ -3838,16 +3903,78 @@ function UI:OnSearchTextChanged(text) groupUI[#groupUI + 1] = entry end end - -- Map results come from a separate search, wrap them as hierarchical entries + -- Map results: group by top-ancestor continent so parent/children + -- nest with collapsible headers (matches MapTab's layout). A group + -- with only one entry renders flat — no point wrapping a single + -- result in its own header. Groups with the parent zone itself as + -- a match promote the parent entry to the header's navigate target. if mapResults then - for _, r in ipairs(mapResults) do - groupMap[#groupMap + 1] = { - name = r.data.name, - depth = 1, - isPathNode = false, - isMatch = true, - data = r.data, - } + local getAncestor = ns.MapTab and ns.MapTab.GetTopAncestor + if not getAncestor then + -- Fallback if MapTab isn't loaded yet (unlikely but safe). + for _, r in ipairs(mapResults) do + groupMap[#groupMap + 1] = { + name = r.data.name, depth = 1, + isPathNode = false, isMatch = true, data = r.data, + } + end + else + local groupsByAncestor = {} + local ancestorOrder = {} + for _, r in ipairs(mapResults) do + local d = r.data + local mapID = d.mapID or d.zoneMapID or d.entranceMapID + local ancestor = mapID and getAncestor(ns.MapTab, mapID) + if not ancestor or ancestor == "" then ancestor = "Other" end + local g = groupsByAncestor[ancestor] + if not g then + g = { items = {}, bestScore = 0, selfMatch = nil } + groupsByAncestor[ancestor] = g + ancestorOrder[#ancestorOrder + 1] = ancestor + end + -- Detect "the zone IS its own top ancestor" (Eastern + -- Kingdoms matching while all its child zones also + -- match) so the parent header can point at it rather + -- than a synthesized one. + if d.name == ancestor and d.isZone then + g.selfMatch = r + else + g.items[#g.items + 1] = r + end + local s = r.score or 0 + if s > g.bestScore then g.bestScore = s end + end + for _, ancestor in ipairs(ancestorOrder) do + local g = groupsByAncestor[ancestor] + local total = #g.items + (g.selfMatch and 1 or 0) + if total > 1 then + -- Parent header, then children at depth 2 + local headerData = g.selfMatch and g.selfMatch.data + or { name = ancestor, mapSearchResult = true, isZone = true } + groupMap[#groupMap + 1] = { + name = ancestor, depth = 1, + isPathNode = true, isMatch = true, + data = headerData, + } + for _, r in ipairs(g.items) do + groupMap[#groupMap + 1] = { + name = r.data.name, depth = 2, + isPathNode = false, isMatch = true, + data = r.data, + } + end + else + -- Single-item group: render flat, no wrapping header. + local r = g.selfMatch or g.items[1] + if r then + groupMap[#groupMap + 1] = { + name = r.data.name, depth = 1, + isPathNode = false, isMatch = true, + data = r.data, + } + end + end + end end end -- Sort categories by best match score so the most relevant category appears first @@ -4138,16 +4265,11 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) hasResultsAfterPins = true end - -- Pre-compute section header separator positions + -- Section labels carry their own pair of gold rules, so the + -- separator-above-label was just doubling up on the visual cut. + -- Keep the table around for the render loop's lookup but never + -- populate it. local catSepBeforeIndex = {} - for i = 2, count do - if visible[i].isSectionHeader then - local prev = visible[i - 1] - if not prev.isPinHeader and not prev.isPinned then - catSepBeforeIndex[i] = true - end - end - end -- Render visible rows local yOffset = 0 @@ -4184,8 +4306,20 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow:ClearAllPoints() resultRow:SetPoint("TOPLEFT", resultsFrame.scrollChild, "TOPLEFT", padL, -yOffset) - -- Selection highlight color - resultRow.selectionHighlight:SetVertexColor(unpack(theme.selectionColor)) + -- Selection visual is now carried by the row's built-in + -- HighlightTexture (atlas set in CreateResultRow), shared + -- with mouse hover; no separate selectionHighlight texture. + if resultRow.UnlockHighlight then resultRow:UnlockHighlight() end + + -- Always hide section-label visuals up front. The section- + -- header branch below re-shows them when applicable; rows + -- recycled from a previous section-header role would + -- otherwise leak the gold rules across normal rows. + if resultRow.sectionLabelText then + resultRow.sectionLabelText:Hide() + resultRow.sectionLabelLeft:Hide() + resultRow.sectionLabelRight:Hide() + end resultRow.data = data -- Set secure action attributes for toys, mounts, and outfits @@ -4294,6 +4428,25 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.text:SetText(entry.name) resultRow.text:SetFontObject(theme.pathFont) resultRow.text:SetTextColor(0.7, 0.7, 0.7, 1.0) + elseif entry.isSectionHeader then + -- Lightweight inline section label: centered text + -- between two faint horizontal rules. Used for + -- category dividers (UI / Mounts / Toys / Map / ...) + -- so they cost less vertical space than a full + -- parent-tab header and don't waste a parent indent. + resultRow.headerTab:Hide() + resultRow.headerGrad:Hide() + resultRow.text:SetText("") + resultRow.sectionLabelText:SetText(entry.name) + resultRow.sectionLabelText:Show() + resultRow.sectionLabelLeft:ClearAllPoints() + resultRow.sectionLabelLeft:SetPoint("LEFT", resultRow, "LEFT", 6, 0) + resultRow.sectionLabelLeft:SetPoint("RIGHT", resultRow.sectionLabelText, "LEFT", -6, 0) + resultRow.sectionLabelLeft:Show() + resultRow.sectionLabelRight:ClearAllPoints() + resultRow.sectionLabelRight:SetPoint("LEFT", resultRow.sectionLabelText, "RIGHT", 6, 0) + resultRow.sectionLabelRight:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) + resultRow.sectionLabelRight:Show() elseif theme.showHeaderTab and entry.isPathNode then -- Quest-log raised tab header local tabInset = depth * indPx @@ -5240,12 +5393,25 @@ function UI:UpdateSelectionHighlight(skipRefocus) for i = 1, MAX_BUTTON_POOL do local resultRow = resultButtons[i] if not resultRow then break end - if resultRow.selectionHighlight then - resultRow.selectionHighlight:SetShown(i == selectedIndex and not toggleFocused) + -- Leaf rows (no headerTab): LockHighlight pins the shared + -- row HighlightTexture so keyboard selection looks identical + -- to mouse hover. Header rows skip the row highlight — their + -- visual is carried by tabHoverOverlay inside the tab to match + -- mouse hover exactly (rather than painting a row-wide glow + -- that extends past the tab). + local isHeaderRow = resultRow.headerTab and resultRow.headerTab:IsShown() + if resultRow.LockHighlight then + if i == selectedIndex and not toggleFocused and not isHeaderRow then + resultRow:LockHighlight() + else + resultRow:UnlockHighlight() + end end - -- Tab selection highlight (Retail theme) + -- Retired blue glow: keyboard selection now uses the same + -- tabHoverOverlay the mouse-hover path does so the two paths + -- look identical. Keep the texture reference silent. if resultRow.tabSelectionHighlight then - resultRow.tabSelectionHighlight:SetShown(i == selectedIndex and resultRow.headerTab:IsShown() and not toggleFocused) + resultRow.tabSelectionHighlight:Hide() end if resultRow.toggleHighlight then local showToggle = i == selectedIndex and toggleFocused @@ -5265,11 +5431,18 @@ function UI:UpdateSelectionHighlight(skipRefocus) resultRow.toggleBtn:UnlockHighlight() end end + -- Tab overlay shows for keyboard selection on a non-pin + -- header whether the cursor is on the row body (not + -- toggleFocused) or on the toggle button (toggleFocused). + -- Pin headers use their own pin-toggle highlight instead. + local isHeaderSelected = i == selectedIndex + and resultRow.headerTab and resultRow.headerTab:IsShown() + and not isPinToggle if resultRow.tabHoverOverlay then - resultRow.tabHoverOverlay:SetShown(showToggle and not isPinToggle) + resultRow.tabHoverOverlay:SetShown(isHeaderSelected) end if resultRow.tabText then - if showToggle and not isPinToggle then + if isHeaderSelected then resultRow.tabText:SetTextColor(0.90, 0.88, 0.85, 1.0) elseif not resultRow.headerTab:IsMouseOver() then if resultRow._isMatch then @@ -6609,6 +6782,46 @@ function UI:ShowFirstTimeSetup() searchFrame.setupMode = true searchFrame.editBox:EnableMouse(false) + -- Golden glow overlay (port of the v1.5.0 setup glow). 6px outset + -- from the search bar, pulsing fill, gold border, "Drag to move" + -- label centered on top. + local glow = CreateFrame("Frame", "EasyFindSetupGlow", searchFrame, "BackdropTemplate") + glow:SetPoint("TOPLEFT", searchFrame, "TOPLEFT", -6, 6) + glow:SetPoint("BOTTOMRIGHT", searchFrame, "BOTTOMRIGHT", 6, -6) + glow:SetFrameStrata("DIALOG") + glow:SetFrameLevel(100) + glow:EnableMouse(false) -- clicks pass through to search bar + glow:SetIgnoreParentAlpha(true) -- stay opaque when search bar fades + + glow:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8x8", + edgeFile = TOOLTIP_BORDER, + edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 } + }) + glow:SetBackdropColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.20) + glow:SetBackdropBorderColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) + + -- Gentle pulse on the gold fill + local pulseUp = true + local pulseAlpha = 0.20 + glow:SetScript("OnUpdate", function(self, elapsed) + if pulseUp then + pulseAlpha = pulseAlpha + elapsed * 0.12 + if pulseAlpha >= 0.35 then pulseAlpha = 0.35; pulseUp = false end + else + pulseAlpha = pulseAlpha - elapsed * 0.12 + if pulseAlpha <= 0.12 then pulseAlpha = 0.12; pulseUp = true end + end + self:SetBackdropColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], pulseAlpha) + end) + + -- Centered label overlaid on the glow (like edit-mode frame labels). + local setupLabel = glow:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + setupLabel:SetPoint("CENTER", glow, "CENTER", 0, 0) + setupLabel:SetText("Drag to move") + setupLabel:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.9) + -- Resize handle (bottom-left corner of the search bar). Parents to -- searchFrame so it fades in sync when Smart Show hides the bar. local resizer = CreateFrame("Button", nil, searchFrame) @@ -6741,9 +6954,8 @@ function UI:ShowFirstTimeSetup() -- Set the initial resizer size to match the current font scale scaleResizerVisual() - -- Fixed panel width - text wraps to fit. 290px comfortably holds the - -- longest header line and lets the descriptions wrap to 2 lines. - local SIDE_BUFFER = 16 + -- Fixed panel width — comfortably holds the keybind rows and + -- two-button bottom strip without wrapping. local panelWidth = 290 -- Instruction panel (anchored below the search bar) @@ -6752,67 +6964,33 @@ function UI:ShowFirstTimeSetup() panel:SetPoint("TOP", searchFrame, "BOTTOM", 0, -6) panel:SetIgnoreParentAlpha(true) -- survive Smart Show fade panel:SetFrameStrata("DIALOG") - -- Solid flat background (same WHITE8x8 texture the search bar uses) so - -- the panel is fully opaque and text stays easy to read. panel:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8", edgeFile = TOOLTIP_BORDER, - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 } + edgeSize = 14, + insets = { left = 3, right = 3, top = 3, bottom = 3 } }) - panel:SetBackdropColor(0.05, 0.05, 0.05, 0.9) - - -- Top header lines (centered). - local header = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight") - header:SetPoint("TOP", panel, "TOP", 0, -12) - header:SetWidth(panelWidth - SIDE_BUFFER * 2) - header:SetJustifyH("CENTER") - header:SetText( - "|cffffffffDrag the search bar to position it.|r\n" .. - "|cffffffffUse the corner handle to resize.|r" - ) - - -- Tip line (left-aligned, anchored below header) - local tip = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight") - tip:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, -8) - tip:SetPoint("TOPRIGHT", header, "BOTTOMRIGHT", 0, -8) - tip:SetJustifyH("LEFT") - tip:SetText( - "\226\128\162 |cff999999Hold |cffFFD100Shift|r|cff999999 + drag to reposition later.|r" - ) + panel:SetBackdropColor(0.05, 0.05, 0.05, 0.95) + panel:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.7) - -- Reset to defaults button: escape hatch if a new user accidentally - -- shrinks the bar to an unclickable size or drags it off-screen. - local resetDefaultsBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") - resetDefaultsBtn:SetSize(180, 20) - resetDefaultsBtn:SetPoint("TOP", tip, "BOTTOM", 0, -6) - resetDefaultsBtn:SetText("Reset size & position") - resetDefaultsBtn:SetScript("OnClick", function() - EasyFind.db.uiSearchPosition = nil - EasyFind.db.uiSearchWidth = 0.88 - EasyFind.db.fontSize = 0.9 - if UI.ResetPosition then UI:ResetPosition() end - UI:UpdateWidth() - UI:UpdateFontSize() - scaleResizerVisual() - end) + -- "Setup" header (centered, just under the panel top). + local header = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + header:SetPoint("TOP", panel, "TOP", 0, -10) + header:SetText("Setup") - -- Horizontal separator between the reset button and Smart Show section + -- Horizontal separator immediately under the header. local sep = panel:CreateTexture(nil, "ARTWORK") sep:SetHeight(1) sep:SetPoint("LEFT", panel, "LEFT", 12, 0) sep:SetPoint("RIGHT", panel, "RIGHT", -12, 0) - sep:SetPoint("TOP", resetDefaultsBtn, "BOTTOM", 0, -8) + sep:SetPoint("TOP", header, "BOTTOM", 0, -8) sep:SetColorTexture(0.4, 0.4, 0.4, 0.6) - -- Smart Show checkbox (default checked - matches DB_DEFAULTS.smartShow = true) - -- The separator anchor sits ~15px outside the panel's left edge - -- (sep extends past the panel for a full hr-rule look), so anchoring - -- the checkbox with offset 30 from sep.BOTTOMLEFT places it at - -- roughly panel.left + 15 - a comfortable left margin inside the - -- panel. + -- Smart Show checkbox aligned to the panel's left margin (sep is + -- already anchored at panel.left + 12 so 0 keeps the checkbox box + -- flush with the separator without further indentation). local smartShowCheckbox = CreateFrame("CheckButton", nil, panel, "InterfaceOptionsCheckButtonTemplate") - smartShowCheckbox:SetPoint("TOPLEFT", sep, "BOTTOMLEFT", 30, -6) + smartShowCheckbox:SetPoint("TOPLEFT", sep, "BOTTOMLEFT", 0, -6) smartShowCheckbox.Text:SetText("|cffFFD100Smart Show|r |cff999999(Recommended)|r") smartShowCheckbox:SetChecked(false) smartShowCheckbox:SetScript("OnClick", function(self) @@ -6849,25 +7027,120 @@ function UI:ShowFirstTimeSetup() fadeDesc:SetJustifyH("LEFT") fadeDesc:SetText("|cff999999Reduces bar opacity while you're moving.|r") - -- Footer note - local footer = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight") - footer:SetPoint("BOTTOM", panel, "BOTTOM", 0, 36) - footer:SetWidth(panelWidth - SIDE_BUFFER * 2) - footer:SetJustifyH("CENTER") - footer:SetText("|cff666666See all settings in |cffFFD100/ef|r|cff666666.|r") + -- Keybind rows. Each row = [label] [keybind button] [recommended hint]. + -- Click the button to capture a key, right-click to clear, Esc to cancel — + -- mirrors the keybind UI in /ef Options > Shortcuts. + local function GetKeybindLabel(action) + local k1 = GetBindingKey(action) + return k1 or "Not Bound" + end + + local function StopKeybindCapture(btn, action) + btn.waitingForKey = false + btn:SetText(GetKeybindLabel(action)) + btn:UnlockHighlight() + Utils.SafeCallMethod(btn, "EnableKeyboard", false) + btn:SetScript("OnKeyDown", nil) + end + + -- Two-column symmetric layout. Row 1 = labels, row 2 = buttons. + -- Each column is centered in half the panel; the label and button + -- inside a column share an x-center, so the button sits directly + -- under its label. + local KEYBIND_BTN_W = 110 + local LEFT_COL_X = panelWidth / 4 -- center of the left half + local RIGHT_COL_X = panelWidth * 3/4 -- center of the right half + + -- Invisible row anchor: y is locked to fadeDesc bottom so the labels + -- below sit a fixed gap under the description. Width spans the panel + -- so label x = row.left + colCenterX places each label center on its + -- column. Using a single-anchor TOP on each label avoids the over- + -- constrained x that two SetPoints would create. + local keybindRow = CreateFrame("Frame", nil, panel) + keybindRow:SetHeight(1) + keybindRow:SetPoint("LEFT", panel, "LEFT") + keybindRow:SetPoint("RIGHT", panel, "RIGHT") + keybindRow:SetPoint("TOP", fadeDesc, "BOTTOM", 0, -12) + + local function CreateKeybindLabel(text, colCenterX) + local lbl = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + lbl:SetText(text) + lbl:SetPoint("TOP", keybindRow, "TOPLEFT", colCenterX, 0) + return lbl + end + + local function CreateKeybindButton(label, anchorLabel, action, recommended) + local btn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + btn:SetNormalFontObject("GameFontHighlightSmall") + btn:SetHighlightFontObject("GameFontHighlightSmall") + btn:SetSize(KEYBIND_BTN_W, 22) + btn:SetPoint("TOP", anchorLabel, "BOTTOM", 0, -4) + btn:SetText(GetKeybindLabel(action)) + btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + btn:SetScript("OnClick", function(self, mouseButton) + if mouseButton == "RightButton" then + local o1, o2 = GetBindingKey(action) + if o1 then SetBinding(o1) end + if o2 then SetBinding(o2) end + SaveBindings(GetCurrentBindingSet()) + self:SetText("Not Bound") + return + end + if self.waitingForKey then + StopKeybindCapture(self, action) + return + end + self.waitingForKey = true + self:SetText("Press a key...") + self:LockHighlight() + Utils.SafeCallMethod(self, "EnableKeyboard", true) + self:SetScript("OnKeyDown", function(s, key) + if key == "LSHIFT" or key == "RSHIFT" or key == "LCTRL" or key == "RCTRL" + or key == "LALT" or key == "RALT" then return end + if key == "ESCAPE" then + StopKeybindCapture(s, action) + return + end + local combo = "" + if IsAltKeyDown() then combo = combo .. "ALT-" end + if IsControlKeyDown() then combo = combo .. "CTRL-" end + if IsShiftKeyDown() then combo = combo .. "SHIFT-" end + combo = combo .. key + local o1, o2 = GetBindingKey(action) + if o1 then SetBinding(o1) end + if o2 then SetBinding(o2) end + SetBinding(combo, action) + SaveBindings(GetCurrentBindingSet()) + StopKeybindCapture(s, action) + end) + end) + btn:HookScript("OnEnter", function(self) + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:AddLine("Recommended: " .. recommended, 1, 1, 1, true) + GameTooltip:AddLine("Click to bind. Right-click to clear.", 0.7, 0.7, 0.7, true) + GameTooltip:Show() + end) + btn:HookScript("OnLeave", GameTooltip_Hide) + return btn + end + + local toggleLabel = CreateKeybindLabel("Toggle bar", LEFT_COL_X) + local mapLabel = CreateKeybindLabel("Open map search", RIGHT_COL_X) + CreateKeybindButton("toggle", toggleLabel, "EASYFIND_TOGGLE_FOCUS", "Ctrl+Space") + CreateKeybindButton("map", mapLabel, "EASYFIND_MAP_FOCUS", "Ctrl+M") + + -- Bottom buttons swapped: "See demo" on the LEFT, "Got it" on the + -- RIGHT. Centered as a pair around the panel's vertical midline. + local seeDemoBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + seeDemoBtn:SetSize(100, 22) + seeDemoBtn:SetPoint("BOTTOMRIGHT", panel, "BOTTOM", -4, 12) + seeDemoBtn:SetText("See demo") - -- "Got it" button: just closes the tutorial, no mode-intro step. local gotItBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") gotItBtn:SetSize(100, 22) - gotItBtn:SetPoint("BOTTOM", panel, "BOTTOM", 0, 12) + gotItBtn:SetPoint("BOTTOMLEFT", panel, "BOTTOM", 4, 12) gotItBtn:SetText("Got it") - local seeDemoBtn = CreateFrame("Button", nil, searchFrame, "UIPanelButtonTemplate") - seeDemoBtn:SetSize(120, 24) - seeDemoBtn:SetPoint("TOP", searchFrame, "BOTTOM", 0, -12) - seeDemoBtn:SetText("See demo") - seeDemoBtn:SetIgnoreParentAlpha(true) - -- During setup: allow drag without holding Shift searchFrame:SetScript("OnDragStart", function(self) self:StartMoving() @@ -6889,10 +7162,11 @@ function UI:ShowFirstTimeSetup() searchFrame.setupMode = nil searchFrame.editBox:EnableMouse(true) UI:UpdateSearchBarTheme() -- restore proper backdrop colors + glow:SetScript("OnUpdate", nil) + glow:Hide() resizer:SetScript("OnUpdate", nil) resizer:Hide() panel:Hide() - seeDemoBtn:Hide() if demoFrame then demoFrame:Hide() end -- Clear the demo suspend flag so the dropdown auto-close works if searchFrame.filterDropdown then @@ -6948,10 +7222,13 @@ function UI:ShowFirstTimeSetup() EasyFind.db.staticOpacity = not fadeCheckbox:GetChecked() UI:UpdateSmartShow() - -- Hide the positioning UI before the demo runs. + -- Hide all setup-mode chrome before the demo takes over the + -- search bar. Without this, the gold "Drag to move" glow + -- lingers on top of the demo. panel:Hide() resizer:Hide() - seeDemoBtn:Hide() + glow:SetScript("OnUpdate", nil) + glow:Hide() startDemo() end) end diff --git a/Utils.lua b/Utils.lua index aa05a70..f99a502 100644 --- a/Utils.lua +++ b/Utils.lua @@ -90,6 +90,53 @@ function Utils.SafeAfter(delay, fn) end) end +--- Key-repeat controller shared by UI search and MapTab nav. Holding +--- a key fires the action immediately, waits INITIAL seconds, then +--- ticks at a rate that accelerates from INITIAL toward FAST over +--- ACCEL seconds. Attach OnKeyUp to `Stop(key)` so releasing the key +--- stops the repeat — pass the key so other keys pressed concurrently +--- don't cancel each other. +--- +--- Returns a table: { Start(key, action), Stop(key?), IsKey(key) }. +function Utils.CreateKeyRepeat(frame, initialDelay, fastDelay, accelDuration) + initialDelay = initialDelay or 0.30 + fastDelay = fastDelay or 0.05 + accelDuration = accelDuration or 1.5 + local repeatKey, repeatAction, repeatNext, repeatHeld + local repeatActive = false + + local function Start(key, action) + action() + repeatKey = key + repeatAction = action + repeatHeld = 0 + repeatNext = initialDelay + repeatActive = true + end + local function Stop(key) + if not key or repeatKey == key then + repeatActive = false + repeatKey = nil + repeatAction = nil + end + end + local function IsKey(key) return repeatKey == key end + + frame:HookScript("OnUpdate", function(_, elapsed) + if not repeatActive then return end + repeatHeld = repeatHeld + elapsed + repeatNext = repeatNext - elapsed + if repeatNext <= 0 then + repeatAction() + local t = repeatHeld / accelDuration + if t > 1 then t = 1 end + repeatNext = initialDelay + (fastDelay - initialDelay) * t + end + end) + + return { Start = Start, Stop = Stop, IsKey = IsKey } +end + --- Scroll a ScrollFrame so that the given child button is visible. --- Uses the button's top/bottom relative to the scrollChild. function Utils.ScrollToButton(scrollFrame, button) From 8a4f15ed44e87320c9eea81959d1a59d4ee7f07d Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Wed, 29 Apr 2026 17:07:20 -0400 Subject: [PATCH 027/103] MapTab integration: keybinds, options, MapSearch hooks, db migration - Bindings / Core / luacheckrc: drop EASYFIND_TOGGLE and EASYFIND_FOCUS (the unified EASYFIND_TOGGLE_FOCUS replaces both); add EASYFIND_MAP_FOCUS bound to EasyFind:FocusMapSearch -> MapTab:Focus. - Core: db migration v4 flips stale localMapDirectOpen and globalMapDirectOpen defaults to true. The prior "Make Fast Mode default" change didn't run a migration, so existing saves carried false through and routed every zone click into the multi-step teach path, making clicks feel like they needed two presses. - Options: replace the legacy Map Search Bars section with a Map Tab section (recent-search count slider, show-recent toggle); refresh the keyboard shortcut help text to match the consolidated bindings. - MapSearch: expose GetFilterBucket and GetCategoryIcon for MapTab; CreateFilterDropdown gains an onChanged callback so MapTab can refresh its own panel without going through the legacy editbox refresh path; highlight / indicator / zone-highlight frames now EnableMouse(false) so decorative chrome can never absorb clicks. - MapSearch: SelectResult tracks pinHoverClearsOverride so guide-mode terminal pins teach hover-to-clear the same way global pins do. --- .luacheckrc | 3 +- Bindings.xml | 9 +- Core.lua | 31 +- MapSearch.lua | 885 +++++++++++++++++++++++++++++++++++++++++++------- Options.lua | 151 ++++----- 5 files changed, 860 insertions(+), 219 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 4cff09e..48c47ba 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -15,10 +15,9 @@ globals = { "SlashCmdList", "StaticPopupDialogs", "SLASH_EASYFIND1", - "BINDING_NAME_EASYFIND_TOGGLE", - "BINDING_NAME_EASYFIND_FOCUS", "BINDING_NAME_EASYFIND_TOGGLE_FOCUS", "BINDING_NAME_EASYFIND_CLEAR", + "BINDING_NAME_EASYFIND_MAP_FOCUS", "EncounterJournal", "TransmogFrame", } diff --git a/Bindings.xml b/Bindings.xml index d582b0d..8d1043f 100644 --- a/Bindings.xml +++ b/Bindings.xml @@ -1,14 +1,11 @@ - - EasyFind:ToggleSearchUI() - - - EasyFind:FocusSearchUI() - EasyFind:ToggleFocusSearchUI() EasyFind:ClearAll() + + EasyFind:FocusMapSearch() + diff --git a/Core.lua b/Core.lua index 669191d..9c839dc 100644 --- a/Core.lua +++ b/Core.lua @@ -12,10 +12,9 @@ EasyFind = {} ns.EasyFind = EasyFind EasyFind._ns = ns -- Expose namespace for dev tools (EasyFindDev) -BINDING_NAME_EASYFIND_TOGGLE = "Toggle UI Search Bar" -BINDING_NAME_EASYFIND_FOCUS = "Resume Typing in Search Bar" -BINDING_NAME_EASYFIND_TOGGLE_FOCUS = "Toggle + Focus Search Bar" +BINDING_NAME_EASYFIND_TOGGLE_FOCUS = "Toggle Search Bar" BINDING_NAME_EASYFIND_CLEAR = "Clear All Highlights" +BINDING_NAME_EASYFIND_MAP_FOCUS = "Open Map Search" local eventFrame = CreateFrame("Frame") ns.eventFrame = eventFrame @@ -24,7 +23,7 @@ EasyFind.db = {} -- SavedVariables version. Increment when changing DB schema. -- Each migration runs once: if saved dbVersion < DB_VERSION, run all steps in order. -local DB_VERSION = 3 +local DB_VERSION = 4 -- SavedVariables defaults - new keys are auto-merged for existing users local DB_DEFAULTS = { @@ -98,6 +97,16 @@ local DB_DEFAULTS = { services = true, rares = true, }, + mapTabFilters = { -- MapTab (unified) category filters. Applied after + zones = true, -- BuildResults to gate each bucket independently + instances = true, -- of the global/local DBs. + travel = true, + services = true, + rares = true, + }, + mapTabRecentSearches = {}, -- Most-recent-first list of past map search queries + mapTabShowRecent = true, -- Toggle for showing recent searches when idle + mapTabRecentCount = 3, -- Number of recent searches to keep / display (1-20) alwaysShowRares = false, -- Persistent rare tracking: show active rares on map without searching uiSearchFilters = { -- UI search category filters ui = true, @@ -147,6 +156,16 @@ local DB_MIGRATIONS = { db.uiMaxResults = nil db.mapMaxResults = nil end, + -- [4] = Flip stale localMapDirectOpen / globalMapDirectOpen defaults. + -- The "Make Fast Mode default" commit changed both defaults from false + -- to true but didn't migrate existing saves. Users carried over + -- false → SelectResult bucketed every zone click into the multi-click + -- teach path (HighlightZoneOnMap), making clicks feel like they + -- needed two presses to register. + [4] = function(db) + if db.localMapDirectOpen == false then db.localMapDirectOpen = true end + if db.globalMapDirectOpen == false then db.globalMapDirectOpen = true end + end, } -- Fields that are runtime-only and must not persist in SavedVariables @@ -536,6 +555,10 @@ function EasyFind:ToggleFocusSearchUI() end end +function EasyFind:FocusMapSearch() + if ns.MapTab then ns.MapTab:Focus() end +end + function EasyFind:OpenOptions() if ns.Options then ns.Options:Toggle() end end diff --git a/MapSearch.lua b/MapSearch.lua index 4c42f03..36530f8 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -332,6 +332,11 @@ local indicatorFrame local waypointPin local zoneHighlightFrame -- For highlighting zones on continent maps local isGlobalSearch = false -- Tracks which search bar triggered the current search +-- Set by SelectResult when invoked in Guide mode (directOverride=false) +-- so the final waypoint placed at the end of the breadcrumb teaches +-- the user how to dismiss it (hover = clear), same as a global-style +-- result. Reset on each SelectResult call. +local pinHoverClearsOverride = nil local activePinState = nil -- {mapID, x, y, icon, category} - survives map close/reopen local mapIsMaximized = false -- Tracks WorldMapFrame maximize state for search bar repositioning local cachedWorldZones -- Built once per session by GetAllWorldZones @@ -1210,6 +1215,14 @@ local function GetCategoryIcon(category) return CATEGORY_ICONS[category] or CATEGORY_ICONS.unknown end +ns.MapSearch = ns.MapSearch or MapSearch +ns.MapSearch.GetCategoryIcon = GetCategoryIcon + +-- GetFilterBucket lives below the CATEGORIES table so it can reference +-- it as an upvalue (Lua resolves at definition time — declaring this +-- function above CATEGORIES would silently treat the name as a global +-- and every parent-based lookup would return nil). + -- Helper: set a texture to a file path, fileDataID, atlas (prefixed "atlas:"), or -- cropped table { file = , coords = { L, R, T, B } }. -- Resets texture state first to prevent texCoord/atlas bleed between rows. @@ -1270,6 +1283,32 @@ local CATEGORIES = { lorewalker = { keywords = {"lorewalker", "cho", "lore walker", "pandaria lore", "flashback", "replay cinematic"}, parent = "service" }, } +-- Classify a result into the MapTab unified-filter bucket. Mirrors +-- the logic inside BuildResults' local/global filter checks. +local function GetFilterBucket(data) + if not data then return "other" end + -- Category takes priority over isZone so dungeons/raids/delves that + -- are map-type Dungeon (and therefore carry isZone=true alongside + -- their category) still bucket into "instances" instead of "zones". + local cat = data.category + if cat == "dungeon" or cat == "raid" or cat == "delve" then return "instances" end + if data.isZone then return "zones" end + if not cat then return "other" end + if cat == "flightmaster" then return "flightpath" end + if cat == "rare" then return "rares" end + local parent = CATEGORIES[cat] and CATEGORIES[cat].parent + if parent == "instance" then return "instances" end + if parent == "travel" then return "travel" end + if parent == "service" or cat == "service" then return "services" end + -- StaticLocations carries narrower categories (classtrainer_*, prof_*, + -- guildbank, etc.) that aren't in the CATEGORIES table. Treat them as + -- services so the filter works on them too. + if sfind(cat, "^classtrainer_") or sfind(cat, "^prof_") then return "services" end + if cat == "guildbank" or cat == "guildservices" or cat == "trainingdummy" then return "services" end + return "other" +end +ns.MapSearch.GetFilterBucket = GetFilterBucket + -- Categories allowed in global (cross-zone) search results. -- Everything else (services, travel, etc.) is excluded to keep global results clean. local GLOBAL_SEARCH_CATEGORIES = { @@ -1294,7 +1333,7 @@ function MapSearch:Initialize() end -- SHARED FILTER DROPDOWN BUILDER - creates a tracking-menu-style checkbox panel -function MapSearch:CreateFilterDropdown(globalName, options, dbKey, toggleBtn, anchorFrame, searchEditBox) +function MapSearch:CreateFilterDropdown(globalName, options, dbKey, toggleBtn, anchorFrame, searchEditBox, onChanged) local ROW_HEIGHT = 20 local DROPDOWN_WIDTH = 207 local PADDING_TOP = 8 @@ -1367,9 +1406,10 @@ function MapSearch:CreateFilterDropdown(globalName, options, dbKey, toggleBtn, a row:SetScript("OnClick", function(self) local filters = EasyFind.db[dbKey] filters[opt.key] = self:GetChecked() - if searchEditBox:GetText() ~= "" then + if searchEditBox and searchEditBox.GetText and searchEditBox:GetText() ~= "" then MapSearch:OnSearchTextChanged(searchEditBox:GetText()) end + if onChanged then onChanged(opt.key, self:GetChecked()) end end) checkRows[opt.key] = row @@ -2810,6 +2850,9 @@ function MapSearch:CreateHighlightFrame() highlightFrame:SetSize(64, 64) highlightFrame:SetFrameStrata("TOOLTIP") highlightFrame:SetFrameLevel(2000) + -- Decoration only — never absorb clicks even if it ends up over a + -- clickable region in some map mode (maximized canvas, etc.). + highlightFrame:EnableMouse(false) highlightFrame:Hide() local top = highlightFrame:CreateTexture(nil, "OVERLAY") @@ -2832,6 +2875,7 @@ function MapSearch:CreateHighlightFrame() indicatorFrame = CreateFrame("Frame", "EasyFindMapIndicator", highlightFrame) indicatorFrame:SetSize(ns.ICON_SIZE, ns.ICON_SIZE) indicatorFrame:SetPoint("BOTTOM", highlightFrame, "TOP", 0, 2) + indicatorFrame:EnableMouse(false) ns.CreateIndicatorTextures(indicatorFrame) local animGroup = highlightFrame:CreateAnimationGroup() @@ -2935,6 +2979,11 @@ function MapSearch:CreateZoneHighlightFrame() zoneHighlightFrame:SetFrameStrata("TOOLTIP") -- High strata to be visible zoneHighlightFrame:SetFrameLevel(400) zoneHighlightFrame:SetAllPoints(WorldMapFrame.ScrollContainer.Child) + -- Decorative overlay only — must never absorb clicks. At TOOLTIP + -- strata it's the topmost frame in WorldMapFrame; if the canvas + -- extents reach under the MapTab side panel (maximized map), a + -- mouse-enabled overlay there would eat row clicks. + zoneHighlightFrame:EnableMouse(false) zoneHighlightFrame:Hide() -- Store references to zone highlight textures @@ -2963,6 +3012,7 @@ function MapSearch:CreateZoneHighlightFrame() zoneInd:SetSize(ns.ICON_SIZE, ns.ICON_SIZE) zoneInd:SetFrameStrata("TOOLTIP") zoneInd:SetFrameLevel(500) + zoneInd:EnableMouse(false) ns.CreateIndicatorTextures(zoneInd) local zoneIndAnimGroup = zoneInd:CreateAnimationGroup() @@ -3137,8 +3187,55 @@ local ZONE_ABBREVIATIONS = { ["ek"] = "eastern kingdoms", ["kali"] = "kalimdor", ["dk"] = "acherus: the ebon hold", + -- Continent shorthand for names the normal prefix/substring matcher + -- can't reach ("nr" isn't inside "northrend", "wod" isn't inside + -- "draenor", etc.). Plain prefixes like "drae", "pand", "khaz" are + -- intentionally omitted because substring matching already handles + -- them. + ["nr"] = "northrend", + ["ol"] = "outland", + ["tbc"] = "outland", + ["sl"] = "shadowlands", + ["wod"] = "draenor", + ["di"] = "dragon isles", + ["df"] = "dragon isles", + ["bi"] = "broken isles", + ["leg"] = "broken isles", + ["mop"] = "pandaria", + ["tww"] = "khaz algar", } +ns.MapSearch.ZONE_ABBREVIATIONS = ZONE_ABBREVIATIONS + +-- Per-query cache for SearchZones, keyed on mode (local vs global). +-- Stores recently-run queries so backspace hits cache (the previous +-- query's result set is still in memory) and typing extensions still +-- narrow from the most recent entry. LRU-evicts when capacity exceeds +-- SEARCH_CACHE_MAX. Invalidated alongside cachedWorldZones. +local SEARCH_CACHE_MAX = 32 +local searchZonesCache = { + local_ = { entries = {}, order = {}, lastQuery = "" }, + global_ = { entries = {}, order = {}, lastQuery = "" }, +} +local function ResetSearchZonesCache() + for _, c in pairs(searchZonesCache) do + wipe(c.entries); wipe(c.order); c.lastQuery = "" + end +end +ns.MapSearch.ResetSearchZonesCache = ResetSearchZonesCache + +local function CachePut(cache, query, value) + if cache.entries[query] == nil then + cache.order[#cache.order + 1] = query + if #cache.order > SEARCH_CACHE_MAX then + local oldest = tremove(cache.order, 1) + cache.entries[oldest] = nil + end + end + cache.entries[query] = value + cache.lastQuery = query +end + function MapSearch:SearchZones(query) if not query or query == "" then return {} end @@ -3179,11 +3276,36 @@ function MapSearch:SearchZones(query) zones = self:GetDirectChildZones() end + -- Query cache: exact-hit returns cached results (covers backspace + -- and re-typing). Extension of the last query narrows from its + -- match set. Anything else falls through to a full scan. + local cacheKey = isGlobalSearch and "global_" or "local_" + local cache = searchZonesCache[cacheKey] + local cachedHit = cache.entries[query] + if cachedHit then + cache.lastQuery = query + return cachedHit + end + local candidates = zones + if cache.lastQuery ~= "" + and #query > #cache.lastQuery + and query:sub(1, #cache.lastQuery) == cache.lastQuery then + local prev = cache.entries[cache.lastQuery] + if prev then candidates = prev end + end + local matches = {} local abbrevTarget = ZONE_ABBREVIATIONS[query] -- check once outside loop - for _, zone in ipairs(zones) do - local nameLower = slower(zone.name) + for _, zone in ipairs(candidates) do + -- Cache the lowercased name on the zone itself. cachedWorldZones + -- persists across searches, so this pays the slower() cost once + -- per zone instead of every keystroke (global mode = 1500 zones). + local nameLower = zone.nameLower + if not nameLower then + nameLower = slower(zone.name) + zone.nameLower = nameLower + end local score = ns.Database:ScoreName(nameLower, query, #query) -- Check abbreviation match (e.g. "sw" → "stormwind city") @@ -3191,6 +3313,14 @@ function MapSearch:SearchZones(query) score = mmax(score, 200) -- Treat as exact match end + -- Ancestor matching is intentionally NOT done here. If the + -- parent zone matches the query, the renderer expands ALL of + -- its children via GetWorldChildren — no need to inject + -- partial children into the results, which produced the + -- inconsistent "only some children show" behavior where + -- whether a child surfaced depended on whether its name + -- happened to share characters with the query. + if score >= 50 then zone.score = score tinsert(matches, zone) @@ -3205,6 +3335,9 @@ function MapSearch:SearchZones(query) return a.name < b.name end) + -- Store under the current query so backspace/retyping hits cache. + CachePut(cache, query, matches) + return matches end @@ -3443,6 +3576,161 @@ local function FindSurroundingZone(parentMapID, mapID, left, right, top, bottom, if bestID then return zones[bestID] end end +-- Hover-safe variant of HighlightZone: draws a translucent yellow rect +-- where the zone sits on the currently-viewed map, but only if the zone +-- is actually visible there. Strict no-side-effects contract: +-- * never calls SetMapID (would trigger OnMapChanged → MapTab refresh) +-- * never touches self.pendingZoneHighlight (no nav state) +-- * bails silently when the zone isn't on the current map, when we're +-- already viewing it, or when its rect is degenerate +-- Used by RunHoverPreview so hovering a result row never moves the map +-- and never re-renders the results window. +function MapSearch:PreviewZoneHighlight(mapID) + if not zoneHighlightFrame then return end + if not WorldMapFrame or not WorldMapFrame.ScrollContainer then return end + local canvas = WorldMapFrame.ScrollContainer.Child + if not canvas then return end + + local parentMapID = WorldMapFrame:GetMapID() + if not parentMapID then return end + + local resolved = ResolveZoneForMap(mapID, parentMapID) + if resolved ~= mapID then mapID = resolved end + + -- Already inside the zone's own map: no preview to draw (the rect + -- would just be the entire canvas). + if mapID == parentMapID then return end + + -- Ancestor of the currently-viewed map (Kalimdor when on Orgrimmar, + -- Durotar when on Orgrimmar, etc.). The current canvas IS the + -- ancestor's territory, so the only sensible "preview" would cover + -- the whole map — which both reads as a glitch and isn't what the + -- user is asking to be shown. Bail. + do + local cur = parentMapID + for _ = 1, 15 do + local info = GetMapInfo(cur) + if not info or not info.parentMapID or info.parentMapID == 0 then break end + if info.parentMapID == mapID then return end + cur = info.parentMapID + end + end + + local ok, left, right, top, bottom = pcall(GetMapRectOnMap, mapID, parentMapID) + if not ok or not left then return end + + if left == 0 and right == 0 and top == 0 and bottom == 0 then + local pL, pR, pT, pB = GetMapRectViaContinent(mapID, parentMapID) + if not pL then return end + left, right, top, bottom = pL, pR, pT, pB + end + + -- Degenerate or implausibly-large rect: ancestor escapees that the + -- chain check above missed (different continent root, projection + -- math overflow) tend to produce widths >> 1. Tight gates here keep + -- the preview "this zone fits sensibly inside the visible canvas". + if (right - left) < 0.01 or (bottom - top) < 0.01 then return end + if (right - left) > 1.05 or (bottom - top) > 1.05 then return end + if right < 0.02 or left > 0.98 or bottom < 0.02 or top > 0.98 then return end + + local clampedL = mmax(0, left) + local clampedR = mmin(1, right) + local clampedT = mmax(0, top) + local clampedB = mmin(1, bottom) + if (clampedR - clampedL) < 0.01 or (clampedB - clampedT) < 0.01 then return end + -- Clamped rect covers essentially the whole canvas — it's a + -- "this zone is the entire view" case. No useful preview. + if (clampedR - clampedL) >= 0.95 and (clampedB - clampedT) >= 0.95 then return end + + local canvasWidth, canvasHeight = canvas:GetSize() + local centerX = (left + right) / 2 + local centerY = (top + bottom) / 2 + local zoneCenterPxX = centerX * canvasWidth + local zoneCenterPxY = centerY * canvasHeight + + -- Hide all pooled textures first so previous-hover residue is gone + -- before we paint the new preview. + for i = 1, #zoneHighlightFrame.highlights do + local hl = zoneHighlightFrame.highlights[i] + hl:Hide() + hl:ClearAllPoints() + hl:SetTexture(nil) + hl:SetTexCoord(0, 1, 0, 1) + end + + -- Try the API's actual zone-shape texture (matches Blizzard's hover + -- behavior on the world map). Falls back to a translucent rect if + -- the API returns nothing useful or the texture belongs to a + -- different zone (cities pick up their containing zone's outline at + -- the center sample). + local fileDataID, atlasID, texPercentX, texPercentY, texWidth, texHeight, posX, posY + local highlightSuccess = pcall(function() + fileDataID, atlasID, texPercentX, texPercentY, texWidth, texHeight, posX, posY = + GetMapHighlightInfoAtPosition(parentMapID, centerX, centerY) + end) + + local hasTexture = highlightSuccess and posX and posY and texPercentX and texPercentY + and ((fileDataID and fileDataID > 0) or (atlasID and atlasID ~= "")) + + if hasTexture then + local resolvedInfo = GetMapInfoAtPosition(parentMapID, centerX, centerY) + if resolvedInfo and resolvedInfo.mapID ~= mapID then + hasTexture = false + end + end + + if hasTexture then + local pixelPosX = posX * canvasWidth + local pixelPosY = posY * canvasHeight + local pixelWidth = texWidth * canvasWidth + local pixelHeight = texHeight * canvasHeight + local isAtlas = not fileDataID or fileDataID == 0 + + local layers = isAtlas and 2 or 4 + for i = 1, layers do + local hl = zoneHighlightFrame.highlights[i] + if hl then + hl:ClearAllPoints() + if not isAtlas then + hl:SetTexture(fileDataID) + hl:SetTexCoord(0, texPercentX, 0, texPercentY) + hl:SetPoint("TOPLEFT", canvas, "TOPLEFT", pixelPosX, -pixelPosY) + hl:SetSize(pixelWidth, pixelHeight) + hl:SetVertexColor(YELLOW_HIGHLIGHT[1], YELLOW_HIGHLIGHT[2], YELLOW_HIGHLIGHT[3], 1) + else + hl:SetAtlas(atlasID, true) + hl:SetPoint("CENTER", canvas, "TOPLEFT", zoneCenterPxX, -zoneCenterPxY) + hl:SetVertexColor(YELLOW_HIGHLIGHT[1], YELLOW_HIGHLIGHT[2], YELLOW_HIGHLIGHT[3], 0.6) + end + hl:SetBlendMode("ADD") + hl:Show() + end + end + else + -- No zone-shape texture available: fall back to a translucent + -- rectangle covering the zone's bounding rect. Same rect path + -- HighlightZone uses for "bugged" zones. + local x = clampedL * canvasWidth + local y = clampedT * canvasHeight + local w = (clampedR - clampedL) * canvasWidth + local h = (clampedB - clampedT) * canvasHeight + + local highlight = zoneHighlightFrame.highlights[1] + if highlight then + highlight:ClearAllPoints() + highlight:SetTexture("Interface\\Buttons\\WHITE8x8") + highlight:SetTexCoord(0, 1, 0, 1) + highlight:SetVertexColor(YELLOW_HIGHLIGHT[1], YELLOW_HIGHLIGHT[2], YELLOW_HIGHLIGHT[3], 0.20) + highlight:SetBlendMode("BLEND") + highlight:SetPoint("TOPLEFT", canvas, "TOPLEFT", x, -y) + highlight:SetSize(w, h) + highlight:Show() + end + end + + zoneHighlightFrame:Show() +end + -- Highlight a zone on the continent map using the actual zone shape texture function MapSearch:HighlightZone(mapID) DebugPrint("[EasyFind] HighlightZone called for mapID:", mapID) @@ -4977,6 +5265,90 @@ end -- Built once on first global search, then reused. local globalInstanceCache +-- Invalidate map caches when Blizzard signals the map/world state may +-- have become richer. Without this, a first-search call runs before +-- some lazy map APIs have finished populating and both caches end up +-- partial; later searches reuse the stale cache and miss descendant +-- zones (e.g. Northrend's children) entirely. +local localScanCache = nil +local LOCAL_SCAN_TTL = 1.0 +-- Cache of promoted instance POIs (zone-style entries with breadcrumb +-- paths) so BuildResults doesn't allocate ~300 new tables per keystroke. +-- Built once when globalInstanceCache + cachedWorldZones are ready, +-- invalidated alongside them. +local promotedInstancePOIs = nil +do + local invalidator = CreateFrame("Frame") + invalidator:RegisterEvent("PLAYER_ENTERING_WORLD") + invalidator:RegisterEvent("ZONE_CHANGED_NEW_AREA") + invalidator:RegisterEvent("CHALLENGE_MODE_MAPS_UPDATE") + invalidator:SetScript("OnEvent", function() + cachedWorldZones = nil + globalInstanceCache = nil + promotedInstancePOIs = nil + localScanCache = nil + -- Use namespace lookups for reset helpers defined further down + -- in the file; locals declared after this invalidator block + -- aren't visible to its closure. + if ns.MapSearch.ResetSearchZonesCache then ns.MapSearch.ResetSearchZonesCache() end + if ns.MapSearch.ResetSearchPoisCache then ns.MapSearch.ResetSearchPoisCache() end + end) +end + +-- Local-scope caches (SearchZones local_, SearchPOIs local_, +-- localScanCache) are tied to whichever map WorldMapFrame currently +-- shows — its direct child zones, dungeon entrances, etc. The +-- character-zone events above DON'T fire when the player navigates +-- the world map UI to another zone, so without this hook a previous +-- map's child zones would bleed into "This Zone (currentMap)" results. +do + local function FlushLocalCaches() + localScanCache = nil + if ns.MapSearch.ResetSearchZonesCache then ns.MapSearch.ResetSearchZonesCache() end + if ns.MapSearch.ResetSearchPoisCache then ns.MapSearch.ResetSearchPoisCache() end + end + if WorldMapFrame and type(WorldMapFrame.OnMapChanged) == "function" then + hooksecurefunc(WorldMapFrame, "OnMapChanged", FlushLocalCaches) + else + local f = CreateFrame("Frame") + f:RegisterEvent("PLAYER_LOGIN") + f:SetScript("OnEvent", function(self) + self:UnregisterAllEvents() + if WorldMapFrame and type(WorldMapFrame.OnMapChanged) == "function" then + hooksecurefunc(WorldMapFrame, "OnMapChanged", FlushLocalCaches) + end + end) + end +end + +-- Runs every scan needed for local-mode BuildResults in one shot and +-- caches the result keyed on mapID. Typing `delve` used to rescan the +-- current map's POIs, dungeon entrances, flight masters, and vignettes +-- on every keystroke — each scan calls Blizzard APIs repeatedly and +-- adds up to hundreds of milliseconds per search in a busy zone. A +-- 1-second TTL is long enough to coalesce a typing burst but short +-- enough that a freshly-placed pin shows up on the user's next natural +-- pause. +local function GetLocalScans(self) + local mapID = WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID() or 0 + local now = GetTime() + local cache = localScanCache + if cache and cache.mapID == mapID and (now - cache.ts) < LOCAL_SCAN_TTL then + return cache.dynamicPOIs, cache.dungeonEntrances, + cache.flightMasters, cache.vignetteRares + end + local dynamicPOIs = self:ScanMapPOIs() + local dungeonEntrances = self:ScanDungeonEntrances() + local flightMasters = self:ScanFlightMasters() + local vignetteRares = self:ScanVignettes() + localScanCache = { + mapID = mapID, ts = now, + dynamicPOIs = dynamicPOIs, dungeonEntrances = dungeonEntrances, + flightMasters = flightMasters, vignetteRares = vignetteRares, + } + return dynamicPOIs, dungeonEntrances, flightMasters, vignetteRares +end + function MapSearch:GetGlobalInstanceCache() if globalInstanceCache then return globalInstanceCache end @@ -5099,6 +5471,11 @@ function MapSearch:ScanFlightMasters(mapID) local fmMapInfo = GetMapInfo(mapID) local fmParentInfo = fmMapInfo and fmMapInfo.parentMapID and GetMapInfo(fmMapInfo.parentMapID) local fmShouldFilter = fmParentInfo and fmParentInfo.mapType == Enum.UIMapType.Continent + -- Continent-level scans need per-node zone resolution so each FM + -- gets the actual child zone it belongs to (Hillsbrad, EP, etc.) + -- rather than the continent. MapTab uses parentMapID for zone-level + -- sub-grouping under the continent header. + local resolvePerNode = (fmMapInfo and fmMapInfo.mapType == Enum.UIMapType.Continent) or fmShouldFilter for _, node in ipairs(nodes) do if node.name and node.position then @@ -5113,21 +5490,34 @@ function MapSearch:ScanFlightMasters(mapID) if not skip then local x, y = node.position.x, node.position.y if x >= 0 and x <= 1 and y >= 0 and y <= 1 then - local fmInclude = true - if fmShouldFilter then - local posInfo = GetMapInfoAtPosition and GetMapInfoAtPosition(mapID, x, y) - fmInclude = posInfo and (posInfo.mapID == mapID or posInfo.parentMapID == mapID) - end - if fmInclude then - tinsert(results, { - name = node.name .. " (Flight Master)", - category = "flightmaster", - icon = "atlas:TaxiNode_Neutral", - isStatic = true, - x = x, - y = y, - keywords = {"flight", "fly", "taxi", "fp", "flight master"}, - }) + local fmInclude = true + local nodeParentMapID = mapID + if resolvePerNode then + local posInfo = GetMapInfoAtPosition and GetMapInfoAtPosition(mapID, x, y) + if posInfo and posInfo.mapID then + nodeParentMapID = posInfo.mapID + if fmShouldFilter then + fmInclude = (posInfo.mapID == mapID or posInfo.parentMapID == mapID) + end + elseif fmShouldFilter then + fmInclude = false + end + end + if fmInclude then + tinsert(results, { + name = node.name .. " (Flight Master)", + category = "flightmaster", + icon = "atlas:TaxiNode_Neutral", + isStatic = true, + x = x, + y = y, + parentMapID = nodeParentMapID, + -- (x,y) are valid on this map's coordinate system. + -- Used to gate hover previews so an FM scanned in + -- one zone doesn't render a pin in another. + coordMapID = mapID, + keywords = {"flight", "fly", "taxi", "fp", "flight master"}, + }) end end end @@ -5155,7 +5545,13 @@ function MapSearch:ScanAllFlightMasters() for _, child in ipairs(children) do if child.name then local mt = child.mapType - if mt == Enum.UIMapType.Zone or mt == Enum.UIMapType.Continent then + -- Zones only. A continent scan returns the same nodes + -- as its child zones (just expressed in continent + -- coordinates), and the recursion below already covers + -- every zone — so scanning continents would just + -- produce a duplicate of every FM with a coordMapID + -- that's wrong for whichever map the player is viewing. + if mt == Enum.UIMapType.Zone then local nodes = self:ScanFlightMasters(child.mapID) for _, node in ipairs(nodes) do local key = node.name .. "|" .. child.mapID @@ -5370,6 +5766,13 @@ function MapSearch:ScanMapPOIs(mapID) if areaPOIs then for _, poiID in ipairs(areaPOIs) do local poiInfo = GetAreaPOIInfo(mapID, poiID) + -- Skip POIs that are shown on this map but primarily + -- belong to an adjacent map (e.g. a boat dock near a zone + -- border that renders on both zones' maps). Keeps each POI + -- attributed to its home zone only. + if poiInfo and poiInfo.isPrimaryMapForPOI == false then + poiInfo = nil + end if poiInfo and poiInfo.name then local category = nil -- Start with nil, only add if we categorize it local poiName = slower(poiInfo.name or "") @@ -5856,13 +6259,15 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) end end - -- Add cache entries as zone-style results with full breadcrumb paths. - -- Each entry gets isZone + entranceX/Y so both rendering and - -- preview/click use the same unified code path. - for _, poi in ipairs(instancePOIs) do - if not zoneNames[slower(poi.name)] and not zoneNames[normalizeName(poi.name)] then + -- Promote cache entries to zone-style results once, then reuse + -- the promoted tables across every keystroke. Previously each + -- BuildResults call allocated ~300 new tables (14 fields each) + -- which was the dominant per-keystroke cost. + if not promotedInstancePOIs then + promotedInstancePOIs = {} + for _, poi in ipairs(instancePOIs) do local fullPath = pathForMap[poi.entranceMapID] - tinsert(allPOIs, { + promotedInstancePOIs[#promotedInstancePOIs + 1] = { name = poi.name, category = poi.category, icon = poi.icon, @@ -5877,23 +6282,47 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) entranceCategory = poi.category, pathPrefix = fullPath or poi.pathPrefix, keywords = poi.keywords, - }) + } + end + end + -- promotedInstancePOIs lives across queries (rebuilt only when + -- the global instance cache invalidates). SearchPOIs mutates + -- poi.score / duplicateKey / allInstances on every match, and + -- the first-pass pass-through (`if poi.isZone and poi.score`) + -- blindly trusts the score field on the next call — so a prior + -- "raid" or "dungeon" search leaves every instance scored 150, + -- and an unrelated query like "tol" inherits the lot. Clear + -- before each scan to force fresh scoring and dedup. + for i = 1, #promotedInstancePOIs do + local p = promotedInstancePOIs[i] + p.score = nil + p.duplicateKey = nil + p.allInstances = nil + if not zoneNames[slower(p.name)] and not zoneNames[normalizeName(p.name)] then + allPOIs[#allPOIs + 1] = p + end + end + + -- Flight masters are global too, not just "This Zone". The local + -- pass already includes FMs on the current map; the dedupe in + -- MapTab keys FMs by name so a duplicate doesn't surface in both + -- This Zone and Across the World. Cached after first call — + -- same per-call cleanup as promoted instances. + local allFMs = self:ScanAllFlightMasters() + for i = 1, #allFMs do + local fm = allFMs[i] + fm.score = nil + fm.duplicateKey = nil + fm.allInstances = nil + if not zoneNames[slower(fm.name)] then + allPOIs[#allPOIs + 1] = fm end end else -- Get both dynamic pins and static locations for current map - local dynamicPOIs = self:ScanMapPOIs() + local dynamicPOIs, dungeonEntrances, flightMasters, vignetteRares = GetLocalScans(self) local staticLocations = self:GetStaticLocations() - -- Get dungeon/raid entrance locations for current map - local dungeonEntrances = self:ScanDungeonEntrances() - - -- Get flight master locations for current map - local flightMasters = self:ScanFlightMasters() - - -- Scan active rares via vignette API - local vignetteRares = self:ScanVignettes() - -- Coordinate-based sources first (dungeon entrances, flight masters) so they -- take priority over pin-only entries from ScanMapPOIs during deduplication. -- Pin-only entries lack x/y and go through HighlightPin (no icon), while @@ -6040,6 +6469,21 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) return results end +-- Per-query cache for SearchPOIs, mirrors the SearchZones cache. Keeps +-- recent queries so backspace re-hits cached results instead of doing +-- a fresh scan. Extension of the last query still narrows from its +-- match set. +local searchPoisCache = { + local_ = { entries = {}, order = {}, lastQuery = "", lastCategory = nil }, + global_ = { entries = {}, order = {}, lastQuery = "", lastCategory = nil }, +} +local function ResetSearchPoisCache() + for _, c in pairs(searchPoisCache) do + wipe(c.entries); wipe(c.order); c.lastQuery = ""; c.lastCategory = nil + end +end +ns.MapSearch.ResetSearchPoisCache = ResetSearchPoisCache + function MapSearch:SearchPOIs(pois, query) query = slower(query) wipe(reuseSearchResults) @@ -6052,9 +6496,52 @@ function MapSearch:SearchPOIs(pois, query) local matchedCategory = self:GetCategoryMatch(query) local relatedCategories = matchedCategory and self:GetRelatedCategories(matchedCategory) or {} + -- Query cache: exact hit returns cached results (handles backspace, + -- retyping, and repeated same-query calls within one render). + -- Extension from lastQuery narrows scoring to its cached matches. + local cacheKey = isGlobalSearch and "global_" or "local_" + local cache = searchPoisCache[cacheKey] + local cachedHit = cache.entries[query] + if cachedHit and cachedHit.matchedCategory == matchedCategory then + cache.lastQuery = query + cache.lastCategory = matchedCategory + return cachedHit.results + end + local candidates = pois + local candidatesAreCached = false + if cache.lastQuery ~= "" + and #query > #cache.lastQuery + and query:sub(1, #cache.lastQuery) == cache.lastQuery + and cache.lastCategory == matchedCategory then + local prev = cache.entries[cache.lastQuery] + if prev then + candidates = prev.results + candidatesAreCached = true + end + end + -- Cached candidates carry poi.score from the previous query's + -- scoring run. The first-pass pass-through (`if poi.isZone and + -- poi.score`) would treat that as the current score — so typing + -- "to" then "tol" pulls Cape of Stranglethorn (which scored on + -- "to" via initials) into "tol" with the old score intact. Clear + -- per-entry score so the narrowed pass re-scores fresh. + if candidatesAreCached then + for i = 1, #candidates do + candidates[i].score = nil + end + end + -- First pass: name matches - for _, poi in ipairs(pois) do - local nameLower = slower(poi.name) + for _, poi in ipairs(candidates) do + -- Cache nameLower on the POI so repeated keystrokes don't + -- re-lowercase the same name. scan-derived POIs live in the + -- localScanCache (1s TTL) and global instance cache (long-lived) + -- so the cached value sticks for the lifetime of the POI. + local nameLower = poi.nameLower + if not nameLower then + nameLower = slower(poi.name) + poi.nameLower = nameLower + end local key = poi.name .. (poi.category or "") .. (poi.isZone and poi.pathPrefix or "") @@ -6159,6 +6646,22 @@ function MapSearch:SearchPOIs(pois, query) end return a.score > b.score end) + + -- Snapshot for the query cache. Shallow-copy because `results` + -- points at reuseSearchResults which the next call wipes. + local snapshot = {} + for i = 1, #results do snapshot[i] = results[i] end + if cache.entries[query] == nil then + cache.order[#cache.order + 1] = query + if #cache.order > SEARCH_CACHE_MAX then + local oldest = tremove(cache.order, 1) + cache.entries[oldest] = nil + end + end + cache.entries[query] = { matchedCategory = matchedCategory, results = snapshot } + cache.lastQuery = query + cache.lastCategory = matchedCategory + return results end @@ -6664,11 +7167,23 @@ function MapSearch:NavigateToEntrance(name, x, y, icon, category, targetMapID, d end end -function MapSearch:SelectResult(data) +-- directOverride: optional. When non-nil, takes precedence over the +-- per-surface *MapDirectOpen SavedVariables. Used by the right-click +-- Guide menu to force breadcrumb/teaching mode regardless of the +-- user's default left-click setting. +function MapSearch:SelectResult(data, directOverride) -- Clear preview state so OnLeave doesn't undo the real selection self._previewing = nil self._savedPinState = nil self._suppressTextChanged = true + -- Every MapTab/UI-search click hover-clears now. The legacy + -- "local = tooltip on hover, global = hover clears" split was + -- meaningful only when the floating local + global bars asked the + -- user to pick a scope; with MapTab merging them, the consistent + -- expectation is that hovering off the pin clears it. Pinned + -- waypoints placed via the map are unaffected (they go through a + -- different code path). + pinHoverClearsOverride = true -- Floating bars may be gone (deprecated) - guard the editbox clears. if searchFrame and searchFrame.editBox then searchFrame.editBox:SetText("") @@ -6701,7 +7216,9 @@ function MapSearch:SelectResult(data) end local directMode - if isGlobalSearch then + if directOverride ~= nil then + directMode = directOverride + elseif isGlobalSearch then directMode = EasyFind.db.globalMapDirectOpen or false else directMode = EasyFind.db.localMapDirectOpen or false @@ -6716,13 +7233,18 @@ function MapSearch:SelectResult(data) DebugPrint("[EasyFind] SelectResult → ORPHAN ZONE, snapping directly to", data.zoneMapID) self:ClearZoneHighlight() WorldMapFrame:SetMapID(data.zoneMapID) - elseif data.entranceX and data.entranceY and data.entranceMapID then - DebugPrint("[EasyFind] SelectResult → ZONE+ENTRANCE branch, entranceMapID=", data.entranceMapID) - self:NavigateToEntrance(data.name, data.entranceX, data.entranceY, data.entranceIcon, data.entranceCategory, data.entranceMapID, directMode) elseif directMode then + -- Direct (Fast) mode: every zone click zooms straight into the + -- zone's own map. Skipping NavigateToEntrance keeps behavior + -- uniform — without this, sub-zones with entrance coords + -- (Vale of Eternal Blossoms, etc.) would pin on the parent + -- and require a second click on the pin to actually enter. DebugPrint("[EasyFind] SelectResult → ZONE DIRECT branch, zoneMapID=", data.zoneMapID) self:ClearZoneHighlight() WorldMapFrame:SetMapID(data.zoneMapID) + elseif data.entranceX and data.entranceY and data.entranceMapID then + DebugPrint("[EasyFind] SelectResult → ZONE+ENTRANCE branch, entranceMapID=", data.entranceMapID) + self:NavigateToEntrance(data.name, data.entranceX, data.entranceY, data.entranceIcon, data.entranceCategory, data.entranceMapID, directMode) else DebugPrint("[EasyFind] SelectResult → ZONE TEACHING branch, zoneMapID=", data.zoneMapID) self:HighlightZoneOnMap(data.zoneMapID, data.name) @@ -7000,7 +7522,11 @@ function MapSearch:ShowMultipleWaypoints(instances) pin.waypointX = instance.x pin.waypointY = instance.y pin.waypointCategory = instance.category - pin.isLocalSearch = not isGlobalSearch + if pinHoverClearsOverride == true then + pin.isLocalSearch = false + else + pin.isLocalSearch = not isGlobalSearch + end local iconTexture = GetCategoryIcon(instance.category) if instance.icon then @@ -7114,7 +7640,11 @@ function MapSearch:ShowWaypointAt(x, y, icon, category) waypointPin:SetPoint("CENTER", canvas, "TOPLEFT", canvasWidth * x, -canvasHeight * y) waypointPin.waypointX = x waypointPin.waypointY = y - waypointPin.isLocalSearch = not isGlobalSearch + if pinHoverClearsOverride == true then + waypointPin.isLocalSearch = false + else + waypointPin.isLocalSearch = not isGlobalSearch + end waypointPin:Show() if self._previewing then waypointPin:EnableMouse(false) @@ -7230,6 +7760,110 @@ function MapSearch:HighlightPin(pin, x, y, icon, category) end end +-- Shared hover-preview entry point. Shows the hovered pin ALONGSIDE +-- any pin the user already clicked, by reusing ShowMultipleWaypoints — +-- same mechanism that handles multi-instance results like auction +-- houses. Saves activePinState on first preview so EndHoverPreview can +-- cleanly restore to the clicked-only state when the cursor moves off. +function MapSearch:RunHoverPreview(data) + if not data then return end + + -- Snapshot existing pin state once per hover session so EndHoverPreview + -- can restore cleanly. PreviewZoneHighlight has no side effects, so + -- there's no zone-navigation state to save. + if not self._previewing then + self._savedPinState = activePinState + end + self._previewing = true + + -- Zone-area preview: when hovering a zone result, draw a translucent + -- rect where the zone sits on the currently-viewed map. Strictly + -- visible-only — PreviewZoneHighlight bails when the zone isn't on + -- this map, when we're already inside it, and never changes maps. + self._previewingZone = nil + if data.isZone and data.zoneMapID and self.PreviewZoneHighlight then + self:PreviewZoneHighlight(data.zoneMapID) + self._previewingZone = data.zoneMapID + end + + if not self.GetPreviewCoords then return end + local coords = self:GetPreviewCoords(data) + if not coords then + -- No pin coords: zone-highlight (if any) is still active, that's + -- the whole preview. activePinState is unchanged. + return + end + + -- Build a composite: existing clicked pin(s) + the hovered pin. + local composite = {} + local saved = self._savedPinState + if saved and saved.mapID == WorldMapFrame:GetMapID() then + if saved.instances then + for i = 1, #saved.instances do + composite[#composite + 1] = saved.instances[i] + end + elseif saved.x and saved.y then + composite[#composite + 1] = { + x = saved.x, y = saved.y, + icon = saved.icon, category = saved.category, + } + end + end + + if coords.pin and coords.pin:IsShown() then + -- Hovering a native canvas pin: show any saved pins alongside, + -- then glow the hovered native pin in place. + if #composite > 0 then self:ShowMultipleWaypoints(composite) end + self:HighlightPin(coords.pin, coords.x, coords.y, coords.icon, coords.category) + elseif coords.instances then + for i = 1, #coords.instances do + composite[#composite + 1] = coords.instances[i] + end + self:ShowMultipleWaypoints(composite) + elseif coords.x and coords.y then + composite[#composite + 1] = { + x = coords.x, y = coords.y, + icon = coords.icon, category = coords.category, + } + if #composite > 1 then + self:ShowMultipleWaypoints(composite) + else + self:ShowWaypointAt(coords.x, coords.y, coords.icon, coords.category) + end + end + + -- Always restore activePinState — even to nil — so hover never + -- persists as the "active" clicked pin. Without unconditional + -- restoration, hovering when nothing is clicked would silently + -- promote the previewed pin into the real active state, which + -- downstream code (auto-track on map reopen, etc.) latches onto. + activePinState = self._savedPinState +end + +function MapSearch:EndHoverPreview() + if not self._previewing then return end + self._previewing = nil + if self._previewingZone then + -- ClearZoneHighlight nukes pendingZoneHighlight as a side effect. + -- Preserve it across the call so a real click-driven nav chain + -- (set somewhere else, untouched by the hover preview) survives. + local savedPending = self.pendingZoneHighlight + self:ClearZoneHighlight() + self.pendingZoneHighlight = savedPending + self._previewingZone = nil + end + self:ClearHighlight() + local saved = self._savedPinState + self._savedPinState = nil + if saved and saved.mapID == WorldMapFrame:GetMapID() then + if saved.instances then + self:ShowMultipleWaypoints(saved.instances) + elseif saved.x and saved.y then + self:ShowWaypointAt(saved.x, saved.y, saved.icon, saved.category) + end + end +end + function MapSearch:ClearHighlight() if not highlightFrame then return end @@ -7299,12 +7933,27 @@ end -- Returns {x, y, icon, category} or {instances} or nil if not previewable. function MapSearch:GetPreviewCoords(data) local currentMapID = WorldMapFrame:GetMapID() - if data.allInstances and #data.allInstances > 1 then - return { instances = data.allInstances } - elseif data.allInstances and #data.allInstances == 1 then - local single = data.allInstances[1] - if single.x and single.y then + if data.allInstances then + -- Filter instances to those whose (x,y) are valid on the + -- currently viewed map. FMs scanned at both continent and + -- zone level merge into a single result with multiple + -- instances; without this filter, both render — and one is + -- always at the wrong pixel for whichever map you're on. + local valid = {} + for i = 1, #data.allInstances do + local inst = data.allInstances[i] + local instMap = inst.entranceMapID or inst.coordMapID + if inst.x and inst.y and (not instMap or instMap == currentMapID) then + valid[#valid + 1] = inst + end + end + if #valid > 1 then + return { instances = valid } + elseif #valid == 1 then + local single = valid[1] return { x = single.x, y = single.y, icon = single.icon, category = single.category } + else + return nil end end -- Determine the best known coords and their associated map @@ -7318,7 +7967,10 @@ function MapSearch:GetPreviewCoords(data) px, py = data.x, data.y pIcon = data.icon pCat = data.category - pMapID = data.entranceMapID + -- coordMapID identifies the map the (x,y) coords are valid on + -- (set by local scanners). Falls back to entranceMapID for + -- legacy POI shapes that didn't carry the field. + pMapID = data.entranceMapID or data.coordMapID end -- Coords on the current map: use directly. Forward the live pin -- reference (when present) so previews can glow the native icon @@ -7838,22 +8490,19 @@ end function MapSearch:SearchForUI(query) if not query or query == "" or #query < 2 then return nil end - local isLocal = EasyFind.db.uiMapSearchLocal ~= false - -- For UI-bar local search, "local" means the PLAYER's current zone, - -- not whatever the WorldMapFrame is currently viewing. The player - -- might have last opened the map to a different zone, but a local - -- search from the UI bar should always reflect what's around them. - local searchMapID = isLocal - and (GetBestMapForUnit("player") or (WorldMapFrame and WorldMapFrame:GetMapID())) - or (WorldMapFrame and WorldMapFrame:GetMapID()) or GetBestMapForUnit("player") + -- Use the player's current zone as the local anchor so UI-bar map + -- results reflect what's actually around them rather than wherever + -- the WorldMapFrame happens to be viewing. + local searchMapID = GetBestMapForUnit("player") or (WorldMapFrame and WorldMapFrame:GetMapID()) - -- Gather POIs using the same sources as OnSearchTextChanged + -- Gather POIs from both local and global sources in a single pass + -- so the UI bar shows results regardless of zone scope — matching + -- how the MapTab surfaces both "This Zone" and "Across the World" + -- content without asking the user to pick. local pois = {} local existingNames = {} - if isLocal then - -- Local: same sources as the real local search, scoped to the - -- player's current zone (searchMapID computed above). + do local dynamicPOIs = self:ScanMapPOIs(searchMapID) local staticLocations = self:GetStaticLocations(searchMapID) local dungeonEntrances = self:ScanDungeonEntrances(searchMapID) @@ -7892,10 +8541,12 @@ function MapSearch:SearchForUI(query) existingNames[slower(loc.name)] = true end end - else - -- Global: mirror the real global search pipeline (zones + instances) - -- SearchZones checks the module-level isGlobalSearch flag to decide - -- whether to search all world zones vs just current map children. + end + + -- Always also pull in global zone + instance results. Dedup against + -- existingNames so the local sources take priority for any POI that + -- exists in both (same ownership rule as MapTab's local-first pass). + do local savedGlobalFlag = isGlobalSearch isGlobalSearch = true local zoneMatches = self:SearchZones(query) @@ -7966,11 +8617,14 @@ function MapSearch:SearchForUI(query) end end - -- Promote remaining cache entries as zone-style results with breadcrumbs - for _, poi in ipairs(instancePOIs) do - if not zoneNames[slower(poi.name)] and not zoneNames[normalizeName(poi.name)] then + -- Reuse the shared promotedInstancePOIs cache (same lifetime + -- as globalInstanceCache) so we don't re-allocate ~300 tables + -- every keystroke. Build lazily if not yet populated. + if not promotedInstancePOIs then + promotedInstancePOIs = {} + for _, poi in ipairs(instancePOIs) do local fullPath = pathForMap[poi.entranceMapID] - pois[#pois + 1] = { + promotedInstancePOIs[#promotedInstancePOIs + 1] = { name = poi.name, category = poi.category, icon = poi.icon, isZone = true, isStatic = poi.isStatic, isDungeonEntrance = poi.isDungeonEntrance, @@ -7981,6 +8635,12 @@ function MapSearch:SearchForUI(query) } end end + for i = 1, #promotedInstancePOIs do + local p = promotedInstancePOIs[i] + if not zoneNames[slower(p.name)] and not zoneNames[normalizeName(p.name)] then + pois[#pois + 1] = p + end + end end if #pois == 0 then return nil end @@ -7990,8 +8650,9 @@ function MapSearch:SearchForUI(query) if not scored or #scored == 0 then return nil end -- Apply global search filters (zones / dungeons / raids / delves) - -- so UI results mirror what the real global search bar shows - if not isLocal then + -- to the combined result set so category toggles affect UI results + -- whether they come from local scan or the global instance cache. + do local gFilters = EasyFind.db.globalSearchFilters if gFilters then local filtered = {} @@ -8065,49 +8726,49 @@ function MapSearch:HandleUISearchClick(data) local isGlobalResult = data.isZone or data.isDungeonEntrance - if EasyFind.db.directOpen then - if isGlobalResult then - -- Fast mode (global): open map directly at the target + if isGlobalResult then + -- Open the world map at the target and show a waypoint/zone. + -- Guide/breadcrumb mode is reserved for an explicit right-click + -- → Guide flow; a plain UI-search click should always direct- + -- open so the user gets there in one action. + if not WorldMapFrame or not WorldMapFrame:IsShown() then + ToggleWorldMap() + end + if data.entranceMapID and data.entranceX and data.entranceY then + WorldMapFrame:SetMapID(data.entranceMapID) + self:ShowWaypointAt(data.entranceX, data.entranceY, + data.entranceIcon or data.icon, data.entranceCategory or data.category) + elseif data.zoneMapID then + WorldMapFrame:SetMapID(data.zoneMapID) + end + else + -- Local POI: place the tracked waypoint AND open the world map + -- at the POI's zone so the user can see where it is. Tracking- + -- without-opening was a holdover from the old fast-mode flow. + local x, y = data.x, data.y + if data.mapID and x and y and x >= 0 and x <= 1 and y >= 0 and y <= 1 then + SetUserWaypoint(UiMapPoint.CreateFromCoordinates(data.mapID, x, y)) + efPlacedWaypoint = true + C_SuperTrack.SetSuperTrackedUserWaypoint(true) + ShowSuperTrackGlow() + activePinState = { + mapID = data.mapID, + x = x, y = y, + icon = data.icon, category = data.category, + isLocal = true, + } + MapSearch:RefreshAllClearButtons() + -- Open the world map and ensure it's showing the POI's + -- zone, then drop the visual pin on the canvas. ShowWaypointAt + -- expects the canvas to be the matching map. if not WorldMapFrame or not WorldMapFrame:IsShown() then ToggleWorldMap() end - if data.entranceMapID and data.entranceX and data.entranceY then - -- Instance with entrance: navigate to entrance zone, show waypoint - WorldMapFrame:SetMapID(data.entranceMapID) - self:ShowWaypointAt(data.entranceX, data.entranceY, - data.entranceIcon or data.icon, data.entranceCategory or data.category) - elseif data.zoneMapID then - -- Zone: navigate directly - WorldMapFrame:SetMapID(data.zoneMapID) - end - else - -- Fast mode (local): activate navigation without opening the map - local x, y = data.x, data.y - if data.mapID and x and y and x >= 0 and x <= 1 and y >= 0 and y <= 1 then - SetUserWaypoint(UiMapPoint.CreateFromCoordinates(data.mapID, x, y)) - efPlacedWaypoint = true - C_SuperTrack.SetSuperTrackedUserWaypoint(true) - ShowSuperTrackGlow() - activePinState = { - mapID = data.mapID, - x = x, y = y, - icon = data.icon, category = data.category, - isLocal = true, - } - MapSearch:RefreshAllClearButtons() + if WorldMapFrame and WorldMapFrame:GetMapID() ~= data.mapID then + WorldMapFrame:SetMapID(data.mapID) end + self:ShowWaypointAt(x, y, data.icon, data.category) end - else - -- Standard mode: guide user to open the world map, then navigate - local guideData = { - name = data.name or "Map Location", - steps = { - { buttonFrame = "QuestLogMicroButton" }, - { waitForFrame = "WorldMapFrame" }, - }, - } - EasyFind:StartGuide(guideData) - self:SetPendingNavigation(data) end end diff --git a/Options.lua b/Options.lua index cb4d696..f5c1d91 100644 --- a/Options.lua +++ b/Options.lua @@ -1127,47 +1127,33 @@ function Options:Initialize() local FLYOUT_W = 260 - local searchBarGroup = CreateMultiSelectDropdown(sec2, "Search Bars", { - { label = "Smart Show |cFF888888(Recommended)|r", shortLabel = "Smart", dbKey = "mapSmartShow", default = false, - tooltip = "Map search bars hide until you move your mouse near them.\nBars reappear on hover and fade when you move away.\nText in search bars or an open results list prevents fading.", - callback = function() if ns.MapSearch and ns.MapSearch.UpdateMapSmartShow then ns.MapSearch:UpdateMapSmartShow() end end }, - { label = "Hide Fullscreen", shortLabel = "No Full", dbKey = "hideSearchBarsMaximized", default = true, - tooltip = "Both map search bars are hidden when the world map is maximized (full screen).\nThey reappear when you return to the windowed map.", - callback = function() if ns.MapSearch and ns.MapSearch.UpdateHideMaximized then ns.MapSearch:UpdateHideMaximized() end end }, - { label = "Results Above", shortLabel = "Above", dbKey = "mapResultsAbove", default = false, - tooltip = "Map search results appear above the bar instead of below.\nApplies to both local and global map search bars.", - callback = function() if ns.MapSearch and ns.MapSearch.RefreshResultsAnchor then ns.MapSearch:RefreshResultsAnchor() end end }, + local mapTabGroup = CreateMultiSelectDropdown(sec2, "Map Tab", { + { label = "Show Recent Searches", shortLabel = "Recent", dbKey = "mapTabShowRecent", default = true, + tooltip = "Show your recent search queries in the Map Search tab when the search box is empty.\nClick any recent entry to rerun that search.", + callback = function() if ns.MapTab and ns.MapTab.RefreshIfOpen then ns.MapTab:RefreshIfOpen() end end }, }, nil, FLYOUT_W) - searchBarGroup:SetPoint("TOPLEFT", rareTrackCheckbox, "BOTTOMLEFT", 0, -2) - searchBarGroup:SetPoint("RIGHT", sec2, "RIGHT", -8, 0) - searchBarGroup.label:SetWidth(85) - searchBarGroup.label:SetJustifyH("LEFT") - searchBarGroup.button:SetPoint("LEFT", searchBarGroup.label, "RIGHT", 6, 0) - optionsFrame.searchBarGroup = searchBarGroup - - local mapFontSlider = searchBarGroup:AddSlider("MapFontSize", "Font Size|cffff3333*|r", 0.5, 2.0, 0.1, - "Changing font size also affects search bar height and results window sizing.", nil, 1.0) - mapFontSlider:SetValue(EasyFind.db.mapFontSize or 1.0) - mapFontSlider:HookScript("OnValueChanged", function(self, value) - EasyFind.db.mapFontSize = value - if ns.MapSearch and ns.MapSearch.UpdateFontSize then - ns.MapSearch:UpdateFontSize() - end - end) - optionsFrame.mapFontSlider = mapFontSlider - - local mapYOffsetSlider = searchBarGroup:AddSlider("MapYOffset", "Bar Y Offset", -20, 20, 1, - "Vertical offset for the map search bars relative to the map bottom edge.\nPositive moves up, negative moves down.", - function(val) return tostring(mfloor(val + 0.5)) .. "px" end, 0, "px") - mapYOffsetSlider:SetValue(EasyFind.db.mapSearchYOffset or 0) - mapYOffsetSlider:HookScript("OnValueChanged", function(self, value) + mapTabGroup:SetPoint("TOPLEFT", rareTrackCheckbox, "BOTTOMLEFT", 0, -6) + mapTabGroup:SetPoint("RIGHT", sec2, "RIGHT", -8, 0) + mapTabGroup.label:SetWidth(85) + mapTabGroup.label:SetJustifyH("LEFT") + mapTabGroup.button:SetPoint("LEFT", mapTabGroup.label, "RIGHT", 6, 0) + optionsFrame.mapTabGroup = mapTabGroup + + local recentCountSlider = mapTabGroup:AddSlider("RecentCount", "Recent Count", 1, 10, 1, + "How many recent searches to keep and display in the Map Search tab.", + function(val) return tostring(mfloor(val + 0.5)) end, 3, "", "mapTabShowRecent") + recentCountSlider:SetValue(EasyFind.db.mapTabRecentCount or 3) + recentCountSlider:HookScript("OnValueChanged", function(_, value) value = mfloor(value + 0.5) - EasyFind.db.mapSearchYOffset = value - if ns.MapSearch and ns.MapSearch.UpdateYOffset then - ns.MapSearch:UpdateYOffset() + EasyFind.db.mapTabRecentCount = value + -- Trim the stored list immediately if user shrank the cap. + local list = EasyFind.db.mapTabRecentSearches + if list then + while #list > value do table.remove(list) end end + if ns.MapTab and ns.MapTab.RefreshIfOpen then ns.MapTab:RefreshIfOpen() end end) - optionsFrame.mapYOffsetSlider = mapYOffsetSlider + optionsFrame.recentCountSlider = recentCountSlider local mapPinGroup = CreateMultiSelectDropdown(sec2, "EF Map Icons", { { label = "Highlight Box", shortLabel = "Highlight", dbKey = "mapPinHighlight", default = true, @@ -1177,7 +1163,7 @@ function Options:Initialize() tooltip = "Map search pins and highlight boxes pulse in sync with the indicator arrow.\nWhen disabled, pins and highlights are steady. The indicator arrow always bobs.", callback = function() if ns.MapSearch and ns.MapSearch.UpdateBlinkingPins then ns.MapSearch:UpdateBlinkingPins() end end }, }, nil, FLYOUT_W) - mapPinGroup:SetPoint("TOPLEFT", searchBarGroup, "BOTTOMLEFT", 0, -6) + mapPinGroup:SetPoint("TOPLEFT", mapTabGroup, "BOTTOMLEFT", 0, -6) mapPinGroup:SetPoint("RIGHT", sec2, "RIGHT", -8, 0) mapPinGroup.label:SetWidth(85) mapPinGroup.label:SetJustifyH("LEFT") @@ -1268,7 +1254,7 @@ function Options:Initialize() mapControls = { resetMapBtn, resetMapPosBtn, rareTrackCheckbox, - searchBarGroup, mapPinGroup, minimapGroup, automationGroup + mapPinGroup, minimapGroup, automationGroup } UpdateMapToggleVisual() @@ -1284,53 +1270,45 @@ function Options:Initialize() shortcutText:SetSpacing(2) shortcutText:SetText( "|cFFFFD100Search box:|r\n" - .. "|cFF00FF00Down|r Enter results list\n" - .. "|cFF00FF00Tab / Shift+Tab|r Cycle between search/clear/filter buttons\n" - .. "|cFF00FF00Enter|r Activate focused button or highlighted result\n" - .. "|cFF00FF00Escape|r Remove cursor from search bar\n\n" + .. "|cFF00FF00Down|r enter results |cFF00FF00Enter|r activate |cFF00FF00Esc|r unfocus\n" + .. "|cFF00FF00Tab / Shift+Tab|r cycle search/clear/filter buttons\n\n" .. "|cFFFFD100Results list:|r\n" - .. "|cFF00FF00Up / Down|r Move through results\n" - .. "|cFF00FF00Tab / Shift+Tab|r Toggle focus between result/nav button\n" - .. "|cFF00FF00Page Up / Page Down|r Jump 5 results\n" - .. "|cFF00FF00Home / End|r Jump to first / last result\n" - .. "|cFF00FF00Shift+Up / Shift+Down|r Jump between sections\n" - .. "|cFF00FF00Ctrl+Tab|r Switch local / global map search bar\n\n" + .. "|cFF00FF00Up/Down|r or |cFF00FF00Ctrl+K/J|r Move through results\n" + .. "|cFF00FF00Tab/Shift+Tab|r or |cFF00FF00Ctrl+L/H|r Cycle focus to nav buttons\n" + .. "|cFF00FF00PgUp/PgDn|r jump 5 |cFF00FF00Home/End|r first/last\n" + .. "|cFF00FF00Shift+Up/Down|r or |cFF00FF00Ctrl+Shift+K/J|r jump section\n" + .. "|cFF00FF00Ctrl+Tab|r switch local/global map search bar\n\n" .. "|cFFFFD100Other:|r\n" - .. "|cFF00FF00Shift+Drag|r Reposition search bars\n" - .. "|cFF00FF00Right-click|r a result to pin/unpin it\n" - .. "|cFF00FF00/ef show|r |cFF00FF00/ef hide|r Toggle the search bar\n" + .. "|cFF00FF00Shift+Drag|r reposition |cFF00FF00Right-click|r pin/unpin\n" + .. "|cFF00FF00/ef toggle|r toggle the search bar\n" ) -- Keybind buttons local KEYBIND_ROW_H = 18 local KEYBIND_BTN_W = 80 local keybindDefs = { - { label = "Toggle Bar", action = "EASYFIND_TOGGLE" }, - { label = "Focus Bar", action = "EASYFIND_FOCUS" }, - { label = "Clear All", action = "EASYFIND_CLEAR" }, - { label = "Toggle+Foc", action = "EASYFIND_TOGGLE_FOCUS" }, + { label = "Toggle Search Bar", action = "EASYFIND_TOGGLE_FOCUS" }, + { label = "Open Map Search", action = "EASYFIND_MAP_FOCUS" }, + { label = "Clear All", action = "EASYFIND_CLEAR" }, } local keybindTooltips = { - EASYFIND_TOGGLE = { "Toggle Search Bar", "Shows or hides the main search bar." }, - EASYFIND_FOCUS = { "Focus Search Bar", "Places the cursor in the search bar without toggling visibility." }, - EASYFIND_TOGGLE_FOCUS = { "Toggle + Focus", "Opens and focuses the search bar in one press. Press again to close. When the map is open, focuses the local map search bar instead." }, + EASYFIND_TOGGLE_FOCUS = { "Toggle Search Bar", "Opens and focuses the UI search bar in one press. Press again to close." }, + EASYFIND_MAP_FOCUS = { "Open Map Search", "Opens the world map, switches to the EasyFind Map Search tab, and focuses its search box." }, EASYFIND_CLEAR = { "Clear All", "Dismisses all active highlights, map pins, zone highlights, and pending waypoints." }, } local keybindButtons = {} - local KEYBIND_LABEL_W = 70 - local COL2_X = 160 + -- Single column. Two-column layout overlapped because labels like + -- "Toggle Search Bar:" exceed the small per-column allowance and + -- bleed into the button area of the same row. One column with a + -- wide label slot keeps every label clear of its button. + local KEYBIND_LABEL_W = 130 for i, def in ipairs(keybindDefs) do - local row = (i - 1) % 2 -- 0 or 1 - local col = (i <= 2) and 0 or 1 -- left or right column + local row = i - 1 local rowLabel = sec4:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - if row == 0 then - rowLabel:SetPoint("TOPLEFT", shortcutText, "BOTTOMLEFT", col * COL2_X, -12) - else - rowLabel:SetPoint("TOPLEFT", shortcutText, "BOTTOMLEFT", col * COL2_X, -12 - KEYBIND_ROW_H) - end + rowLabel:SetPoint("TOPLEFT", shortcutText, "BOTTOMLEFT", 0, -12 - row * KEYBIND_ROW_H) rowLabel:SetText(def.label .. ":") local keybindBtn = CreateFrame("Button", nil, sec4, "UIPanelButtonTemplate") @@ -1360,10 +1338,9 @@ function Options:Initialize() keybindButtons[def.action] = keybindBtn end - optionsFrame.keybindBtn = keybindButtons["EASYFIND_TOGGLE"] - optionsFrame.focusBtn = keybindButtons["EASYFIND_FOCUS"] optionsFrame.toggleFocusBtn = keybindButtons["EASYFIND_TOGGLE_FOCUS"] - optionsFrame.clearBtn = keybindButtons["EASYFIND_CLEAR"] + optionsFrame.mapFocusBtn = keybindButtons["EASYFIND_MAP_FOCUS"] + optionsFrame.clearBtn = keybindButtons["EASYFIND_CLEAR"] -- Reset buttons (tips on Home tab) @@ -1680,15 +1657,11 @@ function Options:DoResetAll() ns.MapSearch.pendingWaypoint = nil end - local old1, old2 = GetBindingKey("EASYFIND_TOGGLE") - if old1 then SetBinding(old1) end - if old2 then SetBinding(old2) end - - old1, old2 = GetBindingKey("EASYFIND_FOCUS") + local old1, old2 = GetBindingKey("EASYFIND_TOGGLE_FOCUS") if old1 then SetBinding(old1) end if old2 then SetBinding(old2) end - old1, old2 = GetBindingKey("EASYFIND_TOGGLE_FOCUS") + old1, old2 = GetBindingKey("EASYFIND_MAP_FOCUS") if old1 then SetBinding(old1) end if old2 then SetBinding(old2) end @@ -1698,18 +1671,15 @@ function Options:DoResetAll() SaveBindings(GetCurrentBindingSet()) optionsFrame.mapIconSlider:SetValue(0.8) - optionsFrame.mapYOffsetSlider:SetValue(0) optionsFrame.panelOpacitySlider:SetValue(0.9) optionsFrame.opacitySlider:SetValue(DEFAULT_OPACITY) optionsFrame.uiFontSlider:SetValue(0.9) - optionsFrame.mapFontSlider:SetValue(0.9) optionsFrame.smartShowCheckbox:SetChecked(false) optionsFrame.staticOpacityCheckbox:SetChecked(false) optionsFrame.loginMessageCheckbox:SetChecked(true) optionsFrame.uiResultsAboveCheckbox:SetChecked(false) optionsFrame.minimapBtnCheckbox:SetChecked(true) if optionsFrame.rareTrackCheckbox then optionsFrame.rareTrackCheckbox:SetChecked(false) end - if optionsFrame.searchBarGroup then optionsFrame.searchBarGroup:UpdateVisuals() end if optionsFrame.mapPinGroup then optionsFrame.mapPinGroup:UpdateVisuals() end if optionsFrame.minimapGroup then optionsFrame.minimapGroup:UpdateVisuals() end if optionsFrame.automationGroup then optionsFrame.automationGroup:UpdateVisuals() end @@ -1831,12 +1801,9 @@ function Options:DoResetMap() EasyFind.db.alwaysShowRares = false if optionsFrame.rareTrackCheckbox then optionsFrame.rareTrackCheckbox:SetChecked(false) end - if optionsFrame.searchBarGroup then optionsFrame.searchBarGroup:UpdateVisuals() end if optionsFrame.mapPinGroup then optionsFrame.mapPinGroup:UpdateVisuals() end if optionsFrame.minimapGroup then optionsFrame.minimapGroup:UpdateVisuals() end if optionsFrame.automationGroup then optionsFrame.automationGroup:UpdateVisuals() end - optionsFrame.mapFontSlider:SetValue(0.9) - optionsFrame.mapYOffsetSlider:SetValue(0) optionsFrame.mapIconSlider:SetValue(0.8) optionsFrame.arrivalSlider:SetValue(10) optionsFrame.circleScaleSlider:SetValue(1.0) @@ -1886,7 +1853,6 @@ function Options:DoResetMapPositions() EasyFind.db.mapResultsScale = 1.0 EasyFind.db.mapResultsWidth = 300 EasyFind.db.mapSearchYOffset = 0 - optionsFrame.mapYOffsetSlider:SetValue(0) if _G["EasyFindMapSearchFrame"] and ns.MapSearch then if ns.MapSearch.ResetPosition then ns.MapSearch:ResetPosition() end if ns.MapSearch.UpdateScale then ns.MapSearch:UpdateScale() end @@ -1969,16 +1935,13 @@ function Options:Show() if optionsFrame.panelOpacitySlider then optionsFrame.panelOpacitySlider:SetValue(EasyFind.db.panelOpacity or 0.9) end if optionsFrame.opacitySlider then optionsFrame.opacitySlider:SetValue(EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) end if optionsFrame.uiFontSlider then optionsFrame.uiFontSlider:SetValue(EasyFind.db.fontSize or 0.9) end - if optionsFrame.mapFontSlider then optionsFrame.mapFontSlider:SetValue(EasyFind.db.mapFontSize or 0.9) end if optionsFrame.mapIconSlider then optionsFrame.mapIconSlider:SetValue(EasyFind.db.iconScale or 0.8) end - if optionsFrame.mapYOffsetSlider then optionsFrame.mapYOffsetSlider:SetValue(EasyFind.db.mapSearchYOffset or 0) end if optionsFrame.arrivalSlider then optionsFrame.arrivalSlider:SetValue(EasyFind.db.arrivalDistance or 10) end optionsFrame.smartShowCheckbox:SetChecked(EasyFind.db.smartShow or false) optionsFrame.staticOpacityCheckbox:SetChecked(EasyFind.db.staticOpacity or false) optionsFrame.loginMessageCheckbox:SetChecked(EasyFind.db.showLoginMessage ~= false) optionsFrame.uiResultsAboveCheckbox:SetChecked(EasyFind.db.uiResultsAbove or false) optionsFrame.minimapBtnCheckbox:SetChecked(EasyFind.db.showMinimapButton or false) - if optionsFrame.searchBarGroup then optionsFrame.searchBarGroup:UpdateVisuals() end if optionsFrame.mapPinGroup then optionsFrame.mapPinGroup:UpdateVisuals() end if optionsFrame.minimapGroup then optionsFrame.minimapGroup:UpdateVisuals() end if optionsFrame.automationGroup then optionsFrame.automationGroup:UpdateVisuals() end @@ -1990,14 +1953,12 @@ function Options:Show() optionsFrame.colorBtnText:SetTextColor(rgb[1], rgb[2], rgb[3]) optionsFrame.colorSwatch:SetColorTexture(rgb[1], rgb[2], rgb[3], 1) - local key1 = GetBindingKey("EASYFIND_TOGGLE") - optionsFrame.keybindBtn:SetText(key1 or "Not Bound") - local key2 = GetBindingKey("EASYFIND_FOCUS") - optionsFrame.focusBtn:SetText(key2 or "Not Bound") - local key3 = GetBindingKey("EASYFIND_TOGGLE_FOCUS") - optionsFrame.toggleFocusBtn:SetText(key3 or "Not Bound") - local key4 = GetBindingKey("EASYFIND_CLEAR") - optionsFrame.clearBtn:SetText(key4 or "Not Bound") + local key1 = GetBindingKey("EASYFIND_TOGGLE_FOCUS") + optionsFrame.toggleFocusBtn:SetText(key1 or "Not Bound") + local key2 = GetBindingKey("EASYFIND_MAP_FOCUS") + optionsFrame.mapFocusBtn:SetText(key2 or "Not Bound") + local key3 = GetBindingKey("EASYFIND_CLEAR") + optionsFrame.clearBtn:SetText(key3 or "Not Bound") if not self.embedded and optionsFrame.bgTex then optionsFrame.bgTex:SetAlpha(EasyFind.db.panelOpacity or 0.9) From 11ecdefeb8afd28eca39c21a69d4216321f8b4fd Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 1 May 2026 02:13:41 -0400 Subject: [PATCH 028/103] MapTab nav: fix arrow keys with active autocomplete suggestion Pressing an arrow key (or Ctrl+J/K) while the autocomplete suffix was highlighted in the search box did not move into the results. WoW's default arrow-key handling on a highlighted selection collapses it synchronously, firing OnTextChanged with the typed prefix unchanged. The OnTextChanged hook rescheduled the search timer, which fired one frame later and re-rendered the rows -- resetting navRowIndex to 0 and erasing the move HandleNavKey had just made. - OnTextChanged: bail when the typed prefix is unchanged. Selection- collapse edits don't change what the user is searching for. - OnKeyDown: also cancel any pending search timer when a nav key is pressed. Defends against a timer scheduled by an OnTextChanged that fired before our hook (timer would otherwise fire next frame and wipe nav state regardless of the typed-prefix guard). While here, drop the navKeyRepeat hold-to-step ticker. The press that fires from the editbox has no paired OnKeyUp on navFrame, so a dropped release would leave repeatActive stuck and resume auto- scrolling the next time the panel was shown. WoW delivers repeated OnKeyDown events at the OS auto-repeat cadence while a key is held, so single-stepping per event preserves hold-to-walk without owning the timer. --- MapTab.lua | 54 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/MapTab.lua b/MapTab.lua index 821e38f..3114608 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -1576,7 +1576,6 @@ end -- declared between the two. local HandleNavKey -local navKeyRepeat local function EnsureNavFrame() if navFrame then return navFrame end if not panel then return nil end @@ -1590,12 +1589,6 @@ local function EnsureNavFrame() self:SetPropagateKeyboardInput(true) end end) - navFrame:SetScript("OnKeyUp", function(_, key) - if navKeyRepeat and navKeyRepeat.IsKey(key) then - navKeyRepeat.Stop(key) - end - end) - navKeyRepeat = Utils.CreateKeyRepeat(navFrame) return navFrame end @@ -1699,14 +1692,17 @@ HandleNavKey = function(key, keepSearchFocus) panel.searchBox:ClearFocus() end SetNavFrameCapture(true) - -- Hold-to-step via the shared key-repeat helper. Start fires - -- the action immediately, then again after initialDelay, - -- accelerating toward fastDelay the longer the key is held. - if navKeyRepeat then - navKeyRepeat.Start(key, function() MoveNavSelection(1) end) - else - MoveNavSelection(1) - end + -- Single-step: each OnKeyDown moves one row. WoW delivers + -- repeated OnKeyDown events at the OS auto-repeat cadence + -- while the key is held, so a held arrow / Ctrl+J still walks + -- the list; we just don't run our own repeat ticker. Owning + -- the repeat ourselves bit us in two ways: the press that + -- fires from the editbox doesn't have a paired OnKeyUp on + -- navFrame to stop the timer, so repeatActive could get + -- stuck and auto-scroll on the next panel show; and the OS + -- repeat firing through navFrame's OnKeyDown would also call + -- Start, racing the ticker. + MoveNavSelection(1) return true elseif key == "UP" or (ctrl and key == "K") then if #visibleNavRows == 0 then return false end @@ -1723,11 +1719,7 @@ HandleNavKey = function(key, keepSearchFocus) panel.searchBox:ClearFocus() end SetNavFrameCapture(true) - if navKeyRepeat then - navKeyRepeat.Start(key, function() MoveNavSelection(-1) end) - else - MoveNavSelection(-1) - end + MoveNavSelection(-1) return true elseif key == "SPACE" then -- Space inside the search box is a literal character and must @@ -1921,6 +1913,15 @@ local function CreateSearchBox(parent) -- The "typed" prefix is everything up to the cursor; anything -- after is autocomplete suffix the user hasn't accepted. local typed = current:sub(1, cursorPos) + -- Pressing an arrow key while the autocomplete suffix is + -- highlighted collapses (or deletes) the selection without + -- changing the user's typed prefix. WoW fires OnTextChanged + -- on that selection edit, and if we let it reschedule a + -- search, RunSearch fires on the next frame, RenderRows + -- resets navRowIndex to 0, and the row we just navigated to + -- with HandleNavKey loses its highlight. Bail when the prefix + -- is unchanged: there's nothing new to search. + if typed == typedText then return end lastWasAddition = #typed > #typedText typedText = typed @@ -1951,6 +1952,19 @@ local function CreateSearchBox(parent) -- the end. ENTER routes through HandleNavKey, which activates a -- highlighted row or falls through to push-to-recents below. editBox:HookScript("OnKeyDown", function(self, key) + -- An active autocomplete suffix is rendered as highlighted + -- text. WoW's default arrow-key handling on a highlighted + -- selection fires OnTextChanged synchronously, which schedules + -- a C_Timer.NewTimer(0) to RunSearch on the next frame — + -- RenderRows resets navRowIndex to 0 and drops the move we're + -- about to make. Cancel that timer for nav keys before + -- HandleNavKey runs so the navigation actually sticks. + if pendingSearchTimer + and (key == "DOWN" or key == "UP" + or (IsControlKeyDown() and (key == "J" or key == "K"))) then + pendingSearchTimer:Cancel() + pendingSearchTimer = nil + end HandleNavKey(key, true) Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) end) From 2404f443e3bbfc89c153346b97cd3e1b86fd1729 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 1 May 2026 02:14:56 -0400 Subject: [PATCH 029/103] MapTab/MapSearch: zone hover, version groups, breadcrumb, polish MapTab list: - World hierarchy children with Dungeon mapType render with the dungeon-portal icon instead of the generic continent texture. - BuildFullBreadcrumb prints root-first ("World > Continent > Zone > Name"), and surfaces mapID 946 ("Cosmic") as "World". - Duplicate-named zones (Dalaran in Northrend vs Broken Isles) collapse into a single "version" header that lists each variant on expand. Header navigates to the highest-mapID variant. - Auto-expand checkbox under the recent-search toggle. When on (default), a matched parent zone lists every child the world hierarchy says lives under it; when off, only matched names show. - Continent-type zone matches that EXCLUDE_FROM_LOCAL_MAPTYPES drops from the local section now have their synthesized "This Zone" header promoted back to the real result, so auto-expand fires for them too. - Drop any global group whose ancestor matches the currently-viewed map -- the "This Zone" section already covers it. MapTab pin popup: - Cursor offset by 8px inside the popup at open time. Without it, IsMouseOver flickered at the boundary pixel and the auto-dismiss hid the popup on the same frame it appeared. - 0.15s grace period in the auto-dismiss OnUpdate so the right- click that opens the popup doesn't immediately close it. - Right-click guide menu always shows (was previously gated on isZone or isDungeonEntrance). MapSearch zone hover preview: - Indicator frame is parented to the canvas (sibling of waypointPin / highlightFrame) and anchored explicitly on every show. Reparent on every hover had inconsistent timing on some result rows. - New arrow-only mode for plain zone hovers: just the bouncing arrow over the zone center plus the zone outline; no pin icon/glow/box clutter that would compete with the outline. - PreviewZoneHighlight uses a strict direct-child gate (parentMapID == currently-viewed map). Ancestor / sibling / distant-descendant rects all read as glitches and are skipped. - ClearZoneHighlight gains a preserveBreadcrumb flag so the hover cleanup leaves the click-driven gold breadcrumb intact. - Newly-pinned parent zones default to collapsed. Demo: pin popup is now a structured frame (pinRow, guideRow); the demo flow's auto-pin clicks pinRow:Click() instead of the old popup:Click() that no longer applies. --- Core.lua | 1 + Demo.lua | 6 +- MapSearch.lua | 151 ++++++++++++++++++++++++++++---------- MapTab.lua | 199 ++++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 302 insertions(+), 55 deletions(-) diff --git a/Core.lua b/Core.lua index 9c839dc..189670a 100644 --- a/Core.lua +++ b/Core.lua @@ -107,6 +107,7 @@ local DB_DEFAULTS = { mapTabRecentSearches = {}, -- Most-recent-first list of past map search queries mapTabShowRecent = true, -- Toggle for showing recent searches when idle mapTabRecentCount = 3, -- Number of recent searches to keep / display (1-20) + mapTabAutoExpand = true, -- Auto-expand a matched parent header to show all its world-hierarchy children alwaysShowRares = false, -- Persistent rare tracking: show active rares on map without searching uiSearchFilters = { -- UI search category filters ui = true, diff --git a/Demo.lua b/Demo.lua index 1188ecd..00a264e 100644 --- a/Demo.lua +++ b/Demo.lua @@ -3563,7 +3563,11 @@ function Demo.Start(ctx) rightClickIcon:Hide() moveCursorTo(popup, CURSOR_MOVE * 0.6, function() clickAnim(function() - popup:Click() + if popup.pinRow and popup.pinRow.Click then + popup.pinRow:Click() + elseif popup.Hide then + popup:Hide() + end od.pinnedByDemo = true safeAfter(1.5, function() mapSearchCallout:Hide() diff --git a/MapSearch.lua b/MapSearch.lua index 36530f8..ed864bf 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -1077,6 +1077,12 @@ local function PinMapItem(data) if IsMapItemPinned(data) then return end local clean = CleanForStorage(data) clean.isPinned = true + -- Default a freshly-pinned parent to collapsed. The toggle callback + -- writes back to this same SavedVariables entry, so any user-driven + -- expand/collapse state survives map close, /reload, and logout. + if data.isZone and data.zoneMapID and clean.collapsed == nil then + clean.collapsed = true + end tinsert(EasyFind.db.pinnedMapItems, clean) end @@ -2871,10 +2877,15 @@ function MapSearch:CreateHighlightFrame() right:SetColorTexture(YELLOW_HIGHLIGHT[1], YELLOW_HIGHLIGHT[2], YELLOW_HIGHLIGHT[3], 1) highlightFrame.right = right - -- Indicator pointing down to the location - indicatorFrame = CreateFrame("Frame", "EasyFindMapIndicator", highlightFrame) + -- Indicator pointing down to the location. Parented to the canvas + -- (sibling of waypointPin / highlightFrame) and anchored explicitly + -- on every show — never reparented. Reparenting between hovers had + -- inconsistent timing on some result rows where the indicator would + -- inherit a stale Hidden state and never repaint. + indicatorFrame = CreateFrame("Frame", "EasyFindMapIndicator", WorldMapFrame.ScrollContainer.Child) + indicatorFrame:SetFrameStrata("TOOLTIP") + indicatorFrame:SetFrameLevel(2000) indicatorFrame:SetSize(ns.ICON_SIZE, ns.ICON_SIZE) - indicatorFrame:SetPoint("BOTTOM", highlightFrame, "TOP", 0, 2) indicatorFrame:EnableMouse(false) ns.CreateIndicatorTextures(indicatorFrame) @@ -3597,24 +3608,12 @@ function MapSearch:PreviewZoneHighlight(mapID) local resolved = ResolveZoneForMap(mapID, parentMapID) if resolved ~= mapID then mapID = resolved end - -- Already inside the zone's own map: no preview to draw (the rect - -- would just be the entire canvas). - if mapID == parentMapID then return end - - -- Ancestor of the currently-viewed map (Kalimdor when on Orgrimmar, - -- Durotar when on Orgrimmar, etc.). The current canvas IS the - -- ancestor's territory, so the only sensible "preview" would cover - -- the whole map — which both reads as a glitch and isn't what the - -- user is asking to be shown. Bail. - do - local cur = parentMapID - for _ = 1, 15 do - local info = GetMapInfo(cur) - if not info or not info.parentMapID or info.parentMapID == 0 then break end - if info.parentMapID == mapID then return end - cur = info.parentMapID - end - end + -- Strict direct-child gate: only preview zones whose parentMapID is + -- the currently-viewed map. Anything else (ancestors, siblings, + -- distant descendants) produces glitchy or misleading rects and is + -- skipped entirely. + local zoneInfo = GetMapInfo(mapID) + if not zoneInfo or zoneInfo.parentMapID ~= parentMapID then return end local ok, left, right, top, bottom = pcall(GetMapRectOnMap, mapID, parentMapID) if not ok or not left then return end @@ -4123,7 +4122,11 @@ function MapSearch:HighlightZone(mapID) return true end -function MapSearch:ClearZoneHighlight() +-- preserveBreadcrumb: when true, leaves the click-driven breadcrumb +-- highlight (and its pendingZoneHighlight) untouched. Used by hover- +-- preview cleanup so moving the cursor off a row doesn't wipe the +-- gold breadcrumb the user is being guided to. +function MapSearch:ClearZoneHighlight(preserveBreadcrumb) if not zoneHighlightFrame then return end for _, highlight in ipairs(zoneHighlightFrame.highlights) do @@ -4162,6 +4165,8 @@ function MapSearch:ClearZoneHighlight() zoneHighlightFrame:Hide() + if preserveBreadcrumb then return end + -- Also clear breadcrumb highlight if self.breadcrumbHighlight then if self.breadcrumbHighlight.indicatorFrame then @@ -7601,7 +7606,7 @@ function MapSearch:ShowMultipleWaypoints(instances) end end -function MapSearch:ShowWaypointAt(x, y, icon, category) +function MapSearch:ShowWaypointAt(x, y, icon, category, arrowOnly) if not x or not y then return end self:ClearHighlight() @@ -7611,6 +7616,7 @@ function MapSearch:ShowWaypointAt(x, y, icon, category) x = x, y = y, icon = icon, category = category, isLocal = not isGlobalSearch, + arrowOnly = arrowOnly, } local canvas = WorldMapFrame.ScrollContainer.Child @@ -7626,6 +7632,31 @@ function MapSearch:ShowWaypointAt(x, y, icon, category) local indicatorSize = ns.UIToCanvas(ns.ICON_SIZE) * userScale local indicatorGlowSize = ns.UIToCanvas(ns.ICON_GLOW_SIZE) * userScale + -- Arrow-only mode (zone hover preview): the zone's outline is drawn + -- by zoneHighlightFrame, so pin chrome (icon, glow, highlight box) + -- would clutter it. Hide pin and highlight, then anchor the + -- indicator at the zone center on the canvas. The indicator is a + -- permanent canvas child — no reparenting between calls. + if arrowOnly then + waypointPin:Hide() + highlightFrame:Hide() + indicatorFrame:SetSize(indicatorSize, indicatorSize) + if indicatorFrame.glow then + indicatorFrame.glow:SetSize(indicatorGlowSize, indicatorGlowSize) + end + indicatorFrame:ClearAllPoints() + indicatorFrame:SetPoint("BOTTOM", canvas, "TOPLEFT", + canvasWidth * x, -canvasHeight * y + 2) + indicatorFrame:SetAlpha(1) + indicatorFrame:Show() + if indicatorFrame.animGroup then + indicatorFrame.animGroup:Stop() + indicatorFrame.animGroup:Play() + end + self:RefreshAllClearButtons() + return + end + -- Resize the pin and glow waypointPin:SetSize(iconSize, iconSize) waypointPin.glow:SetSize(glowSize, glowSize) @@ -7658,12 +7689,20 @@ function MapSearch:ShowWaypointAt(x, y, icon, category) highlightFrame:Show() SetHighlightBordersVisible(highlightFrame, EasyFind.db.mapPinHighlight ~= false) - -- Resize indicator and its glow + -- Resize indicator and its glow, then anchor explicitly above the + -- highlight box (indicator is a permanent canvas child; the old + -- parent-relative anchor doesn't apply anymore). indicatorFrame:SetSize(indicatorSize, indicatorSize) indicatorFrame.glow:SetSize(indicatorGlowSize, indicatorGlowSize) + indicatorFrame:ClearAllPoints() + indicatorFrame:SetPoint("BOTTOM", highlightFrame, "TOP", 0, 2) + indicatorFrame:SetAlpha(1) indicatorFrame:Show() - if indicatorFrame.animGroup then indicatorFrame.animGroup:Play() end + if indicatorFrame.animGroup then + indicatorFrame.animGroup:Stop() + indicatorFrame.animGroup:Play() + end if EasyFind.db.blinkingPins then if waypointPin.animGroup then waypointPin.animGroup:Play() end if highlightFrame.animGroup then highlightFrame.animGroup:Play() end @@ -7821,14 +7860,21 @@ function MapSearch:RunHoverPreview(data) end self:ShowMultipleWaypoints(composite) elseif coords.x and coords.y then - composite[#composite + 1] = { - x = coords.x, y = coords.y, - icon = coords.icon, category = coords.category, - } - if #composite > 1 then - self:ShowMultipleWaypoints(composite) + if coords.arrowOnly then + -- Zone preview: only the bouncing arrow + zone outline, no pin + -- icon/glow/box. Skip composite merging so existing clicked + -- pins from another category don't pull in their icons either. + self:ShowWaypointAt(coords.x, coords.y, nil, nil, true) else - self:ShowWaypointAt(coords.x, coords.y, coords.icon, coords.category) + composite[#composite + 1] = { + x = coords.x, y = coords.y, + icon = coords.icon, category = coords.category, + } + if #composite > 1 then + self:ShowMultipleWaypoints(composite) + else + self:ShowWaypointAt(coords.x, coords.y, coords.icon, coords.category) + end end end @@ -7844,12 +7890,10 @@ function MapSearch:EndHoverPreview() if not self._previewing then return end self._previewing = nil if self._previewingZone then - -- ClearZoneHighlight nukes pendingZoneHighlight as a side effect. - -- Preserve it across the call so a real click-driven nav chain - -- (set somewhere else, untouched by the hover preview) survives. - local savedPending = self.pendingZoneHighlight - self:ClearZoneHighlight() - self.pendingZoneHighlight = savedPending + -- preserveBreadcrumb keeps the click-driven gold breadcrumb + -- and pendingZoneHighlight intact while we drop the hover + -- preview's zone outline. + self:ClearZoneHighlight(true) self._previewingZone = nil end self:ClearHighlight() @@ -7876,8 +7920,6 @@ function MapSearch:ClearHighlight() highlightFrame.left:Show() highlightFrame.right:Show() - indicatorFrame:ClearAllPoints() - indicatorFrame:SetPoint("BOTTOM", highlightFrame, "TOP", 0, 2) indicatorFrame:Hide() waypointPin:Hide() -- Reset strata to creation defaults so the mouse-enabled pin doesn't @@ -7986,6 +8028,35 @@ function MapSearch:GetPreviewCoords(data) return { x = ex, y = ey, icon = pIcon or data.icon, category = pCat or data.category } end end + -- Plain zone result with no entrance data: anchor the indicator at + -- the zone's center on the currently-viewed map so the bouncing + -- arrow points into the highlight rect drawn by PreviewZoneHighlight. + -- arrowOnly flag tells RunHoverPreview to skip the icon / pin chrome + -- because the zone outline already conveys "this is a zone". + if data.isZone and data.zoneMapID then + local zMap = data.zoneMapID + local resolved = ResolveZoneForMap(zMap, currentMapID) + if resolved ~= zMap then zMap = resolved end + -- Strict direct-child gate (matches PreviewZoneHighlight): only + -- show the bouncing arrow when the zone's parentMapID is the + -- currently-viewed map. Anything else is suppressed. + local zInfo = GetMapInfo(zMap) + if zInfo and zInfo.parentMapID == currentMapID then + local ok, left, right, top, bottom = pcall(GetMapRectOnMap, zMap, currentMapID) + if ok and left then + if left == 0 and right == 0 and top == 0 and bottom == 0 then + local pL, pR, pT, pB = GetMapRectViaContinent(zMap, currentMapID) + if pL then left, right, top, bottom = pL, pR, pT, pB end + end + if left and (right - left) > 0.005 and (bottom - top) > 0.005 + and (right - left) < 1.05 and (bottom - top) < 1.05 then + local cx = (left + right) / 2 + local cy = (top + bottom) / 2 + return { x = cx, y = cy, arrowOnly = true } + end + end + end + end return nil end diff --git a/MapTab.lua b/MapTab.lua index 3114608..a9c81ca 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -176,13 +176,26 @@ local function GetWorldChildren(mapID) if child.name and mt and mt ~= Enum.UIMapType.Micro and mt ~= Enum.UIMapType.Orphan then + local category, icon + if mt == Enum.UIMapType.Dungeon then + -- Sub-zones of dungeon mapType render with the + -- dungeon-portal category icon instead of the + -- generic continent texture. The API doesn't + -- distinguish raids here, so all instance-type + -- children get the dungeon glyph. + category = "dungeon" + icon = nil + else + category = "zone" + icon = 237382 + end result[#result + 1] = { name = child.name, - category = "zone", + category = category, isZone = true, zoneMapID = child.mapID, zoneMapType = mt, - icon = 237382, + icon = icon, synthesized = true, } end @@ -393,9 +406,14 @@ local function ShowPopup(isPinned, onPin, onGuide) pinPopup.pinRow:SetScript("OnLeave", MenuRowOnLeave) pinPopup.guideRow:SetScript("OnLeave", MenuRowOnLeave) -- Dismiss on outside click (any button). Same pattern used by - -- the MapSearch filter dropdown. + -- the MapSearch filter dropdown. The grace period avoids a race + -- where the press that opened the popup (RightButtonDown) is + -- still registering as held during the first OnUpdate tick — + -- without it, IsMouseButtonDown stays true and the popup hides + -- itself before the cursor settles inside. pinPopup:SetScript("OnUpdate", function(self) if not self:IsShown() then return end + if self._openedAt and GetTime() - self._openedAt < 0.15 then return end if (IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton")) and not self:IsMouseOver() then self:Hide() @@ -434,7 +452,12 @@ local function ShowPopup(isPinned, onPin, onGuide) local scale = UIParent:GetEffectiveScale() local x, y = GetCursorPosition() pinPopup:ClearAllPoints() - pinPopup:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", x / scale, y / scale) + -- Offset so the cursor lands ~8px inside the popup rather than at + -- its bottom-left corner. Without the offset, IsMouseOver flickers + -- at the boundary pixel and the auto-dismiss handler can hide the + -- popup on the same frame it appears. + pinPopup:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", x / scale - 8, y / scale - 8) + pinPopup._openedAt = GetTime() pinPopup:Show() end @@ -612,12 +635,10 @@ local function RowOnClick(row, button) if button == "RightButton" then local isPinned = MapSearch and MapSearch:IsMapItemPinned(data) - local isGlobal = data.isZone or data.isDungeonEntrance - local onGuide = isGlobal and function() TriggerResultSelect(data, false) end or nil ShowPopup(isPinned, function() if isPinned then MapSearch:UnpinMapItem(data) else MapSearch:PinMapItem(data) end RefreshCurrentSearch() - end, onGuide) + end, function() TriggerResultSelect(data, false) end) return end @@ -629,9 +650,11 @@ local function RowOnClick(row, button) end -- Walk a result's parent-map chain and return a full breadcrumb string --- " > > > ". Skips a leading ancestor --- whose name matches the POI itself (zone results' own mapID resolves --- to the same name, so we'd otherwise render "Tol Barad > Tol Barad"). +-- " > > > " — root first, leaf last. +-- The Blizzard API names mapID 946 "Cosmic"; surface it as "World" +-- since that's how players refer to it. Skips an ancestor whose name +-- matches the POI itself (zone results' own mapID resolves to the +-- same name, so we'd otherwise render "Tol Barad > Tol Barad"). local function BuildFullBreadcrumb(data) local mapID = data.mapID or data.zoneMapID or data.entranceMapID or data.parentMapID if not mapID or not C_Map or not C_Map.GetMapInfo then return data.name end @@ -641,14 +664,20 @@ local function BuildFullBreadcrumb(data) for _ = 1, 20 do local info = C_Map.GetMapInfo(current) if not info then break end - if not info.name or info.name:lower() ~= leafName then - parts[#parts + 1] = info.name + if info.name and info.name:lower() ~= leafName then + local name = info.name == "Cosmic" and "World" or info.name + parts[#parts + 1] = name end if not info.parentMapID or info.parentMapID == 0 then break end current = info.parentMapID end if #parts == 0 then return data.name end - return data.name .. " > " .. table.concat(parts, " > ") + local segments = {} + for i = #parts, 1, -1 do + segments[#segments + 1] = parts[i] + end + segments[#segments + 1] = data.name + return table.concat(segments, " > ") end local function RowOnEnter(row) @@ -1131,6 +1160,28 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece for _, e in ipairs(entries) do if e.type == "flat" then placeRow(e.data, 0, nil) + elseif e.type == "version" then + -- Version group: same name, multiple mapIDs (e.g. Dalaran + -- exists in Northrend and Broken Isles). Default-collapsed + -- so the bare name shows; expanding lists each variant — + -- their pathPrefix already disambiguates ("Crystalsong + -- Forest" / "Broken Isles"). Header navigates to newest. + local groupKey = sectionKey .. ":version:" .. e.name + local stored = collapsedDb[groupKey] + local collapsed = stored ~= false -- nil → default collapsed + local capturedCollapsed = collapsed + local onToggle = function() + collapsedDb[groupKey] = not capturedCollapsed and true or false + RefreshCurrentSearch() + end + local nav = e.navigateData + local onRClick = nav and function() headerRightClick(nav, capturedCollapsed) end or nil + placeGroupHeader(e.name, groupKey, nil, collapsed, nav, true, onToggle, onRClick) + if not collapsed then + for _, item in ipairs(e.items) do + placeRow(item, 18, nil) + end + end else local groupKey = sectionKey .. ":" .. e.name local collapsed = collapsedDb[groupKey] == true @@ -1148,7 +1199,8 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece local items local usingWorldChildren = false local parentMatched = e.navigateData and not e.navigateData.synthesized - if parentMatched and e.ancestorMapID then + local autoExpand = EasyFind.db.mapTabAutoExpand ~= false + if parentMatched and e.ancestorMapID and autoExpand then local world = GetWorldChildren(e.ancestorMapID) items = world or e.items usingWorldChildren = world and #world > 0 @@ -1517,6 +1569,24 @@ function MapTab:RunSearch(text) -- don't pollute "This Zone" on a world/continent map. local localFiltered = FilterAndDedupe(localRaw, seen, true) local localEntries = GroupBySharedParent(localFiltered) + -- A continent/world-type zone match (e.g. "Eastern Kingdoms" while + -- viewing EK) is dropped from local by EXCLUDE_FROM_LOCAL_MAPTYPES, + -- so its "This Zone" group ends up with synthesized navigateData + -- and parentMatched stays false — auto-expand never fires. Promote + -- the synthesized header back to the real result so the renderer's + -- parentMatched check sees it. + for i = 1, #localRaw do + local r = localRaw[i] + if r and r.isZone and r.zoneMapID then + for j = 1, #localEntries do + local e = localEntries[j] + if e.type == "group" and e.ancestorMapID == r.zoneMapID + and e.navigateData and e.navigateData.synthesized then + e.navigateData = r + end + end + end + end local globalRaw if multiToken then globalRaw = BuildMultiTokenResults(tokens, true) @@ -1525,7 +1595,70 @@ function MapTab:RunSearch(text) end if myGen ~= lastQueryGen then return end local globalFiltered = FilterAndDedupe(globalRaw, seen, false) + + -- Pull duplicate-named zones out of globalFiltered into version groups. + -- Names matching 2+ entries collapse into one header that lists every + -- variant (highest-mapID first as a "newest" heuristic). The header + -- starts collapsed; clicking it navigates to the newest variant. + local versionGroups + do + local byName = {} + for i = 1, #globalFiltered do + local r = globalFiltered[i] + if r and r.isZone and r.zoneMapID and r.name then + local key = r.name:lower() + local list = byName[key] + if not list then list = {}; byName[key] = list end + list[#list + 1] = r + end + end + local removed = {} + for _, list in pairs(byName) do + if #list >= 2 then + table.sort(list, function(a, b) + return (a.zoneMapID or 0) > (b.zoneMapID or 0) + end) + for j = 1, #list do removed[list[j]] = true end + versionGroups = versionGroups or {} + versionGroups[#versionGroups + 1] = { + type = "version", + name = list[1].name, + items = list, + navigateData = list[1], + } + end + end + if versionGroups then + local kept = {} + for i = 1, #globalFiltered do + if not removed[globalFiltered[i]] then + kept[#kept + 1] = globalFiltered[i] + end + end + globalFiltered = kept + end + end + local globalEntries = GroupBySharedParent(globalFiltered) + if versionGroups then + for i = #versionGroups, 1, -1 do + tinsert(globalEntries, 1, versionGroups[i]) + end + end + + -- Drop any global group whose continent matches the currently-viewed + -- map. The 'this zone' section already covers everything inside + -- that continent, so surfacing it again under 'across the world' is + -- pure redundancy. Belt-and-suspenders against any dedup miss. + local viewedMapID = WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID() + if viewedMapID then + for i = #globalEntries, 1, -1 do + local e = globalEntries[i] + if e.type == "group" and e.ancestorMapID == viewedMapID then + tremove(globalEntries, i) + end + end + end -- Stash top N result names (locals first, then globals) for -- autocomplete. Ghost/Tab picks the first name whose lowercase @@ -2416,6 +2549,44 @@ local function CreatePanel(qmf) end) p.recentCheck = recentCheck + -- "Auto expand headers" checkbox to the right of the recent toggle. + -- When on (default), a matched zone header lists every child the + -- world hierarchy says lives under it. When off, only children + -- whose names actually match the query show up — handy when you + -- want a focused list instead of a continent's full roster. + local expandCheck = CreateFrame("CheckButton", "EasyFindMapTabAutoExpandCheck", p, "UICheckButtonTemplate") + expandCheck:SetSize(20, 20) + expandCheck:SetPoint("LEFT", recentLabel, "RIGHT", 12, -1) + expandCheck:SetHitRectInsets(0, -120, 0, 0) + local expandLabel = expandCheck:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + expandLabel:SetPoint("LEFT", expandCheck, "RIGHT", 2, 1) + expandLabel:SetText("Auto expand headers") + expandCheck:SetScript("OnShow", function(self) + self:SetChecked(EasyFind.db.mapTabAutoExpand ~= false) + end) + expandCheck:SetScript("OnClick", function(self) + EasyFind.db.mapTabAutoExpand = self:GetChecked() and true or false + RefreshCurrentSearch() + end) + expandCheck:SetScript("OnEnter", function(self) + GameTooltip:SetOwner(self, "ANCHOR_TOPRIGHT") + GameTooltip:SetText("Auto expand headers") + GameTooltip:AddLine( + "When a search matches a parent zone, list every child it " + .. "contains - even ones that don't match your query.", + 1, 1, 1, true) + GameTooltip:AddLine(" ") + GameTooltip:AddLine( + "Example: searching |cffffd200east|r matches Eastern Kingdoms. " + .. "With this on, every zone inside Eastern Kingdoms is listed " + .. "under it. With it off, only zones whose names actually match " + .. "|cffffd200east|r show up (Eastern Plaguelands, etc.).", + 0.85, 0.85, 0.85, true) + GameTooltip:Show() + end) + expandCheck:SetScript("OnLeave", GameTooltip_Hide) + p.expandCheck = expandCheck + return p end From b38ccdd68e76cf3e753ea7a173883e2bd32f5803 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 1 May 2026 02:15:08 -0400 Subject: [PATCH 030/103] First-time setup: keybind section header + button placement Group the two keybind buttons under a "Keybindings" header with a thin divider so they read as a labeled subsection rather than two loose buttons. Move "See demo" / "Got it" outside the panel: anchor TOP to the panel's BOTTOM rather than BOTTOM/BOTTOM with a positive y offset. Buttons no longer crowd the keybind section above. --- UI.lua | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/UI.lua b/UI.lua index bd57836..b15b67e 100644 --- a/UI.lua +++ b/UI.lua @@ -7051,16 +7051,29 @@ function UI:ShowFirstTimeSetup() local LEFT_COL_X = panelWidth / 4 -- center of the left half local RIGHT_COL_X = panelWidth * 3/4 -- center of the right half - -- Invisible row anchor: y is locked to fadeDesc bottom so the labels - -- below sit a fixed gap under the description. Width spans the panel - -- so label x = row.left + colCenterX places each label center on its - -- column. Using a single-anchor TOP on each label avoids the over- - -- constrained x that two SetPoints would create. + -- Section header for the keybind row, with a thin divider underneath + -- so it reads as a labeled subsection rather than two loose buttons. + local keybindHeader = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight") + keybindHeader:SetText("Keybindings") + keybindHeader:SetPoint("TOP", fadeDesc, "BOTTOM", 0, -14) + + local keybindDivider = panel:CreateTexture(nil, "ARTWORK") + keybindDivider:SetColorTexture(1, 1, 1, 0.18) + keybindDivider:SetHeight(1) + keybindDivider:SetPoint("LEFT", panel, "LEFT", 24, 0) + keybindDivider:SetPoint("RIGHT", panel, "RIGHT", -24, 0) + keybindDivider:SetPoint("TOP", keybindHeader, "BOTTOM", 0, -4) + + -- Invisible row anchor: y is locked to the divider so the labels + -- below sit a fixed gap under it. Width spans the panel so label + -- x = row.left + colCenterX places each label center on its column. + -- Using a single-anchor TOP on each label avoids the over-constrained + -- x that two SetPoints would create. local keybindRow = CreateFrame("Frame", nil, panel) keybindRow:SetHeight(1) keybindRow:SetPoint("LEFT", panel, "LEFT") keybindRow:SetPoint("RIGHT", panel, "RIGHT") - keybindRow:SetPoint("TOP", fadeDesc, "BOTTOM", 0, -12) + keybindRow:SetPoint("TOP", keybindDivider, "BOTTOM", 0, -10) local function CreateKeybindLabel(text, colCenterX) local lbl = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") @@ -7129,16 +7142,18 @@ function UI:ShowFirstTimeSetup() CreateKeybindButton("toggle", toggleLabel, "EASYFIND_TOGGLE_FOCUS", "Ctrl+Space") CreateKeybindButton("map", mapLabel, "EASYFIND_MAP_FOCUS", "Ctrl+M") - -- Bottom buttons swapped: "See demo" on the LEFT, "Got it" on the - -- RIGHT. Centered as a pair around the panel's vertical midline. + -- Buttons sit just below the panel, centered as a pair around the + -- panel's vertical midline. "See demo" on the LEFT, "Got it" on the + -- RIGHT. Anchoring TOP to the panel's BOTTOM places them outside the + -- frame so they don't crowd the keybind section above. local seeDemoBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") seeDemoBtn:SetSize(100, 22) - seeDemoBtn:SetPoint("BOTTOMRIGHT", panel, "BOTTOM", -4, 12) + seeDemoBtn:SetPoint("TOPLEFT", panel, "BOTTOM", 4, -8) seeDemoBtn:SetText("See demo") local gotItBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") gotItBtn:SetSize(100, 22) - gotItBtn:SetPoint("BOTTOMLEFT", panel, "BOTTOM", 4, 12) + gotItBtn:SetPoint("TOPRIGHT", panel, "BOTTOM", -4, -8) gotItBtn:SetText("Got it") -- During setup: allow drag without holding Shift From 7eda4a2a3523bfefacc94aabc90c6a0fa9e79086 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 1 May 2026 02:52:28 -0400 Subject: [PATCH 031/103] MapTab nav: Enter-activate clears focus + nav state like a click After pressing Enter on a highlighted result, the next Esc was re-focusing the search bar instead of closing the map. The mouse- click path leaves searchBox unfocused (row's OnMouseDown clears it) with navRowIndex=0 and navFrame keyboard off; pressing Esc in that state has no Lua handler to capture it, so WoW's binding system takes over and closes the map. Enter-activation skipped that cleanup. With navRowIndex still > 0 and navFrame still keyboard-enabled, Esc was first consumed by the editbox (clearing search focus) and then picked up by navFrame's Esc branch, which re-focuses the search bar -- the user's reported symptom. Mirror the post-click state explicitly when Enter activates a highlighted row: SetNavRowIndex(0), SetNavFrameCapture(false), and ClearFocus on searchBox. Esc then propagates and closes the map, matching the click flow. --- MapTab.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/MapTab.lua b/MapTab.lua index a9c81ca..afd8673 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -1889,6 +1889,16 @@ HandleNavKey = function(key, keepSearchFocus) elseif key == "ENTER" then if navRowIndex > 0 then ActivateNavSelection() + -- Match the mouse-click activation flow: a clicked row + -- ends with searchBox unfocused, navRowIndex=0, and + -- navFrame keyboard off. Without mirroring that state, + -- the next Esc would clear editbox focus, then navFrame + -- (still keyboard-enabled with navRowIndex > 0) would + -- pick up the propagated key and re-focus the search + -- bar via the Esc branch — instead of closing the map. + SetNavRowIndex(0) + SetNavFrameCapture(false) + if panel and panel.searchBox then panel.searchBox:ClearFocus() end return true end return false From 2e5ff0a08b20ec685c53f607bb04579e265005a6 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 1 May 2026 05:35:19 -0400 Subject: [PATCH 032/103] MapTab: hold-to-step, dup zones, UI search bar integration SearchBoxTemplate swallows OS-level auto-repeat OnKeyDown events, so the shared Utils.CreateKeyRepeat ticker is restored as the hold-to-step source. The editbox now has its own OnKeyUp hook in addition to navFrame's, so a release on a key whose down event fired from the editbox still stops the ticker. SetNavFrameCapture and every focus/click path that releases nav keyboard capture defensively stops the ticker too. Duplicate-named world-hierarchy children (Arathi Highlands warfront variants, old/new Dalaran, etc.) collapse into a nested version sub-group: one collapsible header per name; expanding lists each variant with a "map #" suffix so they're distinguishable. Default-collapsed; clicking the header navigates to the highest mapID (newest) variant. Synthesized children always use the zone icon now -- Blizzard mis-types instanced cities as Dungeon, so the previous mt-based glyph fired wrong on Dalaran. Clicking a map result in the UI search bar now activates the MapTab and populates its search box with the originating query (unfocused so WASD still works). New MapTab:OpenWithQuery mirrors Focus's tab-swap path with a _pendingQuery fallback for the cold- start race; SearchForUI carries the query through to the click handler via data.query. UI search bar map results now respect the MapTab cog wheel filters (mapTabFilters) via GetFilterBucket, replacing the legacy globalSearchFilters block tied to the deprecated floating bars. Toggling a filter in the cog refreshes the UI search bar too. --- MapSearch.lua | 41 +++++---- MapTab.lua | 227 +++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 224 insertions(+), 44 deletions(-) diff --git a/MapSearch.lua b/MapSearch.lua index ed864bf..da84c1f 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -8720,25 +8720,18 @@ function MapSearch:SearchForUI(query) local scored = self:SearchPOIs(pois, query) if not scored or #scored == 0 then return nil end - -- Apply global search filters (zones / dungeons / raids / delves) - -- to the combined result set so category toggles affect UI results - -- whether they come from local scan or the global instance cache. + -- Apply the MapTab cog filters so the UI search bar surfaces the + -- same buckets the user picked there. Mirrors FilterAndDedupe in + -- MapTab.lua: any POI whose bucket is explicitly disabled + -- (filters[bucket] == false) drops out. Buckets without a saved + -- value default to enabled — same convention DB_DEFAULTS uses. do - local gFilters = EasyFind.db.globalSearchFilters - if gFilters then + local mtFilters = EasyFind.db.mapTabFilters + if mtFilters then local filtered = {} for _, r in ipairs(scored) do - local dominated = false - if r.isZone and r.category == "zone" and gFilters.zones == false then - dominated = true - elseif r.category == "dungeon" and gFilters.dungeons == false then - dominated = true - elseif r.category == "raid" and gFilters.raids == false then - dominated = true - elseif r.category == "delve" and gFilters.delves == false then - dominated = true - end - if not dominated then + local bucket = GetFilterBucket(r) + if mtFilters[bucket] ~= false then filtered[#filtered + 1] = r end end @@ -8762,6 +8755,11 @@ function MapSearch:SearchForUI(query) x = r.x or r.entranceX, y = r.y or r.entranceY, keywords = r.keywords, + -- Carry the originating query so HandleUISearchClick can + -- pre-populate the MapTab search box. Mirrors what would + -- happen if the user had typed the same query inside the + -- MapTab and clicked the same row. + query = query, } -- Preserve fields needed by SelectResult for global results if r.isZone then @@ -8797,6 +8795,17 @@ function MapSearch:HandleUISearchClick(data) local isGlobalResult = data.isZone or data.isDungeonEntrance + -- Activate the MapTab + populate the search box with the originating + -- query. Mirrors the end state the user would have if they'd typed + -- the same query inside the MapTab and clicked the same row: tab + -- active, results visible, search bar showing the query (unfocused + -- so the click doesn't trap WASD). MapTab.OpenWithQuery handles + -- ToggleWorldMap itself, so the per-branch ToggleWorldMap calls + -- below become no-ops when the MapTab path runs. + if ns.MapTab and ns.MapTab.OpenWithQuery and data.query then + ns.MapTab:OpenWithQuery(data.query) + end + if isGlobalResult then -- Open the world map at the target and show a waypoint/zone. -- Guide/breadcrumb mode is reserved for an explicit right-click diff --git a/MapTab.lua b/MapTab.lua index afd8673..f9e6a3c 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -168,6 +168,12 @@ local function GetWorldChildren(mapID) worldChildrenCache[mapID] = result return result end + -- Use the generic zone icon for every child regardless of + -- Blizzard's mapType. The API marks instanced cities (Dalaran, + -- etc.) as Dungeon, so an mt-based dungeon glyph mis-fires on + -- cities. Synthesized expansions of a continent never contain + -- real dungeons anyway — those live inside zones, not directly + -- under the continent. local children = GetMapChildrenInfo(mapID, nil, false) if children then for i = 1, #children do @@ -176,26 +182,13 @@ local function GetWorldChildren(mapID) if child.name and mt and mt ~= Enum.UIMapType.Micro and mt ~= Enum.UIMapType.Orphan then - local category, icon - if mt == Enum.UIMapType.Dungeon then - -- Sub-zones of dungeon mapType render with the - -- dungeon-portal category icon instead of the - -- generic continent texture. The API doesn't - -- distinguish raids here, so all instance-type - -- children get the dungeon glyph. - category = "dungeon" - icon = nil - else - category = "zone" - icon = 237382 - end result[#result + 1] = { name = child.name, - category = category, + category = "zone", isZone = true, zoneMapID = child.mapID, zoneMapType = mt, - icon = icon, + icon = 237382, synthesized = true, } end @@ -254,6 +247,13 @@ local lastRenderedQuery local navRowIndex = 0 local visibleNavRows = {} local navFrame -- created lazily by EnsureNavFrame() +-- Hold-to-step controller for nav keys; assigned by EnsureNavFrame. +-- Declared up here so the editbox OnKeyUp hook (defined later in +-- CreateSearchBox) can reach it. SearchBoxTemplate consumes OS-level +-- auto-repeat OnKeyDown events, so unlike UI.lua's plain EditBox we +-- can't rely on the OS to walk the list while a key is held. The +-- ticker fires the action at an accelerating cadence on its own. +local navKeyRepeat -- --------------------------------------------------------------------------- -- Tab select glow + icon tint helper @@ -1061,6 +1061,7 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece if navFrame then Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) end + if navKeyRepeat then navKeyRepeat.Stop() end local y = 4 local collapsedDb = sessionCollapsed @@ -1235,8 +1236,65 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece end end else + -- Group duplicate-named children into nested + -- version sub-groups. Blizzard's hierarchy keeps + -- multiple mapIDs sharing a display name (Arathi + -- Highlands warfront variants, old Dalaran Crater + -- + revisions, etc.). Show one collapsible header + -- per name; expanding lists each variant tagged + -- by mapID so they're distinguishable. Default- + -- collapsed; clicking the header navigates to + -- the highest-mapID (newest) variant. + local byName, order = {}, {} for _, item in ipairs(items) do - placeRow(item, 18, e.name) + local n = item.name or "" + local list = byName[n] + if not list then + list = {} + byName[n] = list + order[#order + 1] = n + end + list[#list + 1] = item + end + for _, n in ipairs(order) do + local variants = byName[n] + if #variants == 1 then + placeRow(variants[1], 18, e.name) + else + table.sort(variants, function(a, b) + return (a.zoneMapID or 0) > (b.zoneMapID or 0) + end) + local subKey = groupKey .. ":var:" .. n + local stored = collapsedDb[subKey] + local subCollapsed = stored ~= false + local subCaptured = subCollapsed + local subToggle = function() + collapsedDb[subKey] = not subCaptured and true or false + RefreshCurrentSearch() + end + local subNav = variants[1] + local subRClick = function() headerRightClick(subNav, subCaptured) end + placeGroupHeader(n, subKey, nil, subCollapsed, subNav, + true, subToggle, subRClick, 18) + if not subCollapsed then + for _, variant in ipairs(variants) do + -- Clone so we don't mutate the + -- per-session worldChildrenCache + -- entry. pathPrefix flows through + -- placeRow's gray-suffix path. + placeRow({ + name = variant.name, + category = variant.category, + isZone = variant.isZone, + zoneMapID = variant.zoneMapID, + zoneMapType = variant.zoneMapType, + icon = variant.icon, + synthesized = variant.synthesized, + pathPrefix = "map #" .. tostring(variant.zoneMapID), + }, 36, n) + end + end + end end end end @@ -1722,6 +1780,12 @@ local function EnsureNavFrame() self:SetPropagateKeyboardInput(true) end end) + navFrame:SetScript("OnKeyUp", function(_, key) + if navKeyRepeat and navKeyRepeat.IsKey(key) then + navKeyRepeat.Stop(key) + end + end) + navKeyRepeat = Utils.CreateKeyRepeat(navFrame) return navFrame end @@ -1780,6 +1844,13 @@ local function SetNavFrameCapture(on) local nf = EnsureNavFrame() if not nf then return end Utils.SafeCallMethod(nf, "EnableKeyboard", on and true or false) + -- Releasing keyboard capture defuses any in-flight hold-to-step + -- ticker. Without this, a missed OnKeyUp (e.g. user clicked the + -- map mid-hold so the editbox+navFrame both lost keyboard input + -- before the release fired) would leave repeatActive on, and the + -- ticker would resume firing the move action the next time + -- navFrame became visible. + if not on and navKeyRepeat then navKeyRepeat.Stop() end end local function SetNavRowIndex(i) @@ -1825,17 +1896,17 @@ HandleNavKey = function(key, keepSearchFocus) panel.searchBox:ClearFocus() end SetNavFrameCapture(true) - -- Single-step: each OnKeyDown moves one row. WoW delivers - -- repeated OnKeyDown events at the OS auto-repeat cadence - -- while the key is held, so a held arrow / Ctrl+J still walks - -- the list; we just don't run our own repeat ticker. Owning - -- the repeat ourselves bit us in two ways: the press that - -- fires from the editbox doesn't have a paired OnKeyUp on - -- navFrame to stop the timer, so repeatActive could get - -- stuck and auto-scroll on the next panel show; and the OS - -- repeat firing through navFrame's OnKeyDown would also call - -- Start, racing the ticker. - MoveNavSelection(1) + -- Hold-to-step via the OnUpdate ticker. SearchBoxTemplate + -- swallows OS-level auto-repeat OnKeyDown events, so a held + -- key would only fire once without our own ticker. The + -- editbox path needs its own OnKeyUp to stop the repeat + -- (paired with the OnKeyDown that started it); see the + -- editBox:HookScript("OnKeyUp", ...) below in CreateSearchBox. + if navKeyRepeat then + navKeyRepeat.Start(key, function() MoveNavSelection(1) end) + else + MoveNavSelection(1) + end return true elseif key == "UP" or (ctrl and key == "K") then if #visibleNavRows == 0 then return false end @@ -1852,7 +1923,11 @@ HandleNavKey = function(key, keepSearchFocus) panel.searchBox:ClearFocus() end SetNavFrameCapture(true) - MoveNavSelection(-1) + if navKeyRepeat then + navKeyRepeat.Start(key, function() MoveNavSelection(-1) end) + else + MoveNavSelection(-1) + end return true elseif key == "SPACE" then -- Space inside the search box is a literal character and must @@ -1962,6 +2037,7 @@ local function CreateSearchBox(parent) navRowIndex = 0 if visibleNavRows then wipe(visibleNavRows) end if navFrame then Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) end + if navKeyRepeat then navKeyRepeat.Stop() end end) end @@ -2111,6 +2187,18 @@ local function CreateSearchBox(parent) HandleNavKey(key, true) Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) end) + -- Stop the hold-to-step ticker on key release. The OnKeyDown that + -- started the repeat fires from the editbox, and WoW pairs OnKeyUp + -- to the same frame regardless of focus changes mid-press, so the + -- navFrame OnKeyUp handler isn't always reached. Mirror it here so + -- a release on a key whose down event was on the editbox still + -- stops the repeat -- otherwise it would keep ticking after the + -- user lets go. + editBox:HookScript("OnKeyUp", function(_, key) + if navKeyRepeat and navKeyRepeat.IsKey(key) then + navKeyRepeat.Stop(key) + end + end) -- Force focus on click. SearchBoxTemplate doesn't always grab focus -- back cleanly after the user has been clicking around in result @@ -2121,12 +2209,14 @@ local function CreateSearchBox(parent) if navFrame then Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) end + if navKeyRepeat then navKeyRepeat.Stop() end navRowIndex = 0 end) editBox:HookScript("OnEditFocusGained", function() if navFrame then Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) end + if navKeyRepeat then navKeyRepeat.Stop() end end) editBox:HookScript("OnEnterPressed", function(self) @@ -2438,6 +2528,17 @@ local function CreatePanel(qmf) if key == "rares" and dropdown.UpdateAutoTrackRow then dropdown:UpdateAutoTrackRow() end + -- The UI search bar's map results follow the same cog + -- settings, so a toggle here should refresh that view + -- too. Without this, a stale UI dropdown could keep + -- showing flight masters after the user disabled them. + local uiMod = ns.UI + local uiSb = uiMod and uiMod.searchFrame and uiMod.searchFrame.editBox + if uiMod and uiMod.OnSearchTextChanged + and uiSb and uiSb:IsShown() then + local txt = uiSb:GetText() or "" + if txt ~= "" then uiMod:OnSearchTextChanged(txt) end + end end ) AttachAutoTrackRow(dropdown) @@ -2638,6 +2739,55 @@ function MapTab:Focus() end) end +-- Activate the MapTab and populate the search box with `query`, leaving +-- the editbox unfocused. Used by the floating UI search bar so clicking +-- a map result there mirrors what would happen if the same query had +-- been typed and submitted from inside the MapTab. SetText fires +-- OnTextChanged, which schedules RunSearch on the next frame, so result +-- rows render automatically. +function MapTab:OpenWithQuery(query) + if not initialized then self:Initialize() end + if not WorldMapFrame or not WorldMapFrame:IsShown() then + if ToggleWorldMap then ToggleWorldMap() end + if not initialized then self:Initialize() end + end + -- Init not ready (rare first-load race): stash the query so the + -- ADDON_LOADED → Initialize callback can replay it once the panel + -- exists. Mirrors _pendingFocus. + if not panel or not tabFrame then + MapTab._pendingQuery = query + return + end + -- Order matters: set the search text BEFORE invoking the tab's + -- OnMouseUp. ShowOurPanel reads the current editbox text and runs + -- a synchronous RunSearch on every show, so seeding the text first + -- means the panel's first render already uses our query instead of + -- briefly flashing an empty / recent-searches state. ClearFocus + -- prevents the editbox from grabbing keyboard input — the user + -- triggered this by clicking a result, so they expect to keep + -- moving with WASD. + if panel.searchBox then + panel.searchBox:SetText(query or "") + panel.searchBox:ClearFocus() + end + local clickHandler = tabFrame:GetScript("OnMouseUp") + if clickHandler then clickHandler(tabFrame, "LeftButton") end + C_Timer.After(0, function() + if not panel then return end + if not panel:IsShown() and clickHandler then + clickHandler(tabFrame, "LeftButton") + end + if panel.searchBox and panel:IsShown() then + -- Re-apply in case some other path (e.g. tab swap focus + -- restoration) blanked the text on this frame. + if panel.searchBox:GetText() ~= (query or "") then + panel.searchBox:SetText(query or "") + end + panel.searchBox:ClearFocus() + end + end) +end + -- --------------------------------------------------------------------------- -- Initialize (hook Blizzard tab clicks + fullscreen-hide) -- --------------------------------------------------------------------------- @@ -2724,6 +2874,22 @@ function MapTab:Initialize() end end) end + -- Mirror handling for OpenWithQuery: replay the deferred + -- query once the tab + panel exist. Cleared even if + -- _pendingFocus was also set (focus path takes precedence). + if MapTab._pendingQuery ~= nil and not MapTab._pendingFocus then + local q = MapTab._pendingQuery + MapTab._pendingQuery = nil + SafeAfter(0, function() + if panel then + ShowOurPanel() + if panel.searchBox and panel:IsShown() then + panel.searchBox:SetText(q or "") + panel.searchBox:ClearFocus() + end + end + end) + end end) end @@ -2763,6 +2929,11 @@ function MapTab:Initialize() MapTab._pendingFocus = nil SafeAfter(0, function() MapTab:Focus() end) end + if MapTab._pendingQuery ~= nil then + local q = MapTab._pendingQuery + MapTab._pendingQuery = nil + SafeAfter(0, function() MapTab:OpenWithQuery(q) end) + end end -- Blizzard_WorldMap is on-demand; hook the moment it loads. From fc3bb86f9bac32dd7642c16b001031e5cf25de7a Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 1 May 2026 05:49:18 -0400 Subject: [PATCH 033/103] Search bar textures: rounded corners replace chamfers New SearchBarFill.tga / SearchBarBorder.tga keep the same 256x128 RGBA top-down v1 layout, so the existing Utils.CreateSearchBorder 9-slice math (cap = 0.125, mid = 0.125-0.875, right = 0.875) works unchanged. Cap regions now carry a quarter-circle of radius 32 at each outer corner; the resulting silhouette is a rounded rectangle instead of the prior chamfered look. Border stroke is 4px in texture (matches the original chamfered design's visual weight). Generator lives at EasyFindDev/tools/gen_search_bar.py (committed separately to that repo) -- rerun it to regenerate the textures after editing tunables (corner radius, stroke width, etc.). --- textures/SearchBarBorder.tga | Bin 131090 -> 131090 bytes textures/SearchBarFill.tga | Bin 131090 -> 131090 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/textures/SearchBarBorder.tga b/textures/SearchBarBorder.tga index d914ab0a109cba3628bad1076a761b09bd374564..c09fa0fc17cab29d58ef9eafd12c11fec34ae487 100644 GIT binary patch literal 131090 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 literal 131090 zcmeI(O>P@W5JX{mPU7X9!za_Jz16T`)W8U&{7W{wqdx*dJJLvztoNdJy50V{{r~#( zcKiJA`}_Otfj@8e-{s{5oWS!=VC&c;JNu~LrTfog?l(Ju?@!=pcYyAHPshKV!1GVw zD0hJFKcBUCa01f_y!sB9x_`TWP(RLq1J7|F?}4p@uQ==Nee?D8K6u4v@t)58Dh_NN z%h%S$J-`3J3#k!|7cyf>gU<} z4$ytwziQWx`n~tM19V^akJ^E&f39cW0lKgISMS)-zyGuD0NvO9qr30(eRK5DcYyBe zzHXh%fk)i|x_>V2dWzR_;BW5$-RIJB$@82p{OKK_`{(4Xr?{m9zk3JhK8MmF`P@$Y z>K&l_=jN=Z_eb0<@2WK2{oE*r$ zXP$Pf)av=MjdD=1K5(hdQ z$i8RBxq~x~I8F{^-!o4;W?bSxhXdL7%s6*&#u3NKf$V$cX~&F99O!T$`<@x+4$e5@ zI607g&phpzaft&R4rJdmyvhSIv9WyR*pu>UeduE(FIOB-pNrkIvmKpXU4gMGmbb;4rJdmPdjE@ z;y{N3+4syicW}lL$H{^0d**4!j7uEoa3K4h8RrhpIN~@tkbTcQ?U-?i104=z-!tRf z!5K#!CkL|cnWr5yE^(m4f$V!`oI5zGfz8aT;f271KIb?ICpTy5y#1a?0e>E$BauH=x`wWo*Cy3&N$*Y zIgowNJnfiqi31%DWZyI6+`$<~947~|@0q6^GcIwU!-4F3W}G`Xeb0<@2WK2{oE*r$XP$Pf)av=MjdD=1K5(hdQ$i8RBxq~x~I8F{^-!o4;W?bSxhXdL7%s6*&#u3NKf$V$c zX~&F99O!T$`<@x+4$e5@I607g&phpzaft&R4rJdmyvhSIv9WyR*pu>Ue zduE(FIOB-p_}+yU2hW;x(_ZU_Ff`-{6f;N1KdN6+hV{M+s?XWRkTb7!&lJSY1{b${`9 z2b|M`#n1CP{2tx?<&HbxIu0%7p66oztnM!l+yUqEVR7^9=l1OGFNfR#{Vpxmp66h_ zSNE3>?tpW6v3Pm*^4h!m%O!U}uTzV$=dl^Ts{6|ecfhgzSe!ijIK8_2%c+m;yr+dBBl@4vmjmlJRT`=7w8yT9%ocfkJa jz2mh{;3(a92VC1j-+uoSI9m7J0sA}P9j|!;5AFVcXEc>l diff --git a/textures/SearchBarFill.tga b/textures/SearchBarFill.tga index 1fa1a96b4f65269b90ea9f2eb39b125abbed558e..121e3d3efe653cba4d338cc19426baf9934094bf 100644 GIT binary patch literal 131090 zcmeI)OHLb00EJ;*b0(39kRtBzt^kPzAU1#$lzAAl1ZD|aU?GuU0g>|9Kn9sckuu6O zFLI7ur*F}h7!1at%YR8lRk!W7>vK-^E3mY*vecF3-J${dxQ4r&jz>b?cfr zPhAQE2uQ%0NIsI4K8&;FjBO;}>USB;y8CYRp5Et=P9@Z?s~)R&d{2{@b1s7vO6Tb&E& z^`mQv-i|k>;%j^q1Q2-m0?BITfT`{aXn48KpY#39DGz_-d_n{eus|}K>j5{qFQC_D zzrR};xwls!Xb>2XKz)yJH`fE){;6neK38yktm^+`XMgGd;@p zg#2gUf2QZU)b|y}Yxs!(0z(o=hR>XPeef%<1!TS2{W7>*Lnw)+-1sLFA_5GV*VB9QX{XWJRSbWc#rIqvuG6uD%+7%%Y?0R)CFkW3%f#{e3X z_x-MqRKKWZu27eP00NB&^B-@AO7{Gt8FZcZ3 zs_!`>&nYjVK>&e91e|SW+*$AL1**TT>f?S_3IYhUE0COT`xwAQL-pSmbjdY>@e)4~ zK%g~&Wc;LL-@haHrVNr}|JJ^3#)H6Rfn?m7clMvC=5KWS+<<}r0__SU+m0?E3w|E!>7PQVBO1lkrz)}8$)s$2tX`}<~e z2t=U1_UG)cs=g@?=C~lwr9}XNwgi%KXMS}_MeRu$;`g?O%4iTkpeK-wpE&z2dVE{e z=L9(~=)HtR1Q2LMAUWSI*QWFupdkTg+ZlJ(ll!$Y4;+?plK%oh zgFs^f$@XE%{F=tMT9(0Rd;)*>=XA^-=E2Jdo>v$6D`_YXsvZ zejo%LmBKSj$q2H02MCG*92 ziJu4{FjRqLdEa^0AVyhF_S0s)+OsmyTrVh#GzcItFo9(FY*O~iW4R~rSY!OG%6Y)R zzHB}&0+m3H`<-QHdX(?%@2_h0oSX;jmhJf1f%^dkg9d?t2spDj?%%y`-(SxKdR_ic zz)>0Nw}FhAkBI;RV}WG$sAPYX={o0=`r1T}15OKQ<`R4nK;U5uB&(+-`&VA?yC(mm zdxGWlnFBnha4zt$zk5F2;tDvM+3zR&|GU>WDfjhb+Lq^l{&9e>C;GYq1px#ej({_n zYyHW-f4BeAef~P%H|hKJsw;l3+FTD%w|g)5DF`6&_XLuAe}bIv-+I5_pBJ>vdJfpr z@+ZHM3daIt2Yw>(Hw5Z^zB9R3_R}-Vemxgx`?)W$ss5{Kz}zFOFz6z1&jR(mJ-?N+ zxLLA4)phK~|nP)*@@AQH3)%wC}0-+SxM-a&G!ewWRxyN_1yQ=bp` z_X+%agZ@88DVd-AdAVN@6lWWbqlHk=V>#hKaaW$DU{)z6u>{~JyK_I3aO literal 131090 zcmeIySx!Ph5CzbuEAi81vU7wF3?U3}=&|aUkQjwW*Xg=S_jDf{q6XjKz)HxIzaa8-?eWb@Jyh02gv@j zTk$sn?F4#tfb6%sW8XyJoj~gjko|YJ;eQ0$3$*G0*>8WhzLCHhf!ZA)`)h8&dkFLs zsMP_o-|tR+Gl4Avr8_|Ox9EE>f&K!eIzaaO-=z}}*duVX17v@Xy3Y_8M__LU$o@EY z=p+P=2yE>D**~J~vjoNySla=zKi=Ir5rGl{pE^MHODKB=fpG<1b%5-TduL8YprpWK z2grU&UC$ygzQC;mWPkj-azX+%1Z2O4s%H`)K!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNBU zRp69$aB~w#E^y8Ma(U^1*8w5d!%%?*N@rgO1YQ=Sg%YTheH{>LO$-xIpmz3kK)|IjQV4-o+1CLf*2NHk z1X^cb2LxIkqf9N(EBiWN>a}tB00O Date: Fri, 1 May 2026 05:54:52 -0400 Subject: [PATCH 034/103] Search bar: true pill (semicircle endcaps) instead of rounded rect New SearchBarFill / SearchBarBorder TGAs are full pills (Pillow's rounded_rectangle with radius == height / 2 == 64), so each end of the texture carries a perfect half-circle. The 9-slice cap ratio bumps from 0.125 to 0.25 so the cap region (64 px in a 256 px texture) lines up with that semicircle, with a 128 px solid mid section between them. Display cap width is now derived from the frame height (cap_w = height / 2), recomputed via an OnSizeChanged hook installed in CreateSearchBorder. Theme / font-size changes that call SetHeight on the search frame propagate to the cap width automatically; ScaleSearchBorder is preserved as a public no-op (re-applies the height-derived cap) so existing callers keep compiling without churn. --- Utils.lua | 42 ++++++++++++++++++++++++----------- textures/SearchBarBorder.tga | Bin 131090 -> 131090 bytes textures/SearchBarFill.tga | Bin 131090 -> 131090 bytes 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/Utils.lua b/Utils.lua index aa05a70..4e20b3d 100644 --- a/Utils.lua +++ b/Utils.lua @@ -129,11 +129,15 @@ ns.SEARCHBAR_FONT = "EasyFindSearchFont" 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} -local TC_MID = {SEARCH_CAP_W / SEARCH_TEX_W, 1 - SEARCH_CAP_W / SEARCH_TEX_W, 0, 1} -local TC_RIGHT = {1 - SEARCH_CAP_W / SEARCH_TEX_W, 1, 0, 1} +-- Pill-shape 9-slice cap occupies 25% of the texture on each side +-- (0..0.25 left, 0.75..1.0 right) so the texture's full semicircle +-- end-cap renders at any bar height. Display cap width is half the +-- frame height -- a true semicircle when rendered, recomputed on +-- every frame resize via OnSizeChanged. +local CAP_TEX_RATIO = 0.25 +local TC_LEFT = {0, CAP_TEX_RATIO, 0, 1} +local TC_MID = {CAP_TEX_RATIO, 1 - CAP_TEX_RATIO, 0, 1} +local TC_RIGHT = {1 - CAP_TEX_RATIO, 1, 0, 1} local BORDER_R, BORDER_G, BORDER_B = 0.42, 0.42, 0.42 local function CreateTexPart(frame, layer, texPath, tc, vertR, vertG, vertB, vertA) @@ -144,16 +148,24 @@ local function CreateTexPart(frame, layer, texPath, tc, vertR, vertG, vertB, ver return tex end +local function ApplyCapWidths(frame) + if not frame.searchBorder then return end + local h = frame:GetHeight() or 0 + if h <= 0 then return end + local capW = h / 2 + local sb = frame.searchBorder + sb.fillLeft:SetWidth(capW) + sb.fillRight:SetWidth(capW) +end + function ns.CreateSearchBorder(frame) local fillLeft = CreateTexPart(frame, "BACKGROUND", SEARCH_TEX_FILL, TC_LEFT, 0, 0, 0, 1) fillLeft:SetPoint("TOPLEFT") fillLeft:SetPoint("BOTTOMLEFT") - fillLeft:SetWidth(SEARCH_CAP_W) local fillRight = CreateTexPart(frame, "BACKGROUND", SEARCH_TEX_FILL, TC_RIGHT, 0, 0, 0, 1) fillRight:SetPoint("TOPRIGHT") fillRight:SetPoint("BOTTOMRIGHT") - fillRight:SetWidth(SEARCH_CAP_W) local fillMid = CreateTexPart(frame, "BACKGROUND", SEARCH_TEX_FILL, TC_MID, 0, 0, 0, 1) fillMid:SetPoint("TOPLEFT", fillLeft, "TOPRIGHT") @@ -172,14 +184,18 @@ function ns.CreateSearchBorder(frame) fillLeft = fillLeft, fillMid = fillMid, fillRight = fillRight, borderLeft = borderLeft, borderMid = borderMid, borderRight = borderRight, } + ApplyCapWidths(frame) + -- A theme/font/zoom change calls SetHeight; OnSizeChanged keeps + -- the cap width in lockstep without each caller having to know. + frame:HookScript("OnSizeChanged", ApplyCapWidths) end -function ns.ScaleSearchBorder(frame, scale) - if not frame.searchBorder then return end - local sb = frame.searchBorder - local capW = SEARCH_CAP_W * scale - sb.fillLeft:SetWidth(capW) - sb.fillRight:SetWidth(capW) +-- Kept as a public no-op for callers that still pass the legacy +-- scale parameter. Cap width tracks frame height now via the +-- OnSizeChanged hook installed in CreateSearchBorder, so we just +-- re-apply in case the caller resized the frame in the same tick. +function ns.ScaleSearchBorder(frame, _scale) + ApplyCapWidths(frame) end function ns.SetSearchBorderShown(frame, shown) diff --git a/textures/SearchBarBorder.tga b/textures/SearchBarBorder.tga index c09fa0fc17cab29d58ef9eafd12c11fec34ae487..a205bb980eb2cf06eeea523ff0b4fea3a695255d 100644 GIT binary patch literal 131090 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 diff --git a/textures/SearchBarFill.tga b/textures/SearchBarFill.tga index 121e3d3efe653cba4d338cc19426baf9934094bf..98ec78019ff86454854f72519275b809d7fb4acd 100644 GIT binary patch literal 131090 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 From ae991ebd223d3de8cd7dd1288da9176b8ab490e3 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 3 May 2026 00:00:50 -0400 Subject: [PATCH 035/103] Add macros/abilities/bags, aliases, settings search, autocomplete polish - Search injects macros, abilities (spellbook), and bag items - Macro click opens MacroFrame at the slot via DataProvider+ScrollBox - Ability click casts via SecureActionButton (LeftButtonDown registry) - Pet click refreshes stale GUID from speciesID before summon - User-defined aliases module + Options panel with prune list - Reputation rows show faction-side crests (alliance/horde/either) - Filter dropdown adds Bags + Game Options buckets, Gear rename - Inline autocomplete: smooth-typing fast path, no re-paint when the next char matches the highlighted suffix, backspace tears down - Search query reads typed prefix only; autocompleted suffix never feeds the search engine - Pin/alias popup dismisses on outside click via blocker overlay - Settings panel search via BlizzOptionsSearch - Combined search bar + results container with rounded-rect chrome - Delve icon coords restored; raid/dungeon use atlas pins - Keybind capture rejects bare SPACE/ENTER/WASD to protect defaults - Tab confirms autocomplete only; toolbar nav via arrow keys --- .luacheckrc | 9 + Aliases.lua | 110 ++ BlizzOptionsSearch.lua | 158 +++ Core.lua | 70 +- Database.lua | 221 ++++ EasyFind.toc | 2 + MapSearch.lua | 15 +- MapTab.lua | 43 +- Options.lua | 94 +- UI.lua | 2115 ++++++++++++++++++++++++++----- Utils.lua | 296 ++++- textures/CombinedBorder.tga | Bin 0 -> 262162 bytes textures/CombinedFill.tga | Bin 0 -> 262162 bytes textures/FilterButtonCircle.tga | Bin 0 -> 65554 bytes textures/SearchBarBorder.tga | Bin 131090 -> 131090 bytes textures/SearchBarFill.tga | Bin 131090 -> 131090 bytes 16 files changed, 2783 insertions(+), 350 deletions(-) create mode 100644 Aliases.lua create mode 100644 BlizzOptionsSearch.lua create mode 100644 textures/CombinedBorder.tga create mode 100644 textures/CombinedFill.tga create mode 100644 textures/FilterButtonCircle.tga diff --git a/.luacheckrc b/.luacheckrc index 48c47ba..ae0dc49 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -84,6 +84,15 @@ read_globals = { "EJ_SetDifficulty", "EJ_SetLootFilter", "EJ_SetSlotFilter", "EJ_GetNumLoot", "EJ_GetLootInfoByIndex", "HasAction", "PlaceAction", "PickupAction", "ClearCursor", "GetActionCooldown", + "PickupSpell", "PickupItem", "PickupMacro", "C_Spell", + "ACCEPT", "CANCEL", "StaticPopup_Show", + "C_SpellBook", "C_Container", "GetNumSpellTabs", "GetSpellTabInfo", + "GetSpellBookItemInfo", "GetSpellBookItemName", "GetSpellBookItemTexture", + "GetContainerNumSlots", "GetContainerItemInfo", "PickupContainerItem", + "NUM_BAG_SLOTS", "GetNumMacros", "GetMacroInfo", "MAX_ACCOUNT_MACROS", + "ShowMacroFrame", "MacroFrame", "MacroFrame_SelectMacro", "MacroFrame_Update", + "MacroFrame_OnTabChanged", "PanelTemplates_SetTab", "C_AddOns", "LoadAddOn", + "CreateMacro", "SetOverrideBindingClick", "ClearOverrideBindings", -- Data types diff --git a/Aliases.lua b/Aliases.lua new file mode 100644 index 0000000..d4022b8 --- /dev/null +++ b/Aliases.lua @@ -0,0 +1,110 @@ +-- User-defined search aliases. Each alias maps a short user-typed +-- string to a stable identifier that points at one Database entry. +-- Aliases survive reloads/relogs (stored in EasyFindDB.aliases) and +-- inject the matching entry into the search results when the user +-- types text that prefix-matches the alias. + +local _, ns = ... +local Aliases = {} +ns.Aliases = Aliases + +local Utils = ns.Utils +local sfind, slower, strtrim = Utils.sfind, Utils.slower, strtrim +local tinsert = Utils.tinsert + +-- Build a stable, type-prefixed key for a Database entry. Used both +-- to record an alias target and to find the matching entry later. +-- Returns nil when the entry doesn't expose a stable identifier +-- (e.g. transient pin headers). +function Aliases:GetEntryKey(data) + if not data then return nil end + if data.mountID then return "mount:" .. data.mountID end + if data.toyItemID then return "toy:" .. data.toyItemID end + if data.petID then return "pet:" .. data.petID end + if data.outfitID then return "outfit:" .. data.outfitID end + if data.transmogSetID then return "appearanceSet:" .. data.transmogSetID end + if data.macroIndex then return "macro:" .. data.macroIndex end + if data.factionID then return "reputation:" .. data.factionID end + if data.itemID and data.category == "Loot" then return "loot:" .. data.itemID end + if data.category == "Currency" and data.steps then + for i = 1, #data.steps do + local cid = data.steps[i].currencyID + if cid then return "currency:" .. cid end + end + end + -- UI Elements: identify by full path + name. Path may be empty + -- for top-level entries, so fall back to the bare name. + if data.path and #data.path > 0 then + return "ui:" .. table.concat(data.path, ">") .. ">" .. (data.name or "") + end + if data.name then return "ui:" .. data.name end + return nil +end + +-- Find the live Database entry that matches a stored alias key. The +-- live entry is what the renderer needs (icons, tooltip targets, +-- secure attributes) so the alias hit looks identical to a normal +-- search hit. Returns nil if the entry has been removed (e.g. mount +-- relearned with a different ID). +function Aliases:FindEntryByKey(key) + if not key or not ns.Database or not ns.Database.uiSearchData then return nil end + local data = ns.Database.uiSearchData + for i = 1, #data do + local entry = data[i] + if Aliases:GetEntryKey(entry) == key then return entry end + end + return nil +end + +-- Persist an alias. The text is trimmed and lowercased so search +-- matching can stay case-insensitive without per-keystroke lower() +-- calls. Replaces any prior alias with the same text. +function Aliases:Add(aliasText, data) + if not EasyFind or not EasyFind.db then return false end + aliasText = strtrim(aliasText or "") + if aliasText == "" then return false end + local key = Aliases:GetEntryKey(data) + if not key then return false end + if type(EasyFind.db.aliases) ~= "table" then + EasyFind.db.aliases = {} + end + EasyFind.db.aliases[slower(aliasText)] = { + text = aliasText, + key = key, + name = data.name or aliasText, + } + return true +end + +-- Remove a previously stored alias by its (case-insensitive) text. +function Aliases:Remove(aliasText) + if not EasyFind or not EasyFind.db or not EasyFind.db.aliases then return end + EasyFind.db.aliases[slower(strtrim(aliasText or ""))] = nil +end + +-- Walk all stored aliases. cb(aliasText, info) for each entry. +function Aliases:ForEach(cb) + if not EasyFind or not EasyFind.db or not EasyFind.db.aliases then return end + for _, info in pairs(EasyFind.db.aliases) do + cb(info.text, info) + end +end + +-- Look up entries whose alias prefix-matches the lowercase query. +-- Returns a list of `{ data = liveEntry, alias = info }` so callers +-- can highlight the alias text in the result row if they want. +function Aliases:GetMatches(queryLower) + if not queryLower or queryLower == "" then return nil end + if not EasyFind or not EasyFind.db or not EasyFind.db.aliases then return nil end + local out + for storedKey, info in pairs(EasyFind.db.aliases) do + if sfind(storedKey, queryLower, 1, true) then + local entry = Aliases:FindEntryByKey(info.key) + if entry then + out = out or {} + tinsert(out, { data = entry, alias = info }) + end + end + end + return out +end diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua new file mode 100644 index 0000000..ff83790 --- /dev/null +++ b/BlizzOptionsSearch.lua @@ -0,0 +1,158 @@ +-- Blizzard Settings panel search. Walks Settings.GetCategoryList() +-- after the system frames have loaded and registers each category / +-- sub-category as a searchable Database entry. Selecting a hit opens +-- the Settings panel to that category via Settings.OpenToCategory. +-- +-- Retail (10.0.2+) replaced the old InterfaceOptions tree with a flat +-- registry under Settings.*, so this file targets that API only. +-- Classic builds will simply skip registration when Settings is nil. + +local _, ns = ... +local BlizzOptionsSearch = {} +ns.BlizzOptionsSearch = BlizzOptionsSearch + +local Utils = ns.Utils +local tinsert = Utils.tinsert +local slower = Utils.slower +local SafeAfter = Utils.SafeAfter + +-- Stable category-name -> id table built when the search runs. Used +-- by step handlers to translate the entry's stashed name back into +-- the live category ID (which can change between sessions). +local function GetCategoryID(name) + if not Settings or not Settings.GetCategoryList then return nil end + local list = Settings.GetCategoryList() + if type(list) ~= "table" then return nil end + for _, cat in ipairs(list) do + if cat and cat.GetName and cat:GetName() == name then + if cat.GetID then return cat:GetID() end + end + end + return nil +end + +-- Open the Settings panel to a given category by name. +local function OpenSettingsByName(name) + local id = GetCategoryID(name) + if not id then return false end + if Settings and Settings.OpenToCategory then + Settings.OpenToCategory(id) + return true + end + return false +end +BlizzOptionsSearch.OpenSettingsByName = OpenSettingsByName + +-- Collect name/path entries from the live Settings registry. Each +-- entry stores the category and sub-category names so we can resolve +-- the live category ID at click time. +local function CollectEntries() + local entries = {} + if not Settings or not Settings.GetCategoryList then return entries end + local list = Settings.GetCategoryList() + if type(list) ~= "table" then return entries end + + for _, cat in ipairs(list) do + if cat and cat.GetName then + local catName = cat:GetName() + if catName and catName ~= "" then + local catNameLower = slower(catName) + local catKw = { "settings", "options", catNameLower } + tinsert(entries, { + name = catName, + nameLower = catNameLower, + keywords = catKw, + keywordsLower = catKw, + category = "Game Settings", + icon = 134399, -- Interface\\Icons\\Trade_Engineering (gear-ish) + settingsCategory = catName, + -- A no-op steps[] keeps the entry in the + -- "guideable" code path; the real work happens + -- in the click handler that calls OpenSettingsByName. + steps = { { settingsCategory = catName } }, + }) + + -- Sub-categories show as their own entries with the + -- parent name in the path so search results read like + -- "Combat > Self Highlight" instead of just "Self + -- Highlight" with no context. + if cat.GetSubcategories then + local subs = cat:GetSubcategories() + if type(subs) == "table" then + for _, sub in ipairs(subs) do + if sub and sub.GetName then + local subName = sub:GetName() + if subName and subName ~= "" then + local subNameLower = slower(subName) + local subKw = { "settings", "options", subNameLower, catNameLower } + tinsert(entries, { + name = subName, + nameLower = subNameLower, + keywords = subKw, + keywordsLower = subKw, + category = "Game Settings", + path = { "Game Settings", catName }, + icon = 134399, + settingsCategory = subName, + steps = { { settingsCategory = subName } }, + }) + end + end + end + end + end + end + end + end + return entries +end + +-- Register the collected entries into the Database. Called once +-- after PLAYER_LOGIN so Settings.* is fully populated. +function BlizzOptionsSearch:Populate() + if not ns.Database or not ns.Database.uiSearchData then return end + local entries = CollectEntries() + local data = ns.Database.uiSearchData + for i = 1, #entries do + tinsert(data, entries[i]) + end +end + +-- Step handler: open the Settings panel to a category by name. The +-- Highlight engine looks for a `settingsCategory` field on a step and +-- routes here when found. +function BlizzOptionsSearch:HandleStep(step) + if not step or not step.settingsCategory then return false end + return OpenSettingsByName(step.settingsCategory) +end + +-- Schedule registration after PLAYER_LOGIN so Settings.GetCategoryList +-- has the full tree (some addons register late). Two passes catch +-- any stragglers that register on first frame. +local registered = false +local function Register() + if registered then return end + registered = true + BlizzOptionsSearch:Populate() +end + +local f = CreateFrame("Frame") +f:RegisterEvent("PLAYER_LOGIN") +f:SetScript("OnEvent", function() + SafeAfter(0.5, Register) + SafeAfter(3.0, function() + -- Re-collect after a longer delay to pick up addons that + -- register their settings categories during the first few + -- seconds. Uses a name-based dedupe so we don't double up. + local seen = {} + for _, e in ipairs(ns.Database.uiSearchData or {}) do + if e.settingsCategory then seen[e.settingsCategory] = true end + end + local fresh = CollectEntries() + for _, e in ipairs(fresh) do + if not seen[e.settingsCategory] then + tinsert(ns.Database.uiSearchData, e) + end + end + end) +end) diff --git a/Core.lua b/Core.lua index 189670a..0bd5ff5 100644 --- a/Core.lua +++ b/Core.lua @@ -23,7 +23,7 @@ EasyFind.db = {} -- SavedVariables version. Increment when changing DB schema. -- Each migration runs once: if saved dbVersion < DB_VERSION, run all steps in order. -local DB_VERSION = 4 +local DB_VERSION = 6 -- SavedVariables defaults - new keys are auto-merged for existing users local DB_DEFAULTS = { @@ -36,7 +36,7 @@ local DB_DEFAULTS = { uiSearchScale = 1.0, mapSearchScale = 1.0, mapSearchWidth = 0.88, - uiSearchWidth = 0.88, + uiSearchWidth = 1.54, -- 0.88 * 1.75: results dropdown matches bar width now uiResultsScale = 1.0, uiResultsWidth = 350, mapResultsScale = 1.0, @@ -110,13 +110,19 @@ local DB_DEFAULTS = { mapTabAutoExpand = true, -- Auto-expand a matched parent header to show all its world-hierarchy children alwaysShowRares = false, -- Persistent rare tracking: show active rares on map without searching uiSearchFilters = { -- UI search category filters - ui = true, + ui = true, -- UI elements (excludes achievement/currency/reputation entries) + achievements = true, -- Individual achievement category entries + currencies = true, -- Individual currency entries + reputations = true, -- Individual reputation entries + collections = true, -- Parent toggle for Mounts/Toys/Pets/Outfits/Appearance Sets mounts = false, toys = false, pets = false, outfits = false, loot = false, appearanceSets = false, + bags = false, + options = true, map = false, }, lootSpecs = nil, -- Loot search: nil = current spec only, table of {classID, specID} pairs when customized @@ -130,6 +136,10 @@ local DB_DEFAULTS = { appearanceSetPvE = true, -- Show PvE sets (Dungeon/Raid) appearanceSetPvP = true, -- Show PvP sets uiMapSearchLocal = true, -- Map search in UI bar: true = local zone only, false = global + uiHideHeaders = false, -- Flat results list: no category headers, path shown as subtext per row + aliases = {}, -- User-defined search aliases: { [aliasText] = { kind, id, name } } + uiSearchHistory = {}, -- Shell-style search history (most recent at index 1, capped at uiSearchHistoryLimit) + uiSearchHistoryLimit = 500, -- Bash HISTSIZE default } local DB_MIGRATIONS = { @@ -157,13 +167,25 @@ local DB_MIGRATIONS = { db.uiMaxResults = nil db.mapMaxResults = nil end, - -- [4] = Flip stale localMapDirectOpen / globalMapDirectOpen defaults. + -- [4] = Combined search bar + results dropdown silhouette. The + -- results panel now matches the bar's width directly, so the + -- old 0.88 default would render the bar (and therefore the + -- dropdown) too narrow. Bump uiSearchWidth ~1.75x for everyone + -- whose width is at or below the old default; users who have + -- explicitly widened it past the old default keep their value. + [4] = function(db) + local w = db.uiSearchWidth + if w == nil or w <= 0.88 then + db.uiSearchWidth = 1.54 + end + end, + -- [5] = Flip stale localMapDirectOpen / globalMapDirectOpen defaults. -- The "Make Fast Mode default" commit changed both defaults from false -- to true but didn't migrate existing saves. Users carried over -- false → SelectResult bucketed every zone click into the multi-click -- teach path (HighlightZoneOnMap), making clicks feel like they -- needed two presses to register. - [4] = function(db) + [5] = function(db) if db.localMapDirectOpen == false then db.localMapDirectOpen = true end if db.globalMapDirectOpen == false then db.globalMapDirectOpen = true end end, @@ -417,6 +439,15 @@ local function OnPlayerLogin() ns.Database:PopulateDynamicPets() SafeAfter(0, function() ns.Database:PopulateDynamicOutfits() + if ns.Database.PopulateDynamicMacros then + ns.Database:PopulateDynamicMacros() + end + if ns.Database.PopulateDynamicAbilities then + ns.Database:PopulateDynamicAbilities() + end + if ns.Database.PopulateDynamicBags then + ns.Database:PopulateDynamicBags() + end SafeAfter(0, function() ns.Database:SyncTransmogSetFiltersFromUI() ns.Database:PopulateDynamicTransmogSets() @@ -493,6 +524,11 @@ eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD") eventFrame:RegisterEvent("PLAYER_LOGOUT") eventFrame:RegisterEvent("TRANSMOG_OUTFITS_CHANGED") eventFrame:RegisterEvent("TRANSMOG_COLLECTION_UPDATED") +eventFrame:RegisterEvent("UPDATE_MACROS") +eventFrame:RegisterEvent("SPELLS_CHANGED") +eventFrame:RegisterEvent("BAG_UPDATE_DELAYED") +local bagRefreshTimer +local spellRefreshTimer eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2) if event == "ADDON_LOADED" and arg1 == ADDON_NAME then OnInitialize() @@ -530,6 +566,30 @@ eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2) ns.Database:PopulateDynamicTransmogSets() end end + elseif event == "UPDATE_MACROS" then + -- Fires when the player creates, edits, or deletes a macro. Re-scan + -- so the search index reflects the change without /reload. + if ns.Database and ns.Database.PopulateDynamicMacros then + ns.Database:PopulateDynamicMacros() + end + elseif event == "SPELLS_CHANGED" then + -- Throttle: many SPELLS_CHANGED events can fire in rapid + -- succession on login or when learning a row of talents. + if spellRefreshTimer then spellRefreshTimer:Cancel() end + spellRefreshTimer = C_Timer.NewTimer(1.0, function() + spellRefreshTimer = nil + if ns.Database and ns.Database.PopulateDynamicAbilities then + ns.Database:PopulateDynamicAbilities() + end + end) + elseif event == "BAG_UPDATE_DELAYED" then + if bagRefreshTimer then bagRefreshTimer:Cancel() end + bagRefreshTimer = C_Timer.NewTimer(0.5, function() + bagRefreshTimer = nil + if ns.Database and ns.Database.PopulateDynamicBags then + ns.Database:PopulateDynamicBags() + 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 57cd6b6..31bcd47 100644 --- a/Database.lua +++ b/Database.lua @@ -303,6 +303,12 @@ function Database:PopulateDynamicReputations() return path end + -- Localized "Alliance" / "Horde" header names from globals when available; + -- fall back to lowercase string comparison for non-English clients that + -- don't expose them under these IDs. + local ALLIANCE_HEADER = (FACTION_ALLIANCE or "Alliance"):lower() + local HORDE_HEADER = (FACTION_HORDE or "Horde"):lower() + local function injectFaction(factionData) local isDiscovered = (factionData.currentStanding and factionData.currentStanding > 0) or (factionData.isWatched == true) @@ -322,6 +328,19 @@ function Database:PopulateDynamicReputations() end keywords[#keywords + 1] = factionNameLower + -- Faction side: inferred from the parent group header. Factions + -- under an Alliance/Horde sub-header are faction-locked; the rest + -- are either-faction reputations. + local factionSide + if currentFactionGroup then + local groupLower = slower(currentFactionGroup) + if groupLower == ALLIANCE_HEADER then + factionSide = "alliance" + elseif groupLower == HORDE_HEADER then + factionSide = "horde" + end + end + local entry = { name = factionData.name, keywords = keywords, @@ -330,6 +349,7 @@ function Database:PopulateDynamicReputations() path = path, steps = steps, factionID = factionData.factionID, + factionSide = factionSide, hasRepBar = not factionData.isHeader or factionData.isHeaderWithRep, } @@ -1325,6 +1345,207 @@ function Database:PopulateDynamicLoot(scanAllSpecs) collectgarbage("collect") end +-- Macro search: scans the player's account-wide and per-character macros +-- and injects them as searchable entries. Direct click runs the macro; +-- guide mode opens MacroFrame, switches to the matching tab, and selects +-- the macro. Re-callable: clears prior Macro entries before re-injecting, +-- so calls from UPDATE_MACROS reflect renames/edits/deletes. +function Database:PopulateDynamicMacros() + if not GetNumMacros or not GetMacroInfo then return end + + for i = #uiSearchData, 1, -1 do + if uiSearchData[i].category == "Macro" then + tremove(uiSearchData, i) + end + end + if self.ResetSearchCache then self:ResetSearchCache() end + + local numGlobal, numPerChar = GetNumMacros() + local MAX_ACCOUNT = MAX_ACCOUNT_MACROS or 120 + + local function injectMacro(macroIdx, isCharSpecific) + local name, iconTexture, body = GetMacroInfo(macroIdx) + if not name or name == "" then return end + local nameLower = slower(name) + local kw = { "macro", nameLower } + -- Index macro body words (slash command names, target names, etc.) + -- so /castsequence Hearthstone is reachable by typing "hearthstone". + if body and body ~= "" then + local cleanBody = body:gsub("#show[^\n]*", ""):gsub("/", " ") + for word in cleanBody:gmatch("[%w']+") do + local wl = slower(word) + if #wl >= 3 then kw[#kw + 1] = wl end + end + end + local tabIdx = isCharSpecific and 2 or 1 + local entry = { + name = name, + nameLower = nameLower, + keywords = kw, + keywordsLower = kw, + category = "Macro", + icon = iconTexture, + macroIndex = macroIdx, + macroBody = body, + macroIsChar = isCharSpecific, + buttonFrame = "MainMenuMicroButton", + path = { "Macros", isCharSpecific and "Character" or "General" }, + steps = { + { buttonFrame = "MainMenuMicroButton" }, + { gameMenuText = "Macros" }, + { waitForFrame = "MacroFrame", tabIndex = tabIdx }, + { waitForFrame = "MacroFrame", macroIndex = macroIdx }, + }, + } + uiSearchData[#uiSearchData + 1] = entry + end + + for i = 1, numGlobal do + injectMacro(i, false) + end + for i = 1, numPerChar do + injectMacro(MAX_ACCOUNT + i, true) + end +end + +-- Inject one entry per learned spell into uiSearchData. Retail Midnight +-- replaced GetSpellBookItemInfo's (slot, slotType) string-typed args +-- with (slotIndex, Enum.SpellBookSpellBank) and returns a single +-- SpellBookItemInfo table containing name, subName, actionID, iconID. +-- We use that path exclusively; pre-Midnight clients (Classic) skip +-- registration since this addon targets Midnight 12.0+. +function Database:PopulateDynamicAbilities() + -- Strip prior pass so /reload-equivalent rebuilds don't double up. + for i = #uiSearchData, 1, -1 do + if uiSearchData[i].category == "Ability" then + tremove(uiSearchData, i) + end + end + if self.ResetSearchCache then self:ResetSearchCache() end + + local SBOOK = C_SpellBook + if not SBOOK or not SBOOK.GetSpellBookItemInfo + or not SBOOK.GetNumSpellBookSkillLines or not SBOOK.GetSpellBookSkillLineInfo then + return + end + local BANK = (Enum and Enum.SpellBookSpellBank and Enum.SpellBookSpellBank.Player) or 0 + local SPELL_TYPE = Enum and Enum.SpellBookItemType and Enum.SpellBookItemType.Spell + + local seen = {} -- spellID -> true (dedupe across skill lines) + + local numLines = SBOOK.GetNumSpellBookSkillLines() or 0 + for tab = 1, numLines do + local lineInfo = SBOOK.GetSpellBookSkillLineInfo(tab) + local offset = lineInfo and lineInfo.itemIndexOffset or 0 + local numSpells = lineInfo and lineInfo.numSpellBookItems or 0 + for s = offset + 1, offset + numSpells do + local itemInfo = SBOOK.GetSpellBookItemInfo(s, BANK) + -- Skip flyout placeholders, passives we don't want, etc. + if itemInfo + and (not SPELL_TYPE or itemInfo.itemType == SPELL_TYPE) + and itemInfo.name and itemInfo.name ~= "" + and itemInfo.actionID and not seen[itemInfo.actionID] then + seen[itemInfo.actionID] = true + local name = itemInfo.name + local subName = itemInfo.subName + local displayName = (subName and subName ~= "") and (name .. " (" .. subName .. ")") or name + local nameLower = slower(displayName) + local kw = { "ability", "spell", "cast", slower(name) } + uiSearchData[#uiSearchData + 1] = { + name = displayName, + nameLower = nameLower, + keywords = kw, + keywordsLower = kw, + category = "Ability", + icon = itemInfo.iconID, + spellID = itemInfo.actionID, + spellName = name, + -- No multi-step guide: SelectResult treats spellID + -- as a direct cast target; drag-to-pickup uses it + -- to put the spell on the cursor. + steps = { { spellID = itemInfo.actionID } }, + } + end + end + end +end + +-- Inject one entry per unique item carried in the player's bags. The +-- entry stores the first occupied location so guide mode can highlight +-- the right slot; drag-to-pickup uses the item ID to put the item on +-- the cursor (matching the in-game bag drag behavior). +function Database:PopulateDynamicBags() + local CONT = C_Container + local getNumSlots = (CONT and CONT.GetContainerNumSlots) or GetContainerNumSlots + local getItemInfo = (CONT and CONT.GetContainerItemInfo) or GetContainerItemInfo + if not getNumSlots or not getItemInfo then return end + + for i = #uiSearchData, 1, -1 do + if uiSearchData[i].category == "Bag" then + tremove(uiSearchData, i) + end + end + if self.ResetSearchCache then self:ResetSearchCache() end + + local itemMap = {} + local order = {} + for bag = 0, NUM_BAG_SLOTS or 4 do + local slots = getNumSlots(bag) or 0 + for slot = 1, slots do + local raw = getItemInfo(bag, slot) + if raw then + local itemID, texture, link, count + if type(raw) == "table" then + itemID, texture, link, count = raw.itemID, raw.iconFileID, raw.hyperlink, raw.stackCount + else + -- Legacy multi-return (Classic-era layout) + texture = raw + local _ + _, count, _, _, _, _, link, _, _, itemID = getItemInfo(bag, slot) + end + if itemID then + local entry = itemMap[itemID] + if not entry then + entry = { texture = texture, link = link, totalCount = 0, locations = {} } + itemMap[itemID] = entry + order[#order + 1] = itemID + end + entry.totalCount = entry.totalCount + (count or 1) + entry.locations[#entry.locations + 1] = { bag = bag, slot = slot } + end + end + end + end + + for _, itemID in ipairs(order) do + local info = itemMap[itemID] + local name = info.link and info.link:match("%[(.-)%]") or (GetItemInfo and GetItemInfo(itemID)) or ("Item " .. itemID) + local first = info.locations[1] + local bagBtn = first.bag == 0 and "MainMenuBarBackpackButton" + or ("CharacterBag" .. (first.bag - 1) .. "Slot") + local nameLower = slower(name) + local kw = { "bag", "item", "inventory", nameLower } + uiSearchData[#uiSearchData + 1] = { + name = name, + nameLower = nameLower, + keywords = kw, + keywordsLower = kw, + category = "Bag", + icon = info.texture, + itemID = itemID, + bagID = first.bag, + bagSlot = first.slot, + bagItemLink = info.link, + bagCount = info.totalCount, + bagLocations = info.locations, + buttonFrame = bagBtn, + steps = { + { buttonFrame = bagBtn }, + }, + } + end +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. diff --git a/EasyFind.toc b/EasyFind.toc index defb3e5..eff5b54 100644 --- a/EasyFind.toc +++ b/EasyFind.toc @@ -15,10 +15,12 @@ Database.lua StaticLocations.lua TutorialBox.lua ChevronConfig.lua +Aliases.lua UI.lua Highlight.lua MapSearch.lua MapTab.lua Rescaler.lua +BlizzOptionsSearch.lua Options.lua Demo.lua diff --git a/MapSearch.lua b/MapSearch.lua index da84c1f..c7ca0fe 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -1144,13 +1144,14 @@ local CATEGORY_ICONS = { boat = 1126431, portal = "Interface\\Icons\\Spell_Arcane_PortalDalaran", tram = "Interface\\Icons\\INV_Misc_Gear_01", - -- Cropped texCoords from atlas sprite sheet 1121272 - removes the built-in glow border - -- Full atlas coords: Dungeon L=0.1982 R=0.2471 T=0.4404 B=0.4893 - -- Raid L=0.1982 R=0.2471 T=0.4912 B=0.5400 - -- delves L=0.0010 R=0.0635 T=0.3896 B=0.4521 - dungeon = { file = 1121272, coords = { 0.2056, 0.2397, 0.4478, 0.4819 } }, - raid = { file = 1121272, coords = { 0.2056, 0.2397, 0.4986, 0.5327 } }, - delve = { file = 1121272, coords = { 0.0104, 0.0541, 0.3990, 0.4427 } }, + -- Use the clean map-pin atlases instead of the journal sprite sheet. + -- The 1121272 sheet's icons are wrapped in a wide soft glow that + -- gets clipped to a square inside the row's icon slot; the pin + -- atlases are tight, no-bleed shapes that match WoW's own dungeon / + -- raid / delve world-map pins. + dungeon = "atlas:Dungeon", + raid = "atlas:Raid", + delve = { file = 1121272, coords = { 0.0000, 0.0620, 0.3903, 0.4509 } }, bank = 136453, guildbank = 136453, personalbank = 136453, diff --git a/MapTab.lua b/MapTab.lua index f9e6a3c..93edd70 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -352,9 +352,17 @@ local function HideOurPanel() if panel.outer then panel.outer:Hide() else panel:Hide() end if panel.searchBox then panel.searchBox:ClearFocus() end end - if ns.MapSearch and ns.MapSearch.ClearHighlight and ns.MapSearch._previewing then - ns.MapSearch._previewing = nil - ns.MapSearch:ClearHighlight() + -- Tear down any in-flight hover preview when the panel closes so the + -- zone outline / pin / saved-state don't survive into another tab. + -- Mirrors EndHoverPreview's full cleanup rather than just the + -- _previewing flag flip + pin clear it used to do. + if ns.MapSearch and ns.MapSearch._previewing then + if ns.MapSearch.EndHoverPreview then + ns.MapSearch:EndHoverPreview() + else + ns.MapSearch._previewing = nil + if ns.MapSearch.ClearHighlight then ns.MapSearch:ClearHighlight() end + end end RefreshSelectGlows() end @@ -826,7 +834,13 @@ local function CreateGroupHeader(parent) end) toggleBtn:SetScript("OnLeave", function(self) self.btnBg:Hide() - if not hdr:IsMouseOver() then hdr.hoverOverlay:Hide() end + if not hdr:IsMouseOver() then + hdr.hoverOverlay:Hide() + -- Mirror the header body's OnLeave: when neither the toggle + -- nor the header itself is hovered, the user has fully left + -- the group row and the hover preview should clear. + ClearHoverPreview() + end end) hdr.toggleBtn = toggleBtn @@ -2014,6 +2028,27 @@ local function CreateSearchBox(parent) editBox.Instructions:SetText("Search for POIs, zones, instances...") end + -- Reject auto-focus on creation. WoW will silently focus visible + -- EditBoxes after creation despite SetAutoFocus(false). When the + -- WorldMap is open during /reload this editbox auto-focuses, and + -- its OnKeyDown handler sets SetPropagateKeyboardInput(false) for + -- every key -- which silently eats SPACE/WASD even though the bar + -- looks unfocused. Reject any focus that arrives within the first + -- couple frames after creation. + editBox._blockAutoFocus = true + editBox:HookScript("OnEditFocusGained", function(self) + if self._blockAutoFocus then self:ClearFocus() end + end) + -- Allow legitimate user clicks to focus by clearing the block on + -- OnMouseDown (which fires before focus is gained). + editBox:HookScript("OnMouseDown", function(self) self._blockAutoFocus = nil end) + editBox:ClearFocus() + C_Timer.After(0, function() + C_Timer.After(0, function() + if editBox then editBox._blockAutoFocus = nil; editBox:ClearFocus() end + end) + end) + local function UpdateClear(self) if self.clearButton then self.clearButton:SetShown(self:HasFocus() or self:GetText() ~= "") diff --git a/Options.lua b/Options.lua index f5c1d91..c277a64 100644 --- a/Options.lua +++ b/Options.lua @@ -720,6 +720,16 @@ function Options:Initialize() StopCapture(self, action) return end + -- Reject bare SPACE/ENTER/movement keys -- they're vital + -- defaults (jump, accept, WASD) and silently overwriting + -- them on a stray keypress during capture has bricked + -- spacebar after a /reload more than once. Only bind + -- these when modified. + local hasMod = IsAltKeyDown() or IsControlKeyDown() or IsShiftKeyDown() + if not hasMod and (key == "SPACE" or key == "ENTER" + or key == "W" or key == "A" or key == "S" or key == "D") then + return + end local combo = "" if IsAltKeyDown() then combo = combo .. "ALT-" end if IsControlKeyDown() then combo = combo .. "CTRL-" end @@ -1042,9 +1052,22 @@ function Options:Initialize() end) optionsFrame.uiResultsAboveCheckbox = uiResultsAboveCheckbox + local hideHeadersCheckbox = CreateCheckbox(sec1, "HideHeaders", "Flat Results (no headers)", + "Show results as a single flat list with no category headers. Each row displays the entry name with its full path as subtext underneath.") + hideHeadersCheckbox:SetPoint("TOPLEFT", uiResultsAboveCheckbox, "BOTTOMLEFT", 0, -2) + hideHeadersCheckbox:SetChecked(EasyFind.db.uiHideHeaders or false) + hideHeadersCheckbox:SetScript("OnClick", function(self) + EasyFind.db.uiHideHeaders = self:GetChecked() + -- Re-run the search so results rebuild in the chosen layout mode. + if ns.UI and ns.UI.RebuildOpenResults then + ns.UI:RebuildOpenResults() + end + end) + optionsFrame.hideHeadersCheckbox = hideHeadersCheckbox + local uiFontSlider = CreateSlider(sec1, "UIFontSize", "Font Size|cffff3333*|r", 0.5, 2.0, 0.1, "Changing font size also affects search bar height and results window sizing.", nil, 1.0) - uiFontSlider:SetPoint("TOPLEFT", uiResultsAboveCheckbox, "BOTTOMLEFT", 4, -20) + uiFontSlider:SetPoint("TOPLEFT", hideHeadersCheckbox, "BOTTOMLEFT", 4, -20) uiFontSlider:SetValue(EasyFind.db.fontSize or 1.0) uiFontSlider:HookScript("OnValueChanged", function(self, value) EasyFind.db.fontSize = value @@ -1070,7 +1093,7 @@ function Options:Initialize() StaticPopup_Show("EASYFIND_RESET_UI_POS") end) - uiControls = { resizeUIBtn, resetUIBtn, resetUIPosBtn, uiFontSlider, smartShowCheckbox, staticOpacityCheckbox, uiResultsAboveCheckbox } + uiControls = { resizeUIBtn, resetUIBtn, resetUIPosBtn, uiFontSlider, smartShowCheckbox, staticOpacityCheckbox, uiResultsAboveCheckbox, hideHeadersCheckbox } UpdateUIToggleVisual() -- SECTION 3: Map Search @@ -1342,6 +1365,67 @@ function Options:Initialize() optionsFrame.mapFocusBtn = keybindButtons["EASYFIND_MAP_FOCUS"] optionsFrame.clearBtn = keybindButtons["EASYFIND_CLEAR"] + -- Aliases panel: scrollable list with one row per saved alias. + -- Each row shows "alias -> entry name" and a small X button to + -- remove that alias. Entries that no longer resolve (mount + -- relearned with a different ID, etc.) still show so the user can + -- prune them. + local aliasHeader = sec4:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + aliasHeader:SetPoint("TOPLEFT", shortcutText, "BOTTOMLEFT", 0, -12 - #keybindDefs * KEYBIND_ROW_H - 18) + aliasHeader:SetText("|cFFFFD100Saved Aliases:|r right-click any search result and choose Add Alias") + + local aliasScroll = CreateFrame("ScrollFrame", nil, sec4, "UIPanelScrollFrameTemplate") + aliasScroll:SetPoint("TOPLEFT", aliasHeader, "BOTTOMLEFT", 0, -6) + aliasScroll:SetSize(FRAME_W - 80, 110) + + local aliasContent = CreateFrame("Frame", nil, aliasScroll) + aliasContent:SetSize(FRAME_W - 100, 1) + aliasScroll:SetScrollChild(aliasContent) + + local aliasRowPool = {} + local function ReleaseAliasRows() + for i = 1, #aliasRowPool do aliasRowPool[i]:Hide() end + end + local function AcquireAliasRow(idx) + local row = aliasRowPool[idx] + if row then row:Show(); return row end + row = CreateFrame("Frame", nil, aliasContent) + row:SetSize(FRAME_W - 100, 18) + row.text = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + row.text:SetPoint("LEFT", row, "LEFT", 4, 0) + row.text:SetJustifyH("LEFT") + row.removeBtn = CreateFrame("Button", nil, row, "UIPanelCloseButton") + row.removeBtn:SetSize(20, 20) + row.removeBtn:SetPoint("RIGHT", row, "RIGHT", 0, 0) + aliasRowPool[idx] = row + return row + end + + local function RefreshAliasList() + ReleaseAliasRows() + if not ns.Aliases then return end + local entries = {} + ns.Aliases:ForEach(function(text, info) + entries[#entries + 1] = info + end) + table.sort(entries, function(a, b) return (a.text or ""):lower() < (b.text or ""):lower() end) + local y = -2 + for i, info in ipairs(entries) do + local row = AcquireAliasRow(i) + row:ClearAllPoints() + row:SetPoint("TOPLEFT", aliasContent, "TOPLEFT", 0, y) + row.text:SetText(("|cFFFFD100%s|r -> %s"):format(info.text or "?", info.name or "?")) + row.removeBtn:SetScript("OnClick", function() + if ns.Aliases then ns.Aliases:Remove(info.text) end + RefreshAliasList() + end) + y = y - 18 + end + aliasContent:SetHeight(math.max(1, -y + 4)) + end + sec4:HookScript("OnShow", RefreshAliasList) + optionsFrame.RefreshAliasList = RefreshAliasList + -- Reset buttons (tips on Home tab) StaticPopupDialogs["EASYFIND_RESET_ALL"] = { @@ -1600,6 +1684,7 @@ function Options:DoResetAll() EasyFind.db.showLoginMessage = true EasyFind.db.uiResultsAbove = false EasyFind.db.mapResultsAbove = false + EasyFind.db.uiHideHeaders = false EasyFind.db.showMinimapButton = true EasyFind.db.minimapButtonAngle = 200 EasyFind.db.arrivalDistance = 10 @@ -1619,7 +1704,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, rares = true } - EasyFind.db.uiSearchFilters = { ui = true, mounts = false, toys = false, pets = false, outfits = false, loot = false, appearanceSets = false, map = false } + EasyFind.db.uiSearchFilters = { ui = true, achievements = true, currencies = true, reputations = true, collections = true, mounts = false, toys = false, pets = false, outfits = false, loot = false, appearanceSets = false, map = false } EasyFind.db.lootSpecs = nil -- nil = current spec only EasyFind.db.lootSearchSlots = true EasyFind.db.lootSearchStats = true @@ -1678,6 +1763,7 @@ function Options:DoResetAll() optionsFrame.staticOpacityCheckbox:SetChecked(false) optionsFrame.loginMessageCheckbox:SetChecked(true) optionsFrame.uiResultsAboveCheckbox:SetChecked(false) + if optionsFrame.hideHeadersCheckbox then optionsFrame.hideHeadersCheckbox:SetChecked(false) end optionsFrame.minimapBtnCheckbox:SetChecked(true) if optionsFrame.rareTrackCheckbox then optionsFrame.rareTrackCheckbox:SetChecked(false) end if optionsFrame.mapPinGroup then optionsFrame.mapPinGroup:UpdateVisuals() end @@ -1739,7 +1825,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, loot = false, appearanceSets = false, map = false } + EasyFind.db.uiSearchFilters = { ui = true, achievements = true, currencies = true, reputations = true, collections = true, mounts = false, toys = false, pets = false, outfits = false, loot = false, appearanceSets = false, map = false } EasyFind.db.lootSpecs = nil EasyFind.db.lootSearchSlots = true EasyFind.db.lootSearchStats = true diff --git a/UI.lua b/UI.lua index b15b67e..bf0e99c 100644 --- a/UI.lua +++ b/UI.lua @@ -33,6 +33,12 @@ local REP_BAR_WIDTH = 100 local searchFrame local resultsFrame +-- Combined-frame backdrop: rounded-rect 9-slice that wraps the bar +-- alone (collapsed to a pill when results are hidden) or the bar +-- plus the results dropdown (rounded rectangle when open). Sibling +-- of searchFrame, anchored to it; grows downward to cover +-- resultsFrame when ShowHierarchicalResults runs. +local containerFrame local resultButtons = {} local MAX_BUTTON_POOL = 50 -- Maximum buttons (scroll handles overflow beyond this) local inCombat = false @@ -41,6 +47,14 @@ local deferredRepRefreshPending = false -- deferred re-render to let IsTruncate local outfitCdStart, outfitCdDuration = 0, 0 -- shared outfit swap cooldown local lastEquippedOutfitID -- tracks most recent equip for immediate green tint +-- Shell-style search history. historyIndex 0 == "live" buffer (whatever +-- the user has actually typed). Stepping UP increments toward older +-- entries; DOWN decrements back toward 0. Once we hit 0, the next DOWN +-- key falls through to the result-navigation path so the user can drop +-- into the highlighted result row without an extra keystroke. +local historyIndex = 0 +local historyDraft = "" -- User's in-flight text, restored when stepping back to index 0 + -- PIN HELPERS local function GetUIPinKey(data) @@ -94,6 +108,66 @@ local function IsCollectionPin(data) or (data.itemID and data.category == "Loot")) end +-- LEFT-side category icons for flat mode. Collection items (mounts, toys, +-- etc.) push their item-specific icon to the right side of the row, leaving +-- the left empty; we fill it with the same icon used in the filter dropdown +-- so each row carries an at-a-glance category cue. Numeric entries are +-- texture FileDataIDs; strings are texture paths or atlas names (atlas key). +local FLAT_CATEGORY_ICONS = { + mount = { tex = 132261 }, + toy = { tex = 454046 }, + pet = { tex = 631719 }, + outfit = { tex = 132649 }, + appearanceSet = { tex = "Interface\\Icons\\INV_Helmet_03" }, + currency = { tex = 136452 }, -- Same coin/AH glyph the map uses + reputation = { tex = 1121272, coords = { 0.3783, 0.4072, 0.9066, 0.9350 } }, +} + +-- Reputation icon by faction side. Either-faction (nil) uses the same +-- crest as the filter button; Alliance/Horde get their faction-specific +-- crests. All cropped from the shared 1121272 sprite sheet. +local REP_FACTION_ICONS = { + alliance = { tex = 1121272, coords = { 0.4740, 0.5055, 0.8371, 0.8706 } }, + horde = { tex = 1121272, coords = { 0.4743, 0.5058, 0.8707, 0.9042 } }, + either = { tex = 1121272, coords = { 0.3783, 0.4072, 0.9066, 0.9350 } }, +} + +local function GetFlatCategoryIcon(data) + if not data then return nil end + if data.mountID then return FLAT_CATEGORY_ICONS.mount end + if data.toyItemID then return FLAT_CATEGORY_ICONS.toy end + if data.petID then return FLAT_CATEGORY_ICONS.pet end + if data.outfitID then return FLAT_CATEGORY_ICONS.outfit end + if data.transmogSetID then return FLAT_CATEGORY_ICONS.appearanceSet end + if data.category == "Currency" then return FLAT_CATEGORY_ICONS.currency end + if data.category == "Reputation" and data.factionID then + return REP_FACTION_ICONS[data.factionSide or "either"] + end + return nil +end + +-- Compose the small subtext shown under a flat-list result (Alfred-style). +-- UI entries get their breadcrumb path; collection items and map results fall +-- back to a category label or zone name so every row carries some context. +local function GetFlatSubtext(data) + if not data then return "" end + if data.path and #data.path > 0 then + return tconcat(data.path, " > ") + end + if data.mapSearchResult then + return data.zoneName or "Map" + end + if data.mountID then return "Mount" end + if data.toyItemID then return "Toy" end + if data.petID then return "Pet" end + if data.outfitID then return "Outfit" end + if data.transmogSetID then return "Appearance Set" end + if data.itemID and data.category == "Loot" then + return data.lootInstanceName or "Loot" + end + return data.category or "" +end + local charKey -- "Name-Realm", set on first use local function GetCharKey() if not charKey then @@ -269,11 +343,27 @@ local function pinPopupRowOnLeave() if pinPopup and not pinPopup:IsMouseOver() then pinPopup:Hide() end end -local function ShowPinPopup(btn, isPinned, onPinAction, onGuide) +local pinPopupBlocker +local function ShowPinPopup(btn, isPinned, onPinAction, onGuide, onAddAlias) + if not pinPopupBlocker then + -- Full-screen click sink one level below the popup. Any click + -- not on the popup dismisses; the click is consumed (single- + -- click dismissal, click again for next action). + pinPopupBlocker = CreateFrame("Button", nil, UIParent) + pinPopupBlocker:SetFrameStrata("TOOLTIP") + pinPopupBlocker:SetFrameLevel(9999) + pinPopupBlocker:SetAllPoints(UIParent) + pinPopupBlocker:RegisterForClicks("AnyDown") + pinPopupBlocker:SetScript("OnClick", function() + if pinPopup then pinPopup:Hide() end + end) + pinPopupBlocker:Hide() + end if not pinPopup then pinPopup = CreateFrame("Frame", "EasyFindPinPopup", UIParent, "BackdropTemplate") pinPopup:SetFrameStrata("TOOLTIP") pinPopup:SetFrameLevel(10000) + pinPopup:EnableMouse(true) pinPopup:SetBackdrop({ bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", edgeFile = TOOLTIP_BORDER, @@ -286,9 +376,44 @@ local function ShowPinPopup(btn, isPinned, onPinAction, onGuide) pinPopup.guideRow.icon:SetTexture(EYE_ICON_TEX) pinPopup.guideRow.icon:Show() pinPopup.pinRow = CreatePinMenuRow(pinPopup) + pinPopup.aliasRow = CreatePinMenuRow(pinPopup) + pinPopup.aliasRow.label:SetText("Add Alias") pinPopup.pinRow:SetScript("OnLeave", pinPopupRowOnLeave) pinPopup.guideRow:SetScript("OnLeave", pinPopupRowOnLeave) + pinPopup.aliasRow:SetScript("OnLeave", pinPopupRowOnLeave) + + -- Dismiss on any click outside the popup itself. Both + -- GLOBAL_MOUSE_DOWN and GLOBAL_MOUSE_UP are registered: + -- DOWN catches the press from any other frame (most common + -- case), and UP is the safety net in case a higher-strata + -- frame somewhere ate the DOWN event before our handler ran. + -- The first click after Show would otherwise be the + -- right-click that opened the popup itself, which fires + -- before OnShow registers — so we also gate on a tiny grace + -- window to skip that initial event. + pinPopup:SetScript("OnShow", function(self) + self._showedAt = GetTime() + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + self:RegisterEvent("GLOBAL_MOUSE_UP") + if pinPopupBlocker then pinPopupBlocker:Show() end + end) + pinPopup:SetScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + self:UnregisterEvent("GLOBAL_MOUSE_UP") + if pinPopupBlocker then pinPopupBlocker:Hide() end + end) + pinPopup:SetScript("OnEvent", function(self, event) + if event ~= "GLOBAL_MOUSE_DOWN" and event ~= "GLOBAL_MOUSE_UP" then + return + end + if self._showedAt and (GetTime() - self._showedAt) < 0.05 then + return + end + if not self:IsMouseOver() then + self:Hide() + end + end) end pinPopup.pinRow:Show() @@ -297,28 +422,53 @@ local function ShowPinPopup(btn, isPinned, onPinAction, onGuide) pinPopup:Hide() if onPinAction then onPinAction() end end) - pinPopup.pinRow:ClearAllPoints() + + -- Stack rows top-to-bottom in this order: Guide (if applicable), + -- Pin/Unpin, Add Alias (if applicable). Each anchor chains off + -- the previous visible row so dropping one shifts the rest up. + local rowsShown = 0 + local function StackRow(row) + row:ClearAllPoints() + if rowsShown == 0 then + row:SetPoint("TOPLEFT", pinPopup, "TOPLEFT", 4, -4) + row:SetPoint("TOPRIGHT", pinPopup, "TOPRIGHT", -4, -4) + else + local prev = (rowsShown == 1 and pinPopup.guideRow:IsShown()) and pinPopup.guideRow + or (rowsShown == 2 and pinPopup.pinRow:IsShown()) and pinPopup.pinRow + or pinPopup.guideRow + row:SetPoint("TOPLEFT", prev, "BOTTOMLEFT", 0, 0) + row:SetPoint("TOPRIGHT", prev, "BOTTOMRIGHT", 0, 0) + end + row:Show() + rowsShown = rowsShown + 1 + end if onGuide then - pinPopup.guideRow:ClearAllPoints() - pinPopup.guideRow:SetPoint("TOPLEFT", pinPopup, "TOPLEFT", 4, -4) - pinPopup.guideRow:SetPoint("TOPRIGHT", pinPopup, "TOPRIGHT", -4, -4) - pinPopup.guideRow:Show() pinPopup.guideRow:SetScript("OnClick", function() pinPopup:Hide() onGuide() end) - pinPopup.pinRow:SetPoint("TOPLEFT", pinPopup.guideRow, "BOTTOMLEFT", 0, 0) - pinPopup.pinRow:SetPoint("TOPRIGHT", pinPopup.guideRow, "BOTTOMRIGHT", 0, 0) - pinPopup:SetSize(PIN_MENU_WIDTH, PIN_MENU_ROW_H * 2 + 8) + StackRow(pinPopup.guideRow) else pinPopup.guideRow:Hide() pinPopup.guideRow:SetScript("OnClick", nil) - pinPopup.pinRow:SetPoint("TOPLEFT", pinPopup, "TOPLEFT", 4, -4) - pinPopup.pinRow:SetPoint("TOPRIGHT", pinPopup, "TOPRIGHT", -4, -4) - pinPopup:SetSize(PIN_MENU_WIDTH, PIN_MENU_ROW_H + 8) end + StackRow(pinPopup.pinRow) + + if onAddAlias then + pinPopup.aliasRow:SetScript("OnClick", function() + pinPopup:Hide() + onAddAlias() + end) + StackRow(pinPopup.aliasRow) + else + pinPopup.aliasRow:Hide() + pinPopup.aliasRow:SetScript("OnClick", nil) + end + + pinPopup:SetSize(PIN_MENU_WIDTH, PIN_MENU_ROW_H * rowsShown + 8) + local scale = UIParent:GetEffectiveScale() local x, y = GetCursorPosition() pinPopup:ClearAllPoints() @@ -372,7 +522,7 @@ local THEMES = {} -- Classic: colorful tree connectors, +/- icons, gold leaf text THEMES["Classic"] = { - rowHeight = 22, + rowHeight = 18, indentPx = 20, lineWidth = 2, resultsWidth = 350, @@ -415,7 +565,7 @@ THEMES["Classic"] = { -- Retail: quest-log style - raised tab headers, golden tree lines, grey border THEMES["Retail"] = { - rowHeight = 28, + rowHeight = 22, indentPx = 20, -- matches INDENT_PX so tree lines align lineWidth = 2, resultsWidth = 350, @@ -607,10 +757,45 @@ function UI:CreateSearchFrame() local theme = GetActiveTheme() local WHITE8x8 = "Interface\\BUTTONS\\WHITE8x8" ns.CreateSearchBorder(searchFrame) + + -- Combined visual frame: 9-slice rounded rect that morphs from a + -- pill (results closed: height == bar height) to a rounded + -- rectangle (results open: height == bar height + results panel + -- height). Sibling of searchFrame at the same frame level - 1 so + -- its draw layers sit behind the bar's content; anchored to + -- searchFrame so it follows movement / resizing. + containerFrame = CreateFrame("Frame", "EasyFindContainerFrame", UIParent) + UI.containerFrame = containerFrame + containerFrame:SetFrameStrata("MEDIUM") + containerFrame:SetFrameLevel(math.max(0, searchFrame:GetFrameLevel() - 1)) + containerFrame:SetPoint("TOPLEFT", searchFrame, "TOPLEFT", 0, 0) + containerFrame:SetPoint("TOPRIGHT", searchFrame, "TOPRIGHT", 0, 0) + containerFrame:SetPoint("BOTTOM", searchFrame, "BOTTOM", 0, 0) + ns.CreateRoundedRectBorder(containerFrame) + ns.CreateRoundedRectDivider(containerFrame) + ns.SetRoundedRectBarHeight(containerFrame, searchFrame:GetHeight()) + + -- Sibling-of-searchFrame so the container's textures sit BEHIND + -- the bar's content (children would render in front). The trade- + -- off is that searchFrame:Hide / :Show no longer cascades, so + -- mirror visibility by hand. Same for alpha so SmartShow fades + -- match. + searchFrame:HookScript("OnShow", function() containerFrame:Show() end) + searchFrame:HookScript("OnHide", function() containerFrame:Hide() end) + hooksecurefunc(searchFrame, "SetAlpha", function(_, a) + containerFrame:SetAlpha(a or 1) + end) + if not searchFrame:IsShown() then containerFrame:Hide() end + if theme.searchBarRounded then searchFrame:SetBackdrop(nil) - ns.SetSearchBorderShown(searchFrame, true) - ns.SetSearchBorderBgAlpha(searchFrame, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) + -- Pill on searchFrame is hidden; the container provides the + -- visual now. Pill setup is still kept (CreateSearchBorder + -- above) so anything that pokes searchFrame.searchBorder + -- doesn't crash, but the textures stay invisible. + ns.SetSearchBorderShown(searchFrame, false) + ns.SetRoundedRectBorderShown(containerFrame, true) + ns.SetRoundedRectBorderBgAlpha(containerFrame, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) else searchFrame:SetBackdrop({ bgFile = WHITE8x8, @@ -620,6 +805,7 @@ function UI:CreateSearchFrame() }) searchFrame:SetBackdropColor(0, 0, 0, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) ns.SetSearchBorderShown(searchFrame, false) + ns.SetRoundedRectBorderShown(containerFrame, false) end -- Static magnifying-glass icon (non-interactive, flush left) @@ -728,24 +914,63 @@ function UI:CreateSearchFrame() end) local pendingUISearchTimer - editBox:SetScript("OnTextChanged", function(self) + local lastTypedLen = 0 + editBox:SetScript("OnTextChanged", function(self, userInput) if self:GetText() ~= "" then self.placeholder:Hide() end - -- Defer the search to the next frame so the keystroke frame - -- only does cheap work (placeholder + clear-button updates). - -- The full Database/Map search runs on the next frame, well - -- under the perception threshold. Multiple keystrokes within - -- one frame coalesce — only the latest text fires. + -- Autocomplete-driven SetText: text changed only because the + -- helper re-rendered the same candidate the user is already + -- looking at. Skipping here is what makes "type the next + -- highlighted char" feel like a no-op visually -- no fresh + -- search, no fresh findCandidate, no flash. + if editBox.IsAutocompleteProgrammatic + and editBox.IsAutocompleteProgrammatic() then + return + end + historyIndex = 0 + historyDraft = "" if pendingUISearchTimer then pendingUISearchTimer:Cancel() end - local snapshot = self:GetText() + -- Search query is the text up to the cursor -- anything past + -- the cursor is unaccepted autocomplete suffix and must not + -- feed into search results. Programmatic autocomplete SetText + -- is filtered above via IsAutocompleteProgrammatic, so the + -- cursor read here is always from a real keystroke. + local cursorPos = self:GetCursorPosition() or #(self:GetText() or "") + local typedNow = (self:GetText() or ""):sub(1, cursorPos) + local grew = #typedNow > lastTypedLen + lastTypedLen = #typedNow pendingUISearchTimer = C_Timer.NewTimer(0, function() pendingUISearchTimer = nil - UI:OnSearchTextChanged(snapshot) + UI:OnSearchTextChanged(typedNow) + if grew and editBox.UpdateAutocomplete then + editBox.UpdateAutocomplete() + end end) end) editBox:SetScript("OnEnterPressed", function(self) + -- Push the typed query into shell-style history so UP/Ctrl+K + -- can recall it later. Empty queries (and pure whitespace) are + -- ignored. Dedupe by removing any prior occurrence and + -- re-inserting at the front, mirroring bash's HISTCONTROL + -- "ignoredups + erasedups" behavior. + local typed = strtrim(self:GetText() or "") + if typed ~= "" then + UI:PushSearchHistory(typed) + end + historyIndex = 0 + historyDraft = "" + + -- First Enter from the editbox jumps focus into the results + -- and highlights the first row (much like DOWN does), so the + -- user can continue keyboard-navigating instead of being + -- forced to commit to the first match. A second Enter, with a + -- row already highlighted, activates as before. + if selectedIndex == 0 then + UI:MoveSelection(1) + return + end UI:ActivateSelected() end) @@ -754,6 +979,50 @@ function UI:CreateSearchFrame() -- Text and results stay visible; user can click back in to resume. end) + -- Chrome-style inline autocomplete: same helper MapTab uses. + -- Attached AFTER all SetScript calls above so HookScript-based + -- handlers don't get clobbered. The candidate source is the first + -- visible result row name, so the suggested completion always + -- aligns with what the user lands on if they press Enter. + Utils.AttachAutocomplete(editBox, { + findCandidate = function(typed) + if not typed or typed == "" then return nil end + local lower = typed:lower() + for i = 1, MAX_BUTTON_POOL do + local row = resultButtons[i] + if not row or not row:IsShown() then break end + local nm = row.data and row.data.name + if nm and #nm >= #typed then + local prefix = nm:sub(1, #typed):lower() + if prefix == lower and nm:lower() ~= lower then + return nm + end + end + end + return nil + end, + }) + + -- Shift+click link insertion: when the search bar has focus, shift-clicking + -- an item in bags / an achievement in the achievement frame / a spell in + -- the spellbook etc. routes the link's display name into our editbox the + -- same way it does into a chat editbox. ChatEdit_InsertLink is the shared + -- hook the default UI uses for this; hooking it lets us pick up the link + -- when our box is the active typing target. + if not UI._chatLinkHooked then + UI._chatLinkHooked = true + hooksecurefunc("ChatEdit_InsertLink", function(text) + if not text or text == "" then return end + local box = searchFrame and searchFrame.editBox + if box and box:IsVisible() and box:HasFocus() then + -- Strip the hyperlink wrapper so the search engine sees a + -- plain query string ("Hearthstone" instead of |cff...|H...). + local name = text:match("|h%[(.-)%]|h") or text + box:Insert(name) + end + end) + end + -- Clear-text X button (grey circle X, matching retail quest log style) -- Only visible when there is text in the editbox. local clearTextBtn = Utils.CreateClearButton(searchFrame, "EasyFindClearTextButton") @@ -780,7 +1049,9 @@ function UI:CreateSearchFrame() filterBtn:SetPoint("BOTTOM", searchFrame, "BOTTOM", 0, 0) filterBtn:SetPoint("RIGHT", searchFrame, "RIGHT", 0, 0) filterBtn:SetWidth(searchFrame:GetHeight()) - filterBtn:SetFrameLevel(searchFrame:GetFrameLevel() + 10) + -- Sit well above the rounded container's pill border so the filter + -- button's circular hover/highlight isn't visually clipped by the bar. + filterBtn:SetFrameLevel(searchFrame:GetFrameLevel() + 50) local filterArrow = filterBtn:CreateTexture(nil, "OVERLAY") filterArrow:SetSize(11, 11) @@ -800,15 +1071,73 @@ function UI:CreateSearchFrame() filterBtn:SetHighlightTexture(130757) + -- Round-pill bar theme: clip the original Blizzard hover/highlight + -- textures into a circle that fits inside the bar's right-cap + -- silhouette. AddMaskTexture preserves the originals' colors + -- (dark hover bg, blue ADD highlight) and just clips the shape. + if theme.searchBarRounded and filterBtn.CreateMaskTexture then + local CIRCLE_TEX = "Interface\\AddOns\\EasyFind\\Textures\\FilterButtonCircle" + -- Inner circle (clips hover bg + highlight) — keep the same radius + -- as the gold ring's inner edge so the ring sits flush around it. + local innerInset = 6 + local circleMask = filterBtn:CreateMaskTexture(nil, "ARTWORK") + circleMask:SetTexture(CIRCLE_TEX, "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE") + circleMask:SetPoint("TOPLEFT", filterBtn, "TOPLEFT", innerInset, -innerInset) + circleMask:SetPoint("BOTTOMRIGHT", filterBtn, "BOTTOMRIGHT", -innerInset, innerInset) + filterBtnBg:AddMaskTexture(circleMask) + local hl = filterBtn:GetHighlightTexture() + if hl and hl.AddMaskTexture then + hl:AddMaskTexture(circleMask) + end + + -- Gold perimeter ring: outer gold disc + black inner disc layered + -- on top so only a thin annulus of gold shows around the inner + -- circle. Hidden by default and revealed alongside the hover bg + -- in OnEnter / keyboard focus, so the resting state matches the + -- pre-ring look. ringInset is just 1px outside the inner circle + -- to keep the ring stroke thin. + local ringInset = innerInset - 1 + local ringMask = filterBtn:CreateMaskTexture(nil, "BACKGROUND") + ringMask:SetTexture(CIRCLE_TEX, "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE") + ringMask:SetPoint("TOPLEFT", filterBtn, "TOPLEFT", ringInset, -ringInset) + ringMask:SetPoint("BOTTOMRIGHT", filterBtn, "BOTTOMRIGHT", -ringInset, ringInset) + + local ringDisc = filterBtn:CreateTexture(nil, "BACKGROUND", nil, 1) + ringDisc:SetColorTexture(1.0, 0.82, 0.0, 1) + ringDisc:SetPoint("TOPLEFT", filterBtn, "TOPLEFT", ringInset, -ringInset) + ringDisc:SetPoint("BOTTOMRIGHT", filterBtn, "BOTTOMRIGHT", -ringInset, ringInset) + ringDisc:AddMaskTexture(ringMask) + ringDisc:Hide() + + local ringInner = filterBtn:CreateTexture(nil, "BACKGROUND", nil, 2) + ringInner:SetColorTexture(0, 0, 0, 1) + ringInner:SetPoint("TOPLEFT", filterBtn, "TOPLEFT", innerInset, -innerInset) + ringInner:SetPoint("BOTTOMRIGHT", filterBtn, "BOTTOMRIGHT", -innerInset, innerInset) + ringInner:AddMaskTexture(circleMask) + ringInner:Hide() + + filterBtn.ringDisc = ringDisc + filterBtn.ringInner = ringInner + end + + local function SetRingShown(self, shown) + if self.ringDisc then self.ringDisc:SetShown(shown) end + if self.ringInner then self.ringInner:SetShown(shown) end + end + filterBtn:SetScript("OnEnter", function(self) self.btnBg:Show() + SetRingShown(self, true) GameTooltip:SetOwner(self, "ANCHOR_BOTTOM") GameTooltip:SetText("Filter Results") GameTooltip:AddLine("Choose which result types to show.", 1, 1, 1, true) GameTooltip:Show() end) filterBtn:SetScript("OnLeave", function(self) - if not self.keyboardFocused then self.btnBg:Hide() end + if not self.keyboardFocused then + self.btnBg:Hide() + SetRingShown(self, false) + end GameTooltip_Hide() end) searchFrame.filterBtn = filterBtn @@ -835,6 +1164,8 @@ function UI:CreateSearchFrame() or (clearTextBtn and clearTextBtn:IsShown() and clearTextBtn:IsMouseOver()) then return end + -- Explicit user click: lift the persistent auto-focus block. + editBox.blockFocus = nil editBox:SetFocus() end) @@ -842,19 +1173,39 @@ function UI:CreateSearchFrame() -- UI highlight guide, or active map navigation (waypoint, SuperTrack, -- pin). So the player can press it to cancel ANY kind of active -- navigation without typing /ef c. + local lastClearShown local function UpdateClearButtonVisibility() local hasText = editBox:GetText() ~= "" local focused = editBox:HasFocus() local guideActive = ns.Highlight and ns.Highlight:IsActive() local mapActive = ns.MapSearch and ns.MapSearch.HasActiveNavigation and ns.MapSearch:HasActiveNavigation() - clearTextBtn:SetShown(hasText or focused or guideActive or mapActive) + local shouldShow = hasText or focused or guideActive or mapActive + if shouldShow ~= lastClearShown then + clearTextBtn:SetShown(shouldShow) + lastClearShown = shouldShow + end end editBox:HookScript("OnTextChanged", UpdateClearButtonVisibility) editBox:HookScript("OnEditFocusGained", UpdateClearButtonVisibility) editBox:HookScript("OnEditFocusLost", UpdateClearButtonVisibility) searchFrame.UpdateClearButtonVisibility = UpdateClearButtonVisibility + -- Highlight and MapSearch state changes happen outside the + -- editbox event flow, so the focus-event hooks above can leave + -- the clear X stuck on after a guide finishes or a navigation + -- arrival clears. A throttled OnUpdate hook (4 Hz) re-runs the + -- visibility check so the X disappears within ~250ms of the + -- underlying state going false. HookScript (not SetScript) so + -- the keyRepeat OnUpdate registered above keeps running. + local clearPollAccum = 0 + searchFrame:HookScript("OnUpdate", function(_, elapsed) + clearPollAccum = clearPollAccum + (elapsed or 0) + if clearPollAccum < 0.25 then return end + clearPollAccum = 0 + UpdateClearButtonVisibility() + end) + -- Shared key-repeat helper (also used by MapTab). Attaches its own -- OnUpdate to searchFrame; action fires immediately on Start, then -- at an accelerating cadence while the key is held. @@ -869,6 +1220,33 @@ function UI:CreateSearchFrame() -- IMPORTANT: Always block propagation while the editbox has focus so that -- typed letters never trigger the player's game keybinds. editBox:SetScript("OnKeyDown", function(self, key) + -- Shell-style history. UP walks back toward older entries + -- (capped at the oldest); DOWN walks forward toward newer + -- entries until we land back on the live draft, then drops + -- into the results list. Drop-into-results works regardless + -- of buffer content: the user wants keyboard nav into rows + -- without having to press Enter first, even mid-edit. + local isUpHist = key == "UP" or (IsControlKeyDown() and (key == "K" or key == "P")) + local isDownHist = key == "DOWN" or (IsControlKeyDown() and (key == "J" or key == "N")) + if isUpHist then + if UI:NavigateSearchHistory(1) then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + end + -- At history ceiling: swallow the key so it can't fall + -- through to result navigation or game keybinds. + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + elseif isDownHist then + if historyIndex > 0 then + UI:NavigateSearchHistory(-1) + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + end + -- historyIndex == 0 (live draft): fall through to result + -- nav so DOWN/Ctrl+J jumps into the first row. + end + if resultsFrame and resultsFrame:IsShown() and selectedIndex == 0 then if EasyFind.db.uiResultsAbove then if key == "UP" then UI:JumpToEnd() end @@ -876,19 +1254,26 @@ function UI:CreateSearchFrame() if key == "DOWN" then UI:MoveSelection(1) end end end - -- Ctrl+J/K (and the emacs-equivalent Ctrl+N/P) work from the - -- editbox as down/up aliases. First press drops into the first - -- result; subsequent presses walk the list. Parallels MapTab. + -- Ctrl+J/K (and the emacs-equivalent Ctrl+N/P) walk into the + -- result list once history navigation has been exhausted by + -- the branch above. Holding accelerates like UP/DOWN. if IsControlKeyDown() then if key == "J" or key == "N" then - UI:MoveSelection(1) + StartKeyRepeat(key, function() UI:MoveSelection(1) end) elseif key == "K" or key == "P" then - UI:MoveSelection(-1) + StartKeyRepeat(key, function() UI:MoveSelection(-1) end) end end Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) end) + -- Mirror the navFrame OnKeyUp: a key whose down event landed on the + -- editbox needs to terminate its hold-repeat ticker on release here + -- too, otherwise it keeps stepping after the user lets go. + editBox:SetScript("OnKeyUp", function(_, key) + if keyRepeat.IsKey(key) then StopKeyRepeat(key) end + end) + searchFrame.editBox = editBox -- Toolbar keyboard focus: 0 = editbox, 1+ = toolbar control index @@ -920,6 +1305,8 @@ function UI:CreateSearchFrame() if prevTarget then prevTarget.keyboardFocused = nil if prevTarget.btnBg then prevTarget.btnBg:Hide() end + if prevTarget.ringDisc then prevTarget.ringDisc:Hide() end + if prevTarget.ringInner then prevTarget.ringInner:Hide() end if prevTarget.UnlockHighlight then prevTarget:UnlockHighlight() end end toolbarFocus = idx @@ -929,6 +1316,8 @@ function UI:CreateSearchFrame() target.keyboardFocused = true if target.btnBg then target.btnBg:Show() + if target.ringDisc then target.ringDisc:Show() end + if target.ringInner then target.ringInner:Show() end if target.LockHighlight then target:LockHighlight() end toolbarHighlight:Hide() else @@ -948,6 +1337,8 @@ function UI:CreateSearchFrame() if prevTarget then prevTarget.keyboardFocused = nil if prevTarget.btnBg then prevTarget.btnBg:Hide() end + if prevTarget.ringDisc then prevTarget.ringDisc:Hide() end + if prevTarget.ringInner then prevTarget.ringInner:Hide() end if prevTarget.UnlockHighlight then prevTarget:UnlockHighlight() end end toolbarFocus = 0 @@ -973,6 +1364,7 @@ function UI:CreateSearchFrame() if toolbarFocus == 1 then ClearToolbarFocus() Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + searchFrame.editBox.blockFocus = nil searchFrame.editBox:SetFocus() else SetToolbarFocus(toolbarFocus - 1) @@ -994,6 +1386,7 @@ function UI:CreateSearchFrame() if toolbarFocus >= #controls then ClearToolbarFocus() Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + searchFrame.editBox.blockFocus = nil searchFrame.editBox:SetFocus() else SetToolbarFocus(toolbarFocus + 1) @@ -1013,14 +1406,14 @@ function UI:CreateSearchFrame() if IsShiftKeyDown() then UI:JumpToNextSection(1) else - UI:MoveSelection(1) + StartKeyRepeat(key, function() UI:MoveSelection(1) end) end return elseif IsControlKeyDown() and (key == "K" or key == "P") then if IsShiftKeyDown() then UI:JumpToNextSection(-1) else - UI:MoveSelection(-1) + StartKeyRepeat(key, function() UI:MoveSelection(-1) end) end return elseif IsControlKeyDown() and key == "L" then @@ -1137,14 +1530,13 @@ function UI:CreateSearchFrame() Utils.SafeCallMethod(navFrame, "SetPropagateKeyboardInput", true) return end + -- Selection is active and a non-nav key was pressed: swallow it. + -- Previously this branch yanked focus back to the editbox and + -- inserted the typed character, which felt like the bar was + -- still capturing input even though the user had committed to + -- the results list. To type more, press ESC (back to editbox) + -- or click the bar. ClearToolbarFocus() - selectedIndex = 0 - toggleFocused = false - UI:UpdateSelectionHighlight() - if not IsControlKeyDown() and not IsAltKeyDown() and #key == 1 then - local char = IsShiftKeyDown() and key or slower(key) - searchFrame.editBox:Insert(char) - end end end @@ -1185,42 +1577,9 @@ function UI:CreateSearchFrame() UI:HideResults() end) - -- Tab/Shift+Tab from editbox: navigate toolbar controls to the right of - -- the editbox (clearTextBtn, filterBtn). Shift+Tab wraps to the last. - editBox:HookScript("OnKeyDown", function(self, key) - if key ~= "TAB" then return end - -- Tab-completion: if there's a candidate result whose name - -- contains the current query, complete to it and keep focus. - -- Fall through to toolbar-focus behavior below only when no - -- completion is applicable. - local q = self:GetText() or "" - if q ~= "" and not IsShiftKeyDown() and cachedHierarchical then - -- Walk in scoring order, take the first prefix-matching - -- candidate. Skips fuzzy winners that don't start with the - -- typed text (e.g. "dragon i" skipping "Dragonblight" to - -- reach "Dragon Isles"). - local qLower = q:lower() - for _, entry in ipairs(cachedHierarchical) do - if entry.isMatch and not entry.isPathNode and entry.name - and #entry.name >= #q - and entry.name:sub(1, #q):lower() == qLower - and entry.name:lower() ~= qLower then - local completed = entry.name:lower() - self:SetText(completed) - self:SetCursorPosition(#completed) - return - end - end - end - self:ClearFocus() - Utils.SafeCallMethod(navFrame, "EnableKeyboard", true) - local controls = GetToolbarControls() - if IsShiftKeyDown() then - SetToolbarFocus(#controls) - else - SetToolbarFocus(1) - end - end) + -- Tab confirms autocomplete suggestion only. Toolbar nav (clear / + -- filter buttons) is handled by Left/Right and Ctrl+H/Ctrl+L + -- elsewhere; routing Tab into it stomped the autocomplete confirm. -- Draggable with Shift key searchFrame:RegisterForDrag("LeftButton") @@ -1366,17 +1725,72 @@ function UI:CreateSearchFrame() self:CreateUIFilterDropdown(filterBtn, searchFrame, editBox) end +-- Top-level filter list, alphabetical. Collections stays compact via a +-- flyout: the row carries a yellow expand arrow on the right; hovering +-- it opens a popup of sub-filter checkboxes (Mounts/Toys/Pets/Outfits/ +-- Appearance Sets). Each sub-filter still owns its own filters[key] +-- value; only the rendering changed. local UI_FILTER_OPTIONS = { - { key = "ui", label = "UI Search", iconAtlas = "common-search-magnifyingglass" }, - { key = "map", label = "Map Search", iconAtlas = "Waypoint-MapPin-ChatIcon" }, - { key = "mounts", label = "Mounts", iconTex = 132261 }, -- Ability_Mount_RidingHorse - { key = "toys", label = "Toys", iconTex = 454046 }, -- Trade_Archaeology_ChestofTinyGlassAnimals - { key = "pets", label = "Pets", iconTex = 631719 }, -- PetJournal paw icon - { key = "outfits", label = "Outfits", iconTex = 132649 }, -- INV_Chest_Cloth_17 - { key = "loot", label = "Loot", iconTex = 132281 }, -- INV_Sword_04 + { key = "achievements", label = "Achievements", iconTex = 235413 }, + -- Appearance Sets is top-level (rather than nested under + -- Collections) because it owns extra inline controls (class + -- selector + collected/PvE/PvP filter button) that need a stable + -- dropdown row to anchor them. { key = "appearanceSets", label = "Appearance Sets", iconTex = "Interface\\Icons\\INV_Helmet_03" }, + { key = "bags", label = "Bags", iconTex = 133634 }, + { key = "collections", label = "Collections", iconTex = 132834, + flyoutSubFilters = { + { key = "mounts", label = "Mounts", iconTex = 132261 }, + { key = "outfits", label = "Outfits", iconTex = 132649 }, + { key = "pets", label = "Pets", iconTex = 631719 }, + { key = "toys", label = "Toys", iconTex = 454046 }, + } }, + { key = "currencies", label = "Currencies", iconTex = 133784 }, + { key = "loot", label = "Gear", iconTex = 132281 }, + { key = "map", label = "Map Search", iconAtlas = "Waypoint-MapPin-ChatIcon" }, + { key = "options", label = "Game Options", iconAtlas = "QuestLog-icon-setting" }, + { key = "reputations", label = "Reputations", iconTex = 1121272, + iconCoords = { 0.3783, 0.4072, 0.9066, 0.9350 } }, + { key = "ui", label = "UI Elements", iconAtlas = "common-search-magnifyingglass" }, +} + +-- All filter keys (top-level + sub-filters in flyouts). Used by Toggle +-- All / OnShow sync so flyout-hosted filters update too. +local function ForEachFilterKey(callback) + for _, opt in ipairs(UI_FILTER_OPTIONS) do + callback(opt.key, opt) + if opt.flyoutSubFilters then + for _, sub in ipairs(opt.flyoutSubFilters) do + callback(sub.key, sub) + end + end + end +end + +-- Module-level helpers for bucketing UI search results into one of: +-- "achievements" / "currencies" / "reputations" / "ui" (UI elements). +-- Used both in the per-keystroke filter and category sort. +local UI_BUCKET_BY_CATEGORY = { + ["Achievements"] = "achievements", + ["Guild Achievements"] = "achievements", + ["Statistics"] = "achievements", + ["Currency"] = "currencies", + ["Reputation"] = "reputations", + ["Bag"] = "bags", + ["Game Settings"] = "options", } +local function GetUIBucket(d) + -- Returns one of the bucket keys for non-collection / non-map UI + -- entries, or nil for entries handled by a separate dedicated filter. + if not d then return nil end + if d.mountID or d.toyItemID or d.petID or d.outfitID or d.transmogSetID + or (d.itemID and d.category == "Loot") or d.mapSearchResult then + return nil + end + return UI_BUCKET_BY_CATEGORY[d.category] or "ui" +end + function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local ROW_HEIGHT = 20 local DROPDOWN_WIDTH = 207 @@ -1532,10 +1946,16 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end for i, opt in ipairs(UI_FILTER_OPTIONS) do + -- Children of a parent filter (e.g., Collections > Mounts) render + -- indented; their visible width shrinks by SUB_INDENT so the + -- right-edge icon stays inside the dropdown. + local rowWidth = DROPDOWN_WIDTH - 16 + if opt.parentKey then rowWidth = rowWidth - 24 end local row = CreateFrame("CheckButton", nil, dropdown) - row:SetSize(DROPDOWN_WIDTH - 16, ROW_HEIGHT) + row:SetSize(rowWidth, ROW_HEIGHT) row:SetHitRectInsets(0, 0, 0, 0) row.optKey = opt.key + row.parentKey = opt.parentKey row:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") row:GetNormalTexture():SetSize(CHECK_SIZE, CHECK_SIZE) @@ -1552,6 +1972,10 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) label:SetText(opt.label) row.label = label + -- Always draw the configured category icon, even on rows with a + -- flyout. The auto-popup on hover is enough hint that the row has + -- sub-options; no extra arrow needed. Supports atlas, raw fileID, + -- or fileID+coords for sprite-sheet sub-icons. if opt.iconAtlas or opt.iconTex then local icon = row:CreateTexture(nil, "ARTWORK") icon:SetSize(ICON_SIZE, ICON_SIZE) @@ -1560,7 +1984,138 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) icon:SetAtlas(opt.iconAtlas) else icon:SetTexture(opt.iconTex) + if opt.iconCoords then + icon:SetTexCoord(opt.iconCoords[1], opt.iconCoords[2], + opt.iconCoords[3], opt.iconCoords[4]) + end + end + row.iconTex = icon + end + + -- Flyout sub-filters (e.g. Collections > Mounts/Toys/Pets/...). + -- Hovering the row opens a popup containing one CheckButton per + -- sub-filter. Each sub-filter writes through to filters[subKey] + -- like a regular top-level filter so search uses them as-is. + if opt.flyoutSubFilters then + local SUB_POPUP_WIDTH = 180 + local SUB_ROW_H = 22 + local SUB_PAD = 6 + local CHK = CHECK_SIZE + local SUB_ICON = ICON_SIZE + + -- Parent to UIParent + TOOLTIP strata mirrors the loot + -- spec/class popups; nesting under `dropdown` left clicks + -- routed back to the dropdown's own outside-click handler + -- and the popup felt unclickable. + local popup = CreateFrame("Frame", nil, UIParent, "BackdropTemplate") + popup:SetFrameStrata("TOOLTIP") + StylePopup(popup) + popup:EnableMouse(true) + popup:Hide() + row.flyoutPopup = popup + dropdownGuardFrames[#dropdownGuardFrames + 1] = popup + + local subRows = {} + for si, sub in ipairs(opt.flyoutSubFilters) do + local subRow = CreateFrame("CheckButton", nil, popup) + subRow:SetSize(SUB_POPUP_WIDTH - SUB_PAD * 2, SUB_ROW_H) + subRow:SetHitRectInsets(0, 0, 0, 0) + subRow:SetPoint("TOPLEFT", popup, "TOPLEFT", SUB_PAD, -(SUB_PAD + (si - 1) * SUB_ROW_H)) + + subRow:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") + subRow:GetNormalTexture():SetSize(CHK, CHK) + subRow:GetNormalTexture():ClearAllPoints() + subRow:GetNormalTexture():SetPoint("LEFT", 4, 0) + + subRow:SetCheckedTexture("Interface\\Buttons\\UI-CheckBox-Check") + subRow:GetCheckedTexture():SetSize(CHK, CHK) + subRow:GetCheckedTexture():ClearAllPoints() + subRow:GetCheckedTexture():SetPoint("LEFT", 4, 0) + + local subLabel = subRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + subLabel:SetPoint("LEFT", subRow:GetNormalTexture(), "RIGHT", 4, 0) + subLabel:SetText(sub.label) + + if sub.iconAtlas or sub.iconTex then + local subIcon = subRow:CreateTexture(nil, "ARTWORK") + subIcon:SetSize(SUB_ICON, SUB_ICON) + subIcon:SetPoint("RIGHT", -4, 0) + if sub.iconAtlas then + subIcon:SetAtlas(sub.iconAtlas) + else + subIcon:SetTexture(sub.iconTex) + end + end + + local subHL = subRow:CreateTexture(nil, "HIGHLIGHT") + subHL:SetAllPoints() + subHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + + subRow:SetScript("OnClick", function(self) + EasyFind.db.uiSearchFilters[sub.key] = self:GetChecked() + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end) + + subRows[si] = subRow + subRows[sub.key] = subRow + end + row.flyoutSubRows = subRows + popup:SetSize(SUB_POPUP_WIDTH, SUB_PAD * 2 + #opt.flyoutSubFilters * SUB_ROW_H) + + -- Sync sub-row checked state from current DB values. + local function SyncSubChecks() + local f = EasyFind.db.uiSearchFilters + for _, sub in ipairs(opt.flyoutSubFilters) do + local sr = subRows[sub.key] + if sr then sr:SetChecked(f[sub.key] ~= false) end + end + end + row.SyncFlyoutSubChecks = SyncSubChecks + + -- Show on hover of either the parent row or the arrow. + -- Hide when the cursor leaves both the row and the popup, + -- with a small grace timer so brief gaps between them don't + -- snap the menu shut. + local function PositionPopup() + popup:ClearAllPoints() + popup:SetPoint("TOPLEFT", row, "TOPRIGHT", 4, 0) + end + local hideTimer + local function ShowPopup() + if hideTimer then hideTimer:Cancel(); hideTimer = nil end + SyncSubChecks() + popup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + PositionPopup() + popup:Show() + end + local function MaybeHide() + if popup:IsMouseOver() or row:IsMouseOver() then return end + popup:Hide() + end + local function ScheduleHide() + if hideTimer then hideTimer:Cancel() end + hideTimer = C_Timer.NewTimer(0.15, function() + hideTimer = nil + MaybeHide() + end) end + + -- Need to call ShowPopup from row's OnEnter (set lower in + -- the loop), so stash it on the row for the OnClick handler. + row.ShowFlyoutPopup = ShowPopup + row.ScheduleHideFlyoutPopup = ScheduleHide + + popup:HookScript("OnLeave", ScheduleHide) + popup:HookScript("OnEnter", function() + if hideTimer then hideTimer:Cancel(); hideTimer = nil end + end) + row:HookScript("OnEnter", ShowPopup) + row:HookScript("OnLeave", ScheduleHide) + -- Close when the parent dropdown closes so the popup can't + -- linger over other UI. + dropdown:HookScript("OnHide", function() popup:Hide() end) end -- Map Search: was a local/global radio pair, but the MapTab @@ -2591,7 +3146,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) row:SetScript("OnClick", function(self) local filters = EasyFind.db.uiSearchFilters filters[opt.key] = self:GetChecked() - if self.updateMapToggle then self.updateMapToggle() end if self.updateLootToggle then self.updateLootToggle() end if self.updateAppearanceSetToggle then self.updateAppearanceSetToggle() end LayoutDropdown() @@ -2634,6 +3188,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local savedFocus = dropdownFocus wipe(dropdownNavRows) dropdownKbHighlight:Hide() + local filters = EasyFind.db.uiSearchFilters local y = -PADDING_TOP -- Toggle All row uncheckRow:ClearAllPoints() @@ -2643,111 +3198,112 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- 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 - local mapChecked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.map ~= false - for _, sr in ipairs(row.mapSubRows) do - if mapChecked then - sr:ClearAllPoints() - sr:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) - sr:Show() - dropdownNavRows[#dropdownNavRows + 1] = sr - y = y - ROW_HEIGHT - else - sr:Hide() - end + -- Hide a child row entirely when its parent toggle is off + local parentVisible = (not opt.parentKey) or (filters[opt.parentKey] ~= false) + if not parentVisible then + row:Hide() + -- Also hide nested sub-options so they don't float on screen + if row.asClassSelectRow then row.asClassSelectRow:Hide() end + if row.asFilterBtn then row.asFilterBtn:Hide() end + if row.asSep then row.asSep:Hide() end + if row.lootSep then row.lootSep:Hide() end + if row.specSelectRow then row.specSelectRow:Hide() end + if row.diffBtn then row.diffBtn:Hide() end + if row.lootSubRows then + for _, sr in ipairs(row.lootSubRows) do sr:Hide() end end - end - -- Appearance Sets sub-rows - if row.asClassSelectRow then - local asChecked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.appearanceSets ~= false - if row.asSep then - if asChecked then - row.asSep:ClearAllPoints() - row.asSep:SetPoint("LEFT", 8 + SUB_INDENT, 0) - row.asSep:SetPoint("RIGHT", dropdown, "RIGHT", -8, 0) - row.asSep:SetPoint("TOP", 0, y - 2) - row.asSep:Show() - y = y - 6 - else - row.asSep:Hide() + else + local rowIndent = opt.parentKey and SUB_INDENT or 0 + row:ClearAllPoints() + row:SetPoint("TOPLEFT", 8 + rowIndent, y) + row:Show() + dropdownNavRows[#dropdownNavRows + 1] = row + y = y - ROW_HEIGHT + -- Sub-options nest one indent level deeper than the row + local subIndent = rowIndent + SUB_INDENT + -- Appearance Sets sub-rows + if row.asClassSelectRow then + local asChecked = filters and filters.appearanceSets ~= false + if row.asSep then + if asChecked then + row.asSep:ClearAllPoints() + row.asSep:SetPoint("LEFT", 8 + subIndent, 0) + row.asSep:SetPoint("RIGHT", dropdown, "RIGHT", -8, 0) + row.asSep:SetPoint("TOP", 0, y - 2) + row.asSep:Show() + y = y - 6 + else + row.asSep:Hide() + end end - end - if asChecked then - row.asClassSelectRow:ClearAllPoints() - row.asClassSelectRow:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) - row.asClassSelectRow:Show() - dropdownNavRows[#dropdownNavRows + 1] = row.asClassSelectRow - y = y - 28 - else - row.asClassSelectRow:Hide() - end - if row.asFilterBtn then if asChecked then - row.asFilterBtn:ClearAllPoints() - row.asFilterBtn:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) - row.asFilterBtn:Show() - dropdownNavRows[#dropdownNavRows + 1] = row.asFilterBtn - y = y - 24 + row.asClassSelectRow:ClearAllPoints() + row.asClassSelectRow:SetPoint("TOPLEFT", 8 + subIndent, y) + row.asClassSelectRow:Show() + dropdownNavRows[#dropdownNavRows + 1] = row.asClassSelectRow + y = y - 28 else - row.asFilterBtn:Hide() + row.asClassSelectRow:Hide() end - end - end - -- Loot sub-rows - if row.lootSubRows then - local lootChecked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.loot ~= false - -- 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() + if row.asFilterBtn then + if asChecked then + row.asFilterBtn:ClearAllPoints() + row.asFilterBtn:SetPoint("TOPLEFT", 8 + subIndent, y) + row.asFilterBtn:Show() + dropdownNavRows[#dropdownNavRows + 1] = row.asFilterBtn + y = y - 24 + else + row.asFilterBtn:Hide() + end 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() - dropdownNavRows[#dropdownNavRows + 1] = row.specSelectRow - y = y - 24 - else - row.specSelectRow:Hide() + -- Loot sub-rows + if row.lootSubRows then + local lootChecked = filters and filters.loot ~= false + if row.lootSep then + if lootChecked then + row.lootSep:ClearAllPoints() + row.lootSep:SetPoint("LEFT", 8 + subIndent, 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 - 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() - dropdownNavRows[#dropdownNavRows + 1] = row.diffBtn - y = y - 28 - else - row.diffBtn:Hide() + if row.specSelectRow then + if lootChecked then + row.specSelectRow:ClearAllPoints() + row.specSelectRow:SetPoint("TOPLEFT", 8 + subIndent, y) + row.specSelectRow:Show() + dropdownNavRows[#dropdownNavRows + 1] = row.specSelectRow + y = y - 24 + else + row.specSelectRow:Hide() + end end - end - -- Checkbox sub-options - for _, sr in ipairs(row.lootSubRows) do - if lootChecked then - sr:ClearAllPoints() - sr:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) - sr:Show() - dropdownNavRows[#dropdownNavRows + 1] = sr - y = y - ROW_HEIGHT - else - sr:Hide() + if row.diffBtn then + if lootChecked then + row.diffBtn:ClearAllPoints() + row.diffBtn:SetPoint("TOPLEFT", 8 + subIndent, y) + row.diffBtn:Show() + dropdownNavRows[#dropdownNavRows + 1] = row.diffBtn + y = y - 28 + else + row.diffBtn:Hide() + end + end + for _, sr in ipairs(row.lootSubRows) do + if lootChecked then + sr:ClearAllPoints() + sr:SetPoint("TOPLEFT", 8 + subIndent, y) + sr:Show() + dropdownNavRows[#dropdownNavRows + 1] = sr + y = y - ROW_HEIGHT + else + sr:Hide() + end end end end @@ -2805,19 +3361,20 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) uncheckRow:SetScript("OnClick", function() local filters = EasyFind.db.uiSearchFilters local allUnchecked = true - for _, opt in ipairs(UI_FILTER_OPTIONS) do - if filters[opt.key] ~= false then - allUnchecked = false - break - end - end + ForEachFilterKey(function(key) + if filters[key] ~= false then allUnchecked = false end + end) local newState = allUnchecked + ForEachFilterKey(function(key) + filters[key] = newState + end) for _, opt in ipairs(UI_FILTER_OPTIONS) do - filters[opt.key] = newState - checkRows[opt.key]:SetChecked(newState) + local row = checkRows[opt.key] + if row then + row:SetChecked(newState) + if row.SyncFlyoutSubChecks then row.SyncFlyoutSubChecks() end + end 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() @@ -2832,8 +3389,8 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local filters = EasyFind.db.uiSearchFilters 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 + if row.SyncFlyoutSubChecks then row.SyncFlyoutSubChecks() end end LayoutDropdown() end) @@ -2895,17 +3452,18 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) if searchFrame.ClearToolbarFocus then searchFrame.ClearToolbarFocus() end Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) if searchFrame.filterBtn then - searchFrame.filterBtn.keyboardFocused = nil + local fb = searchFrame.filterBtn + fb.keyboardFocused = nil -- Don't wipe the hover highlight if the cursor is still on -- the filter button (the common case when clicking the -- button to toggle the dropdown closed). Otherwise the -- outline disappears and OnEnter doesn't re-fire until -- the cursor leaves and comes back. - if searchFrame.filterBtn.btnBg and not searchFrame.filterBtn:IsMouseOver() then - searchFrame.filterBtn.btnBg:Hide() - end - if searchFrame.filterBtn.UnlockHighlight and not searchFrame.filterBtn:IsMouseOver() then - searchFrame.filterBtn:UnlockHighlight() + if not fb:IsMouseOver() then + if fb.btnBg then fb.btnBg:Hide() end + if fb.ringDisc then fb.ringDisc:Hide() end + if fb.ringInner then fb.ringInner:Hide() end + if fb.UnlockHighlight then fb:UnlockHighlight() end end end if searchFrame.editBox and not searchFrame.editBox:IsMouseOver() then @@ -3032,8 +3590,22 @@ 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, groupAppearanceSets, groupMap = {}, {}, {}, {}, {}, {}, {}, {} +local groupAchievements, groupCurrencies, groupReputations = {}, {}, {} +local groupBags, groupOptions = {}, {} local uiSectionHeader = { - name = "UI", depth = 0, isPathNode = true, + name = "UI Elements", depth = 0, isPathNode = true, + isMatch = false, isSectionHeader = true, +} +local achievementSectionHeader = { + name = "Achievements", depth = 0, isPathNode = true, + isMatch = false, isSectionHeader = true, +} +local currencySectionHeader = { + name = "Currencies", depth = 0, isPathNode = true, + isMatch = false, isSectionHeader = true, +} +local reputationSectionHeader = { + name = "Reputations", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, } local mountSectionHeader = { @@ -3053,7 +3625,7 @@ local outfitSectionHeader = { isMatch = false, isSectionHeader = true, } local lootSectionHeader = { - name = "Loot", depth = 0, isPathNode = true, + name = "Gear", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, } local appearanceSetSectionHeader = { @@ -3064,6 +3636,54 @@ local mapSectionHeader = { name = "Map Search", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, } +local bagsSectionHeader = { + name = "Bags", depth = 0, isPathNode = true, + isMatch = false, isSectionHeader = true, +} +local optionsSectionHeader = { + name = "Game Options", depth = 0, isPathNode = true, + isMatch = false, isSectionHeader = true, +} + +-- Flat-list mode scratch: reused entry pool keeps per-keystroke allocations +-- low when uiHideHeaders is on. flatEntries holds recyclable entry tables; +-- flatCatGroups is the catGroup buffer for sort ordering. +local flatEntries = {} +local flatCatGroups = {} + +-- Module-level so tsort doesn't re-allocate the closure each keystroke. +local function CatGroupScoreDescending(a, b) + if a.score ~= b.score then return a.score > b.score end + return a.key < b.key +end + +-- Within-group ordering for flat-list mode. Path-aware for UI entries so +-- siblings under the same parent cluster together (Group Finder > PvP items +-- stay adjacent regardless of relevance score). Collection items just sort +-- alphabetically since they have no shared parent path. +local function FlatPathLess(ra, rb) + local pa, pb = ra.data.path, rb.data.path + local na = pa and #pa or 0 + local nb = pb and #pb or 0 + local m = na < nb and na or nb + for i = 1, m do + local x, y = pa[i], pb[i] + if x ~= y then return x < y end + end + if na ~= nb then return na < nb end + return (ra.data.name or "") < (rb.data.name or "") +end + +local function FlatNameLess(ra, rb) + return (ra.data.name or "") < (rb.data.name or "") +end + +local function FlatMapLess(ra, rb) + local za = ra.data.zoneName or "" + local zb = rb.data.zoneName or "" + if za ~= zb then return za < zb end + return (ra.data.name or "") < (rb.data.name or "") +end -- Expand a container node: inject its database children into cachedHierarchical. local function ExpandContainer(entry, entryIndex) @@ -3497,7 +4117,81 @@ function UI:CreateResultButton(index) text:SetJustifyH("LEFT") resultRow.text = text + -- Path subtext (flat-headerless mode only). Anchored under the name in the + -- render path; hidden by default since most rendering branches don't use it. + -- Single-line, truncated on overflow so long paths can't wrap into the next row. + local pathSubtext = resultRow:CreateFontString(nil, "OVERLAY", ns.LEAF_FONT) + pathSubtext:SetJustifyH("LEFT") + pathSubtext:SetWordWrap(false) + pathSubtext:SetNonSpaceWrap(false) + pathSubtext:SetMaxLines(1) + pathSubtext:Hide() + resultRow.pathSubtext = pathSubtext + + -- Flat-mode left-side category icon. Shown for collection rows where the + -- main icon is repositioned to the right (mounts/toys/pets/outfits/sets), + -- so the row still has a visual left anchor next to the name+path stack. + local flatCatIcon = resultRow:CreateTexture(nil, "ARTWORK") + flatCatIcon:Hide() + resultRow.flatCatIcon = flatCatIcon + + -- LeftButtonDown for the secure-action click: SecureActionButton's + -- handler in retail fires reliably on Down events for spell/toy/ + -- macro/action types, but silently no-ops for some types when + -- registered on Up only. RegisterForDrag still works because drag + -- detection runs from press-and-move regardless of click registry. + -- RightButtonUp keeps the pin/alias context menu on release. resultRow:RegisterForClicks("LeftButtonDown", "RightButtonUp") + + -- Drag-to-pickup: hold left mouse and drag a result row to drop + -- the action it represents on the cursor (so it can be placed on + -- action bars, etc.) — matching native UI behavior. Mounts route + -- through their cast spell because C_MountJournal.Pickup takes a + -- display index that's only meaningful inside the journal's + -- filtered view. C_Spell.PickupSpell is preferred over the legacy + -- global because Midnight phased the global out for some spells. + local function PickupSpellCompat(spellID) + if C_Spell and C_Spell.PickupSpell then + C_Spell.PickupSpell(spellID) + elseif PickupSpell then + PickupSpell(spellID) + end + end + resultRow:RegisterForDrag("LeftButton") + resultRow:SetScript("OnDragStart", function(self) + local d = self.data + if not d then return end + if InCombatLockdown() then return end + ClearCursor() + if d.mountID and C_MountJournal and C_MountJournal.GetMountInfoByID then + local _, spellID = C_MountJournal.GetMountInfoByID(d.mountID) + if spellID then PickupSpellCompat(spellID) end + elseif d.petID and C_PetJournal and C_PetJournal.PickupPet then + C_PetJournal.PickupPet(d.petID) + elseif d.toyItemID and C_ToyBox and C_ToyBox.PickupToyBoxItem then + C_ToyBox.PickupToyBoxItem(d.toyItemID) + elseif d.outfitID and C_TransmogOutfitInfo and C_TransmogOutfitInfo.PickupOutfit then + C_TransmogOutfitInfo.PickupOutfit(d.outfitID) + elseif d.macroIndex and PickupMacro then + PickupMacro(d.macroIndex) + elseif d.spellID then + PickupSpellCompat(d.spellID) + elseif d.bagID and d.bagSlot then + -- Bag items: pick up by container slot, which mirrors the + -- native bag-item drag (carries stack count, can drop on + -- action bars, banks, etc.). Fall back to PickupItem when + -- the container slot lookup isn't available. + local pickup = (C_Container and C_Container.PickupContainerItem) or PickupContainerItem + if pickup then + pickup(d.bagID, d.bagSlot) + elseif d.itemID and PickupItem then + PickupItem(d.itemID) + end + elseif d.itemID and PickupItem then + PickupItem(d.itemID) + end + end) + -- 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) @@ -3555,6 +4249,7 @@ function UI:CreateResultButton(index) -- 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.blockFocus = nil searchFrame.editBox:SetFocus() end return @@ -3593,6 +4288,10 @@ function UI:CreateResultButton(index) local onGuide = hasGuide and function() UI:SelectResult(pinData, true) end or nil + local canAlias = ns.Aliases and ns.Aliases:GetEntryKey(pinData) ~= nil + local onAddAlias = canAlias and function() + UI:PromptForAlias(pinData) + end or nil ShowPinPopup(self, isPinned, function() if isPinned then UnpinUIItem(pinData) @@ -3606,7 +4305,7 @@ function UI:CreateResultButton(index) else UI:OnSearchTextChanged(text) end - end, onGuide) + end, onGuide, onAddAlias) return end @@ -3778,6 +4477,107 @@ function UI:CreateResultButton(index) return resultRow end +-- Prepend `text` to EasyFindDB.uiSearchHistory, dedupe by removing +-- any prior occurrence (case-insensitive) and trim to the configured +-- limit. The most recent search lives at index 1. +function UI:PushSearchHistory(text) + if not EasyFind.db then return end + local hist = EasyFind.db.uiSearchHistory + if type(hist) ~= "table" then + hist = {} + EasyFind.db.uiSearchHistory = hist + end + local lower = text:lower() + for i = #hist, 1, -1 do + if hist[i] and hist[i]:lower() == lower then + tremove(hist, i) + end + end + tinsert(hist, 1, text) + local limit = EasyFind.db.uiSearchHistoryLimit or 500 + while #hist > limit do tremove(hist) end +end + +-- Prompt the user for an alias text and bind it to `data`. Uses a +-- StaticPopup with a single edit box so we don't need to hand-build a +-- dialog frame. Pre-fills with the current search text so saving an +-- alias for whatever just matched is one keystroke. +StaticPopupDialogs["EASYFIND_ADD_ALIAS"] = { + text = "Alias for %s:", + button1 = ACCEPT or "OK", + button2 = CANCEL or "Cancel", + hasEditBox = true, + maxLetters = 64, + timeout = 0, + whileDead = true, + hideOnEscape = true, + enterClicksFirstButton = true, + OnShow = function(self, data) + self.editBox:SetText("") + self.editBox:SetFocus() + end, + OnAccept = function(self, data) + local txt = self.editBox:GetText() + if not txt or strtrim(txt) == "" then return end + if ns.Aliases and ns.Aliases:Add(txt, data) then + local editBox = searchFrame and searchFrame.editBox + local current = editBox and editBox:GetText() or "" + if current ~= "" then UI:OnSearchTextChanged(current) end + end + end, + EditBoxOnEnterPressed = function(self) + local parent = self:GetParent() + if parent.button1 then parent.button1:Click() end + end, + EditBoxOnEscapePressed = function(self) + self:GetParent():Hide() + end, +} + +function UI:PromptForAlias(data) + if not data then return end + local label = data.name or "this entry" + StaticPopup_Show("EASYFIND_ADD_ALIAS", label, nil, data) +end + +-- Step through search history from the editbox. Direction +1 = older, +-- -1 = newer. Returns true if the editbox was updated, false if the +-- caller should fall through to its default key behavior (e.g. DOWN +-- past the newest entry should drop into result navigation). +function UI:NavigateSearchHistory(direction) + if not EasyFind.db then return false end + local hist = EasyFind.db.uiSearchHistory + if type(hist) ~= "table" or #hist == 0 then return false end + local editBox = searchFrame and searchFrame.editBox + if not editBox then return false end + + -- Capture the user's in-flight buffer the first time we step away + -- from index 0 so DOWN-back-to-0 restores it instead of clobbering + -- their typing. + if historyIndex == 0 and direction > 0 then + historyDraft = editBox:GetText() or "" + end + + local newIndex = historyIndex + direction + if newIndex < 0 then return false end + if newIndex > #hist then newIndex = #hist end + if newIndex == historyIndex then return false end + + historyIndex = newIndex + if newIndex == 0 then + editBox:SetText(historyDraft or "") + else + editBox:SetText(hist[newIndex] or "") + end + editBox:SetCursorPosition(#editBox:GetText()) + -- Programmatic SetText fires OnTextChanged with userInput=false, which + -- the OnTextChanged hook now ignores (so the autocomplete suffix can't + -- feed back into the search query). History nav still wants a fresh + -- result render for the recalled query, so kick it manually. + UI:OnSearchTextChanged(editBox:GetText() or "") + return true +end + function UI:OnSearchTextChanged(text) -- Suppress re-renders while SelectResult is clearing text/focus if selectingResult then return end @@ -3799,30 +4599,73 @@ function UI:OnSearchTextChanged(text) expandedContainers = {} -- Build skip set from filters so SearchUI avoids scoring/copying filtered categories. - -- Mount/Toy/Pet categories are skippable here (actual collection items). - -- The "ui" filter is handled post-search since UI entries span many categories. + -- Collection items (mounts/toys/pets/outfits/appearance sets) are + -- skipped when their own filter is off OR the parent Collections + -- toggle is off. Loot is independent. local filters = EasyFind.db.uiSearchFilters + local collectionsOff = filters and filters.collections == false local skipCategories if filters then - if filters.mounts == false or filters.toys == false or filters.pets == false or filters.outfits == false or filters.loot == false or filters.appearanceSets == false then + local mountsOff = collectionsOff or filters.mounts == false + local toysOff = collectionsOff or filters.toys == false + local petsOff = collectionsOff or filters.pets == false + local outfitsOff = collectionsOff or filters.outfits == false + local appsetsOff = collectionsOff or filters.appearanceSets == false + local lootOff = filters.loot == false + local bagsOff = filters.bags == false + local optionsOff = filters.options == false + if mountsOff or toysOff or petsOff or outfitsOff or lootOff + or appsetsOff or bagsOff or optionsOff 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 - if filters.appearanceSets == false then skipCategories["Appearance Set"] = true end + if mountsOff then skipCategories["Mount"] = true end + if toysOff then skipCategories["Toy"] = true end + if petsOff then skipCategories["Pet"] = true end + if outfitsOff then skipCategories["Outfit"] = true end + if lootOff then skipCategories["Loot"] = true end + if appsetsOff then skipCategories["Appearance Set"] = true end + if bagsOff then skipCategories["Bag"] = true end + if optionsOff then skipCategories["Game Settings"] = true end end end local results = ns.Database:SearchUI(text, skipCategories) - -- "UI Search" filter: hide results that aren't collection items or map results - if filters and filters.ui == false then + -- Inject user-defined alias hits at the front. Aliases bypass + -- bucket filters so a saved shortcut is always reachable, even if + -- the user has the underlying category turned off in the filter + -- menu. Dedupe against already-present results by data identity. + if ns.Aliases then + local aliasMatches = ns.Aliases:GetMatches(text:lower()) + if aliasMatches then + local seen = {} + for _, r in ipairs(results) do seen[r.data] = true end + for i = #aliasMatches, 1, -1 do + local hit = aliasMatches[i] + if not seen[hit.data] then + tinsert(results, 1, { data = hit.data, score = math.huge, isAlias = true }) + seen[hit.data] = true + end + end + end + end + + -- Bucket-aware UI filter: drop UI entries whose bucket + -- (ui / achievements / currencies / reputations / bags / options) is unchecked. + -- Skip the loop entirely if every bucket is enabled. + if filters and (filters.ui == false or filters.achievements == false + or filters.currencies == false or filters.reputations == false + or filters.bags == false or filters.options == false) then 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.itemID and rd.category == "Loot") or rd.transmogSetID or rd.mapSearchResult) then + -- Aliases override bucket filters: the user explicitly + -- saved this shortcut, so respecting the filter would + -- silently hide it. + if r.isAlias then filtered[#filtered + 1] = r + else + local bucket = GetUIBucket(r.data) + if not bucket or filters[bucket] ~= false then + filtered[#filtered + 1] = r + end end end results = filtered @@ -3846,7 +4689,7 @@ function UI:OnSearchTextChanged(text) elseif d.outfitID then cat = "outfits" elseif d.itemID and d.category == "Loot" then cat = "loot" elseif d.transmogSetID then cat = "appearanceSets" - else cat = "ui" + else cat = GetUIBucket(d) or "ui" end if s > (bestCatScore[cat] or 0) then bestCatScore[cat] = s end end @@ -3864,45 +4707,221 @@ function UI:OnSearchTextChanged(text) 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. - for _, entry in ipairs(hierarchical) do - if entry.isContainer then - local key = entry.name .. "_" .. (entry.depth or 0) - collapsedNodes[key] = true - end - end - - -- Group results by type: UI entries first, then collection groups, then map. - -- Each non-UI group gets a collapsible section header. - -- Reuse module-level tables to avoid per-keystroke allocations. - wipe(groupUI) - wipe(groupMounts) - wipe(groupToys) - wipe(groupPets) - wipe(groupOutfits) - wipe(groupLoot) - wipe(groupAppearanceSets) - wipe(groupMap) - for _, entry in ipairs(hierarchical) do - local d = entry.data - if d and d.mountID then - groupMounts[#groupMounts + 1] = entry - elseif d and d.toyItemID then - groupToys[#groupToys + 1] = entry - elseif d and d.petID then - 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 - elseif d and d.transmogSetID then - groupAppearanceSets[#groupAppearanceSets + 1] = entry + local hideHeaders = EasyFind.db.uiHideHeaders + local hierarchical + if hideHeaders then + -- Flat-list mode: skip path-node hierarchy and section headers, but + -- keep category clustering so mounts/toys/UI/etc. stay grouped. + -- Reuses module-level scratch tables and a recyclable entry pool to + -- keep per-keystroke allocations down. + wipe(groupUI); wipe(groupMounts); wipe(groupToys); wipe(groupPets) + wipe(groupOutfits); wipe(groupLoot); wipe(groupAppearanceSets); wipe(groupMap) + wipe(groupAchievements); wipe(groupCurrencies); wipe(groupReputations) + wipe(groupBags); wipe(groupOptions) + + for _, r in ipairs(results) do + local d = r.data + if d.mountID then + groupMounts[#groupMounts + 1] = r + elseif d.toyItemID then + groupToys[#groupToys + 1] = r + elseif d.petID then + groupPets[#groupPets + 1] = r + elseif d.outfitID then + groupOutfits[#groupOutfits + 1] = r + elseif d.itemID and d.category == "Loot" then + groupLoot[#groupLoot + 1] = r + elseif d.transmogSetID then + groupAppearanceSets[#groupAppearanceSets + 1] = r + else + local bucket = UI_BUCKET_BY_CATEGORY[d.category] + if bucket == "achievements" then + groupAchievements[#groupAchievements + 1] = r + elseif bucket == "currencies" then + groupCurrencies[#groupCurrencies + 1] = r + elseif bucket == "reputations" then + groupReputations[#groupReputations + 1] = r + elseif bucket == "bags" then + groupBags[#groupBags + 1] = r + elseif bucket == "options" then + groupOptions[#groupOptions + 1] = r + else + groupUI[#groupUI + 1] = r + end + end + end + if mapResults then + for _, r in ipairs(mapResults) do + groupMap[#groupMap + 1] = r + end + end + + -- Within-group ordering: UI sorts by path-then-name so nested + -- siblings stay adjacent; collection groups sort alphabetically + -- since their items have no shared hierarchy; map sorts by zone. + if #groupUI > 1 then tsort(groupUI, FlatPathLess) end + if #groupAchievements > 1 then tsort(groupAchievements, FlatPathLess) end + if #groupCurrencies > 1 then tsort(groupCurrencies, FlatPathLess) end + if #groupReputations > 1 then tsort(groupReputations, FlatPathLess) end + if #groupBags > 1 then tsort(groupBags, FlatNameLess) end + if #groupOptions > 1 then tsort(groupOptions, FlatPathLess) end + if #groupMounts > 1 then tsort(groupMounts, FlatNameLess) end + if #groupToys > 1 then tsort(groupToys, FlatNameLess) end + if #groupPets > 1 then tsort(groupPets, FlatNameLess) end + if #groupOutfits > 1 then tsort(groupOutfits, FlatNameLess) end + if #groupLoot > 1 then tsort(groupLoot, FlatNameLess) end + if #groupAppearanceSets > 1 then tsort(groupAppearanceSets, FlatNameLess) end + if #groupMap > 1 then tsort(groupMap, FlatMapLess) end + + wipe(flatCatGroups) + if #groupUI > 0 then + flatCatGroups[#flatCatGroups + 1] = { key = "ui", score = bestCatScore.ui or 0, group = groupUI } + end + if #groupAchievements > 0 then + flatCatGroups[#flatCatGroups + 1] = { key = "achievements", score = bestCatScore.achievements or 0, group = groupAchievements } + end + if #groupCurrencies > 0 then + flatCatGroups[#flatCatGroups + 1] = { key = "currencies", score = bestCatScore.currencies or 0, group = groupCurrencies } + end + if #groupReputations > 0 then + flatCatGroups[#flatCatGroups + 1] = { key = "reputations", score = bestCatScore.reputations or 0, group = groupReputations } + end + if #groupBags > 0 then + flatCatGroups[#flatCatGroups + 1] = { key = "bags", score = bestCatScore.bags or 0, group = groupBags } + end + if #groupOptions > 0 then + flatCatGroups[#flatCatGroups + 1] = { key = "options", score = bestCatScore.options or 0, group = groupOptions } + end + if #groupMounts > 0 then + flatCatGroups[#flatCatGroups + 1] = { key = "mounts", score = bestCatScore.mounts or 0, group = groupMounts } + end + if #groupToys > 0 then + flatCatGroups[#flatCatGroups + 1] = { key = "toys", score = bestCatScore.toys or 0, group = groupToys } + end + if #groupPets > 0 then + flatCatGroups[#flatCatGroups + 1] = { key = "pets", score = bestCatScore.pets or 0, group = groupPets } + end + if #groupOutfits > 0 then + flatCatGroups[#flatCatGroups + 1] = { key = "outfits", score = bestCatScore.outfits or 0, group = groupOutfits } + end + if #groupLoot > 0 then + flatCatGroups[#flatCatGroups + 1] = { key = "loot", score = bestCatScore.loot or 0, group = groupLoot } + end + if #groupAppearanceSets > 0 then + flatCatGroups[#flatCatGroups + 1] = { key = "appearanceSets", score = bestCatScore.appearanceSets or 0, group = groupAppearanceSets } + end + if #groupMap > 0 then + flatCatGroups[#flatCatGroups + 1] = { key = "map", score = bestCatScore.map or 0, group = groupMap } + end + tsort(flatCatGroups, CatGroupScoreDescending) + + local n = 0 + for ci = 1, #flatCatGroups do + local cat = flatCatGroups[ci] + local key = cat.key + local g = cat.group + for ri = 1, #g do + n = n + 1 + local e = flatEntries[n] + if not e then + e = {} + flatEntries[n] = e + end + local d = g[ri].data + e.name = d.name + e.depth = 0 + e.isPathNode = false + e.isMatch = true + e.isFlat = true + e.flatCatKey = key + e.data = d + end + end + for i = n + 1, #flatEntries do + flatEntries[i] = nil + end + hierarchical = flatEntries + else + + -- Pre-bucket results by category so each top-level group (UI elements, + -- achievements, currencies, reputations, plus collection sub-groups) + -- gets its own hierarchy. Splitting BEFORE BuildHierarchicalResults + -- keeps the path-node tree of, say, achievements separate from UI + -- elements, so the section headers actually correspond to disjoint + -- subtrees. Path nodes inherit their bucket from leaf entries + -- because each bucket is built from a leaf-only result list. + local prebucket_ui, prebucket_ach, prebucket_cur, prebucket_rep = {}, {}, {}, {} + local prebucket_mounts, prebucket_toys, prebucket_pets = {}, {}, {} + local prebucket_outfits, prebucket_loot, prebucket_appsets = {}, {}, {} + local prebucket_bags, prebucket_options = {}, {} + for _, r in ipairs(results) do + local d = r.data + if d.mountID then + prebucket_mounts[#prebucket_mounts + 1] = r + elseif d.toyItemID then + prebucket_toys[#prebucket_toys + 1] = r + elseif d.petID then + prebucket_pets[#prebucket_pets + 1] = r + elseif d.outfitID then + prebucket_outfits[#prebucket_outfits + 1] = r + elseif d.itemID and d.category == "Loot" then + prebucket_loot[#prebucket_loot + 1] = r + elseif d.transmogSetID then + prebucket_appsets[#prebucket_appsets + 1] = r else - groupUI[#groupUI + 1] = entry + local bucket = UI_BUCKET_BY_CATEGORY[d.category] + if bucket == "achievements" then + prebucket_ach[#prebucket_ach + 1] = r + elseif bucket == "currencies" then + prebucket_cur[#prebucket_cur + 1] = r + elseif bucket == "reputations" then + prebucket_rep[#prebucket_rep + 1] = r + elseif bucket == "bags" then + prebucket_bags[#prebucket_bags + 1] = r + elseif bucket == "options" then + prebucket_options[#prebucket_options + 1] = r + else + prebucket_ui[#prebucket_ui + 1] = r + end + end + end + + -- Reuse module-level group tables to hold the per-bucket hierarchies. + wipe(groupUI); wipe(groupAchievements); wipe(groupCurrencies); wipe(groupReputations) + wipe(groupMounts); wipe(groupToys); wipe(groupPets) + wipe(groupOutfits); wipe(groupLoot); wipe(groupAppearanceSets); wipe(groupMap) + wipe(groupBags); wipe(groupOptions) + + local function buildInto(group, bucketResults) + if #bucketResults == 0 then return end + local hier = ns.Database:BuildHierarchicalResults(bucketResults) + for _, entry in ipairs(hier) do + -- Container nodes (path-node ancestors with extra non-matching + -- children) start collapsed; the user opens them to browse. + if entry.isContainer then + local key = entry.name .. "_" .. (entry.depth or 0) + collapsedNodes[key] = true + end + group[#group + 1] = entry end end + + buildInto(groupUI, prebucket_ui) + buildInto(groupAchievements, prebucket_ach) + buildInto(groupCurrencies, prebucket_cur) + buildInto(groupReputations, prebucket_rep) + buildInto(groupBags, prebucket_bags) + buildInto(groupOptions, prebucket_options) + buildInto(groupMounts, prebucket_mounts) + buildInto(groupToys, prebucket_toys) + buildInto(groupPets, prebucket_pets) + buildInto(groupOutfits, prebucket_outfits) + buildInto(groupLoot, prebucket_loot) + buildInto(groupAppearanceSets, prebucket_appsets) + -- Keep `hierarchical` defined for the rest of the function; + -- it gets rebuilt below by appending each section in catGroups order. + hierarchical = nil + -- Map results: group by top-ancestor continent so parent/children -- nest with collapsible headers (matches MapTab's layout). A group -- with only one entry renders flat — no point wrapping a single @@ -3980,6 +4999,11 @@ function UI:OnSearchTextChanged(text) -- 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 #groupAchievements > 0 then catGroups[#catGroups + 1] = { key = "achievements", score = bestCatScore.achievements or 0 } end + if #groupCurrencies > 0 then catGroups[#catGroups + 1] = { key = "currencies", score = bestCatScore.currencies or 0 } end + if #groupReputations > 0 then catGroups[#catGroups + 1] = { key = "reputations", score = bestCatScore.reputations or 0 } end + if #groupBags > 0 then catGroups[#catGroups + 1] = { key = "bags", score = bestCatScore.bags or 0 } end + if #groupOptions > 0 then catGroups[#catGroups + 1] = { key = "options", score = bestCatScore.options 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 @@ -3999,6 +5023,21 @@ function UI:OnSearchTextChanged(text) hierarchical[#hierarchical + 1] = uiSectionHeader end for _, e in ipairs(groupUI) do hierarchical[#hierarchical + 1] = e end + elseif cat.key == "achievements" then + hierarchical[#hierarchical + 1] = achievementSectionHeader + for _, e in ipairs(groupAchievements) do hierarchical[#hierarchical + 1] = e end + elseif cat.key == "currencies" then + hierarchical[#hierarchical + 1] = currencySectionHeader + for _, e in ipairs(groupCurrencies) do hierarchical[#hierarchical + 1] = e end + elseif cat.key == "reputations" then + hierarchical[#hierarchical + 1] = reputationSectionHeader + for _, e in ipairs(groupReputations) do hierarchical[#hierarchical + 1] = e end + elseif cat.key == "bags" then + hierarchical[#hierarchical + 1] = bagsSectionHeader + for _, e in ipairs(groupBags) do hierarchical[#hierarchical + 1] = e end + elseif cat.key == "options" then + hierarchical[#hierarchical + 1] = optionsSectionHeader + for _, e in ipairs(groupOptions) 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 @@ -4058,19 +5097,23 @@ function UI:OnSearchTextChanged(text) end end - -- Prepend pinned items at the top (always visible regardless of query) + end -- end if hideHeaders / else branch + + -- Prepend pinned items at the top (always visible regardless of query). + -- In flat-headerless mode, skip the "Pinned Paths" header but still show pins + -- on top so quick-launches stay accessible. local pins = GetAllPins() if #pins > 0 then - local pinnedEntries = { - -- "Pinned Paths" collapsible header - { + local pinnedEntries = {} + if not hideHeaders then + pinnedEntries[#pinnedEntries + 1] = { isPinHeader = true, name = "Pinned Paths", depth = 0, isPathNode = true, isMatch = false, - }, - } + } + end for _, pin in ipairs(pins) do tinsert(pinnedEntries, { name = pin.name, @@ -4078,6 +5121,7 @@ function UI:OnSearchTextChanged(text) isPathNode = false, isMatch = true, isPinned = true, + isFlat = hideHeaders or nil, data = pin, }) end @@ -4152,6 +5196,19 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local indPx = theme.indentPx local padT = theme.resultsPadTop + -- Scale row icons to match leaf font height so icon top/bottom + -- align with text top/bottom instead of overflowing the cap line. + local iconScale = 1.15 + local leafFontObj = _G[theme.leafFont] + local leafFontPx = 10 + if leafFontObj and leafFontObj.GetFont then + local _, sz = leafFontObj:GetFont() + if sz and sz > 0 then leafFontPx = sz end + end + local rowIconSize = math.floor(leafFontPx * iconScale + 0.5) + if rowIconSize < 12 then rowIconSize = 12 end + if rowIconSize > (theme.iconSize or 16) then rowIconSize = theme.iconSize or 16 end + -- Apply theme backdrop to results frame resultsFrame:SetBackdrop(theme.resultsBackdrop) if theme.resultsBackdropColor then @@ -4160,8 +5217,17 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if theme.resultsBackdropBorderColor then resultsFrame:SetBackdropBorderColor(unpack(theme.resultsBackdropBorderColor)) end - local customW = EasyFind.db.uiResultsWidth - resultsFrame:SetWidth((customW and customW > 1) and customW or theme.resultsWidth) + -- Width: in rounded-theme + dropdown-below mode, the dropdown + -- silhouette merges with the bar, so it has to match the bar's + -- exact width. Otherwise honor the user's saved customW (legacy + -- floating-results behavior). + local roundedDocked = theme.searchBarRounded and not EasyFind.db.uiResultsAbove + if roundedDocked and searchFrame then + resultsFrame:SetWidth(searchFrame:GetWidth()) + else + local customW = EasyFind.db.uiResultsWidth + resultsFrame:SetWidth((customW and customW > 1) and customW or theme.resultsWidth) + end -- Apply background atlas if specified (e.g. quest log background) if not resultsFrame.bgAtlasTex then @@ -4300,9 +5366,12 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) yOffset = yOffset + 4 end - -- Reposition for theme row height + -- Reposition for theme row height. Flat-list entries are taller + -- to fit the name + path subtext stack with breathing room above + -- the name and below the path so neither bleeds into the rep bar. local padL = theme.resultsPadLeft or 10 - resultRow:SetSize(resultsFrame:GetWidth() - padL * 2 - scrollInset, rowH) + local entryRowH = entry.isFlat and (rowH + 20) or rowH + resultRow:SetSize(resultsFrame:GetWidth() - padL * 2 - scrollInset, entryRowH) resultRow:ClearAllPoints() resultRow:SetPoint("TOPLEFT", resultsFrame.scrollChild, "TOPLEFT", padL, -yOffset) @@ -4322,29 +5391,47 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end resultRow.data = data - -- Set secure action attributes for toys, mounts, and outfits + -- Set secure action attributes for toys, mounts, outfits, and macros 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("macro", nil) resultRow:SetAttribute("action", nil) + resultRow:SetAttribute("spell", nil) elseif data and data.mountID then resultRow:SetAttribute("type", "macro") resultRow:SetAttribute("macrotext", "/cancelform [form]") resultRow:SetAttribute("toy", nil) + resultRow:SetAttribute("macro", nil) resultRow:SetAttribute("action", nil) + resultRow:SetAttribute("spell", nil) elseif data and data.outfitID then -- 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("macro", nil) + resultRow:SetAttribute("macrotext", nil) + resultRow:SetAttribute("spell", nil) + elseif data and data.spellID then + -- Ability: cast via SecureActionButton. Spell name is + -- preferred (more stable across talent swaps) but ID + -- works as a fallback. + resultRow:SetAttribute("type", "spell") + resultRow:SetAttribute("spell", data.spellName or data.spellID) + resultRow:SetAttribute("toy", nil) + resultRow:SetAttribute("macro", nil) resultRow:SetAttribute("macrotext", nil) + resultRow:SetAttribute("action", nil) else resultRow:SetAttribute("type", nil) resultRow:SetAttribute("toy", nil) + resultRow:SetAttribute("macro", nil) resultRow:SetAttribute("macrotext", nil) resultRow:SetAttribute("action", nil) + resultRow:SetAttribute("spell", nil) end end resultRow.isPathNode = entry.isPathNode @@ -4567,35 +5654,94 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.isPathNode = entry.isPathNode -- Store for tooltip text -- Position icon & text (non-tab, non-pin-header rows) - if not entry.isPinHeader and not (theme.showHeaderTab and entry.isPathNode) then + if not entry.isPinHeader and not entry.isSectionHeader and not (theme.showHeaderTab and entry.isPathNode) then local indentPixels = depth * indPx resultRow.icon:ClearAllPoints() - resultRow.icon:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("LEFT", resultRow.icon, "RIGHT", 4, 0) - resultRow.text:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) - resultRow.text:SetText(entry.name) + if entry.isFlat then + -- Flat-list layout (Alfred-style): + -- icon left (vertically centered), name + path stack to its right. + -- pathSubtext has SetWordWrap(false) so long paths truncate + -- horizontally rather than wrapping into the next row. + -- For collection rows (mounts/toys/etc.) the main icon is + -- pushed to the RIGHT later in the loop to display the + -- mount/toy/pet/etc. portrait. We show flatCatIcon (the + -- filter-menu category icon) on the LEFT so the row still + -- has a visual anchor next to the name+path stack. + local catIconDef = GetFlatCategoryIcon(data) + local leftAnchor + if catIconDef then + local sz = entryRowH - 14 + if catIconDef.atlas then + resultRow.flatCatIcon:SetAtlas(catIconDef.atlas) + else + resultRow.flatCatIcon:SetTexture(catIconDef.tex) + end + resultRow.flatCatIcon:SetSize(sz, sz) + resultRow.flatCatIcon:ClearAllPoints() + resultRow.flatCatIcon:SetPoint("LEFT", resultRow, "LEFT", indentPixels + 2, 0) + resultRow.flatCatIcon:Show() + leftAnchor = resultRow.flatCatIcon + else + resultRow.flatCatIcon:Hide() + resultRow.icon:SetPoint("LEFT", resultRow, "LEFT", indentPixels + 2, 0) + leftAnchor = resultRow.icon + end - -- Style: path nodes vs leaf results, themed - if entry.isPathNode then + resultRow.text:ClearAllPoints() + resultRow.text:SetPoint("TOPLEFT", leftAnchor, "TOPRIGHT", 6, -5) + resultRow.text:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) + resultRow.text:SetText(entry.name) resultRow.text:SetFontObject(theme.pathFont) - if entry.isMatch then - resultRow.text:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) -- gold for matches + if isUnearnedCurrency then + resultRow.text:SetTextColor(0.5, 0.5, 0.5, 1.0) else - resultRow.text:SetTextColor(unpack(theme.pathColor)) + resultRow.text:SetTextColor(1.0, 1.0, 1.0, 1.0) end - elseif isUnearnedCurrency then - -- Gray out unearned currencies - resultRow.text:SetFontObject(theme.leafFont) - resultRow.text:SetTextColor(0.5, 0.5, 0.5, 1.0) - elseif entry.isMatch then - resultRow.text:SetFontObject(theme.leafFont) - resultRow.text:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) -- gold for matches + + resultRow.pathSubtext:ClearAllPoints() + resultRow.pathSubtext:SetPoint("TOPLEFT", resultRow.text, "BOTTOMLEFT", 0, -1) + resultRow.pathSubtext:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) + resultRow.pathSubtext:SetText(GetFlatSubtext(data)) + resultRow.pathSubtext:SetFontObject(theme.leafFont) + resultRow.pathSubtext:SetTextColor(0.55, 0.55, 0.55, 1.0) + resultRow.pathSubtext:Show() else - resultRow.text:SetFontObject(theme.leafFont) - resultRow.text:SetTextColor(unpack(theme.leafColor)) + resultRow.icon:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) + if resultRow.flatCatIcon then resultRow.flatCatIcon:Hide() end + + resultRow.text:ClearAllPoints() + resultRow.text:SetPoint("LEFT", resultRow.icon, "RIGHT", 4, 0) + resultRow.text:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) + resultRow.text:SetText(entry.name) + + if resultRow.pathSubtext then + resultRow.pathSubtext:Hide() + end + + -- Style: path nodes vs leaf results, themed + if entry.isPathNode then + resultRow.text:SetFontObject(theme.pathFont) + if entry.isMatch then + resultRow.text:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) -- gold for matches + else + resultRow.text:SetTextColor(unpack(theme.pathColor)) + end + elseif isUnearnedCurrency then + -- Gray out unearned currencies + resultRow.text:SetFontObject(theme.leafFont) + resultRow.text:SetTextColor(0.5, 0.5, 0.5, 1.0) + elseif entry.isMatch then + resultRow.text:SetFontObject(theme.leafFont) + resultRow.text:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) -- gold for matches + else + resultRow.text:SetFontObject(theme.leafFont) + resultRow.text:SetTextColor(unpack(theme.leafColor)) + end end + else + if resultRow.pathSubtext then resultRow.pathSubtext:Hide() end + if resultRow.flatCatIcon then resultRow.flatCatIcon:Hide() end end -- Set icon @@ -4604,13 +5750,21 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local isCurrencyLeaf = isCurrencyItem and not entry.isPathNode local isReputationLeaf = data and data.category == "Reputation" and not entry.isPathNode - if entry.isPinHeader then + if entry.isSectionHeader then + -- Section dividers: no icon, no main text. The + -- centered sectionLabelText handles the visual. + SetRowIcon(resultRow, "hidden", nil, rowIconSize) + resultRow.amountText:Hide() + if resultRow.repBar then resultRow.repBar:Hide() end + iconSet = true + + elseif entry.isPinHeader then -- Pin header: no row icon (toggle is handled by pinToggle) - SetRowIcon(resultRow, "hidden", nil, theme.iconSize) + SetRowIcon(resultRow, "hidden", nil, rowIconSize) iconSet = true elseif theme.showHeaderTab and entry.isPathNode then - SetRowIcon(resultRow, "hidden", nil, theme.iconSize) + SetRowIcon(resultRow, "hidden", nil, rowIconSize) iconSet = true elseif entry.isPathNode then @@ -4670,7 +5824,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) 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:SetSize(rowIconSize, rowIconSize) resultRow.icon:ClearAllPoints() resultRow.icon:SetPoint("RIGHT", resultRow, "RIGHT", -5, 0) resultRow.icon:Show() @@ -4678,15 +5832,37 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.amountText:ClearAllPoints() resultRow.amountText:SetPoint("RIGHT", resultRow.icon, "LEFT", -3, 0) else - SetRowIcon(resultRow, "hidden", nil, theme.iconSize) + SetRowIcon(resultRow, "hidden", nil, rowIconSize) resultRow.amountText:ClearAllPoints() resultRow.amountText:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) end - -- Anchor name text from indent to amount (no left icon, tiny buffer) - local indentPixels = depth * indPx + 4 + -- Show unified currency category icon on the LEFT (matches map AH glyph). + -- indentPixels matches the non-currency leaf calculation so the + -- currency icon lines up horizontally with normal row icons. + local indentPixels = depth * indPx + local leftAnchor + local catIconDef = FLAT_CATEGORY_ICONS.currency + if catIconDef and resultRow.flatCatIcon then + local sz = entry.isFlat and (entryRowH - 14) or rowIconSize + if catIconDef.atlas then + resultRow.flatCatIcon:SetAtlas(catIconDef.atlas) + else + resultRow.flatCatIcon:SetTexture(catIconDef.tex) + end + resultRow.flatCatIcon:SetSize(sz, sz) + resultRow.flatCatIcon:ClearAllPoints() + resultRow.flatCatIcon:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) + resultRow.flatCatIcon:Show() + leftAnchor = resultRow.flatCatIcon + end + resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) + if leftAnchor then + resultRow.text:SetPoint("LEFT", leftAnchor, "RIGHT", 4, 0) + else + resultRow.text:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) + end resultRow.text:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) iconSet = true @@ -4699,7 +5875,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) 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:SetSize(rowIconSize, rowIconSize) resultRow.icon:ClearAllPoints() resultRow.icon:SetPoint("RIGHT", resultRow, "RIGHT", rightOffset, 0) resultRow.icon:Show() @@ -4726,7 +5902,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.icon:SetVertexColor(1, 1, 1, 1) end else - SetRowIcon(resultRow, "hidden", nil, theme.iconSize) + SetRowIcon(resultRow, "hidden", nil, rowIconSize) end -- Outfit lock overlay (dashed border when locked) @@ -4773,7 +5949,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) 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:SetSize(rowIconSize, rowIconSize) resultRow.icon:ClearAllPoints() resultRow.icon:SetPoint("RIGHT", resultRow, "RIGHT", -5, 0) resultRow.icon:Show() @@ -4785,7 +5961,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.icon.outfitID = nil resultRow.icon:SetVertexColor(1, 1, 1, 1) else - SetRowIcon(resultRow, "hidden", nil, theme.iconSize) + SetRowIcon(resultRow, "hidden", nil, rowIconSize) end resultRow.amountText:Hide() resultRow.iconCooldown:Hide() @@ -4816,7 +5992,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) else resultRow.icon:SetTexture(mapIcon) end - resultRow.icon:SetSize(theme.iconSize or 16, theme.iconSize or 16) + resultRow.icon:SetSize(rowIconSize, rowIconSize) resultRow.icon:ClearAllPoints() local indentPixels = depth * indPx + 4 resultRow.icon:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) @@ -4831,6 +6007,33 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.text:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) iconSet = true + -- Reputation leaves: faction-side crest on the LEFT, rep bar on + -- the right (rendered later in the showRepBar block). + elseif not iconSet and isReputationLeaf and data and data.factionID then + local repIcon = REP_FACTION_ICONS[data.factionSide or "either"] + if repIcon then + resultRow.icon:SetTexture(nil) + resultRow.icon:SetTexture(repIcon.tex) + if repIcon.coords then + local c = repIcon.coords + resultRow.icon:SetTexCoord(c[1], c[2], c[3], c[4]) + else + resultRow.icon:SetTexCoord(0, 1, 0, 1) + end + resultRow.icon:SetVertexColor(1, 1, 1, 1) + resultRow.icon:SetSize(rowIconSize, rowIconSize) + resultRow.icon:ClearAllPoints() + local indentPixels = depth * indPx + 4 + resultRow.icon:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) + resultRow.icon:Show() + resultRow.text:ClearAllPoints() + resultRow.text:SetPoint("LEFT", resultRow.icon, "RIGHT", 4, 0) + end + resultRow.amountText:Hide() + resultRow.amountText:ClearAllPoints() + resultRow.amountText:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) + iconSet = true + else resultRow.amountText:Hide() -- Reset amount text anchor for non-currency rows @@ -4946,7 +6149,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end else -- Leaf: side-by-side by default, stack only if text truncates - SetRowIcon(resultRow, "hidden", nil, theme.iconSize) + SetRowIcon(resultRow, "hidden", nil, rowIconSize) local indentPixels = depth * indPx + 4 resultRow.repBar:ClearAllPoints() resultRow.repBar:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) @@ -4976,7 +6179,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end if not iconSet and data and data.icon then - SetRowIcon(resultRow, "file", data.icon, theme.iconSize) + SetRowIcon(resultRow, "file", data.icon, rowIconSize) iconSet = true end @@ -4986,7 +6189,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if step.portraitMenu or step.portraitMenuOption then SetPortraitTexture(resultRow.icon, "player") resultRow.icon:SetTexCoord(0, 1, 0, 1) - resultRow.icon:SetSize(theme.iconSize or 16, theme.iconSize or 16) + resultRow.icon:SetSize(rowIconSize, rowIconSize) resultRow.icon:Show() iconSet = true break @@ -5016,7 +6219,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local ulX, ulY, llX, llY, urX, urY, lrX, lrY = region:GetTexCoord() resultRow.icon:SetTexture(tex) resultRow.icon:SetTexCoord(ulX, ulY, llX, llY, urX, urY, lrX, lrY) - resultRow.icon:SetSize(theme.iconSize or 16, theme.iconSize or 16) + resultRow.icon:SetSize(rowIconSize, rowIconSize) resultRow.icon:Show() iconSet = true end @@ -5027,7 +6230,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if not iconSet then SetPortraitTexture(resultRow.icon, "player") resultRow.icon:SetTexCoord(0, 1, 0, 1) - resultRow.icon:SetSize(theme.iconSize or 16, theme.iconSize or 16) + resultRow.icon:SetSize(rowIconSize, rowIconSize) resultRow.icon:Show() iconSet = true end @@ -5043,13 +6246,93 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local texture, isAtlas = GetButtonIcon(data.buttonFrame) if texture then local kind = isAtlas and "atlas" or "file" - SetRowIcon(resultRow, kind, texture, theme.iconSize) + SetRowIcon(resultRow, kind, texture, rowIconSize) iconSet = true end end if not iconSet then - SetRowIcon(resultRow, "file", 134400, theme.iconSize) + SetRowIcon(resultRow, "file", 134400, rowIconSize) + end + + -- Flat-list icon sizing. The LEFT icon (UI/map/pin or flatCatIcon) + -- is large since it's the row's visual anchor. The RIGHT icon + -- (currency, mount, toy, pet, outfit, appearance set, loot) + -- gets a mid-sized treatment so it's recognizable without + -- dominating the row. + if entry.isFlat and resultRow.icon and resultRow.icon:IsShown() then + local d = entry.data + local rightSideIcon = d and (d.mountID or d.toyItemID or d.petID + or d.outfitID or d.transmogSetID or d.category == "Currency" + or (d.itemID and d.category == "Loot")) + if rightSideIcon then + local rightSize = entryRowH - 18 + if rightSize < (theme.iconSize or 16) then + rightSize = theme.iconSize or 16 + end + resultRow.icon:SetSize(rightSize, rightSize) + else + local flatIconSize = entryRowH - 14 + resultRow.icon:SetSize(flatIconSize, flatIconSize) + end + end + + -- Flat-mode positioning fixup: category-specific blocks above + -- (currency, mount/toy/pet, loot, map, repBar) re-anchor text using + -- LEFT (vertical center) which collapses the name+path stack. + -- Re-apply flat anchoring last so layout is consistent across + -- all categories and the path subtext is bounded by the rep bar + -- when one is shown (so it stays out of the bar's horizontal area). + if entry.isFlat then + local catShown = resultRow.flatCatIcon and resultRow.flatCatIcon:IsShown() + local d = data + local mainIconOnRight = d and (d.mountID or d.toyItemID or d.petID + or d.outfitID or d.transmogSetID or d.category == "Currency" + or (d.itemID and d.category == "Loot")) + + local leftAnchor + if catShown then + leftAnchor = resultRow.flatCatIcon + elseif not mainIconOnRight and resultRow.icon:IsShown() then + leftAnchor = resultRow.icon + end + + local rightAnchor, rightOffset + if resultRow.repBar and resultRow.repBar:IsShown() then + rightAnchor = resultRow.repBar + rightOffset = -4 + elseif mainIconOnRight and resultRow.icon:IsShown() then + rightAnchor = resultRow.icon + rightOffset = -4 + elseif resultRow.amountText and resultRow.amountText:IsShown() then + rightAnchor = resultRow.amountText + rightOffset = -4 + else + rightAnchor = resultRow + rightOffset = -8 + end + + resultRow.text:ClearAllPoints() + if leftAnchor then + resultRow.text:SetPoint("TOPLEFT", leftAnchor, "TOPRIGHT", 6, -7) + else + local flatIndent = depth * indPx + 4 + resultRow.text:SetPoint("TOPLEFT", resultRow, "TOPLEFT", flatIndent, -7) + end + if rightAnchor == resultRow then + resultRow.text:SetPoint("RIGHT", resultRow, "RIGHT", rightOffset, 0) + else + resultRow.text:SetPoint("RIGHT", rightAnchor, "LEFT", rightOffset, 0) + end + + resultRow.pathSubtext:ClearAllPoints() + resultRow.pathSubtext:SetPoint("TOPLEFT", resultRow.text, "BOTTOMLEFT", 0, -1) + if rightAnchor == resultRow then + resultRow.pathSubtext:SetPoint("RIGHT", resultRow, "RIGHT", rightOffset, 0) + else + resultRow.pathSubtext:SetPoint("RIGHT", rightAnchor, "LEFT", rightOffset, 0) + end + resultRow.pathSubtext:Show() end -- Show pin indicator for pinned entries @@ -5059,7 +6342,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.pinIcon:SetPoint("RIGHT", resultRow.text, "LEFT", 0, 0) resultRow.pinIcon:Show() -- Pinned entries during search: show path prefix in name - if data and data.path and #data.path > 0 then + -- (skipped in flat mode, where the path already shows as subtext) + if not entry.isFlat and data and data.path and #data.path > 0 then local prefix = tconcat(data.path, " > ") resultRow.text:SetText("|cff888888" .. prefix .. " >|r " .. (data.name or "")) end @@ -5106,11 +6390,16 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow:SetAttribute("type", nil) resultRow:SetAttribute("toy", nil) resultRow:SetAttribute("action", nil) + resultRow:SetAttribute("spell", nil) + resultRow:SetAttribute("macro", nil) + resultRow:SetAttribute("macrotext", nil) end resultRow.headerGrad:Hide() resultRow.headerTab:Hide() resultRow.separator:Hide() resultRow.repBar:Hide() + if resultRow.flatCatIcon then resultRow.flatCatIcon:Hide() end + if resultRow.pathSubtext then resultRow.pathSubtext:Hide() end for d = 1, MAX_DEPTH do resultRow.treeVert[d]:Hide() resultRow.treeElbow[d]:Hide() @@ -5188,12 +6477,35 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end end - -- Anchor results above or below based on setting + -- Anchor results above or below based on setting. The combined + -- silhouette (rounded-rect container) only makes sense when the + -- dropdown sits BELOW the bar; if the user has flipped to "above" + -- mode, fall back to the legacy gap-with-own-backdrop look. + local belowMode = not EasyFind.db.uiResultsAbove + local roundedTheme = GetActiveTheme().searchBarRounded resultsFrame:ClearAllPoints() - if EasyFind.db.uiResultsAbove then - resultsFrame:SetPoint("BOTTOM", searchFrame, "TOP", 0, -5) + if belowMode then + resultsFrame:SetPoint("TOP", searchFrame, "BOTTOM", 0, 0) else - resultsFrame:SetPoint("TOP", searchFrame, "BOTTOM", 0, 2) + resultsFrame:SetPoint("BOTTOM", searchFrame, "TOP", 0, -5) + end + + -- In rounded+below mode the resultsFrame backdrop is owned by + -- the container; clear its own and hide any bg atlas so the + -- unified silhouette reads as one shape. + if roundedTheme and belowMode then + resultsFrame:SetBackdrop(nil) + if resultsFrame.bgAtlasTex then resultsFrame.bgAtlasTex:Hide() end + if containerFrame then + containerFrame:ClearAllPoints() + containerFrame:SetPoint("TOPLEFT", searchFrame, "TOPLEFT", 0, 0) + containerFrame:SetPoint("TOPRIGHT", searchFrame, "TOPRIGHT", 0, 0) + containerFrame:SetPoint("BOTTOMLEFT", resultsFrame, "BOTTOMLEFT", 0, 0) + containerFrame:SetPoint("BOTTOMRIGHT", resultsFrame, "BOTTOMRIGHT", 0, 0) + -- Divider line at the bar's bottom edge: doubles as the + -- visual separator between search content and results. + ns.SetRoundedRectDivider(containerFrame, searchFrame:GetHeight(), true) + end end resultsFrame:Show() @@ -5246,6 +6558,17 @@ function UI:HideResults() if searchFrame.ClearToolbarFocus then searchFrame.ClearToolbarFocus() end if not resultsFrame then return end resultsFrame:Hide() + -- Collapse the combined container back to bar-only height: the + -- two top anchors stay pinned to the bar, the bottom snaps back + -- to the bar's BOTTOM. Without this the rounded-rect would still + -- cover the (now empty) dropdown area below. + if containerFrame then + containerFrame:ClearAllPoints() + containerFrame:SetPoint("TOPLEFT", searchFrame, "TOPLEFT", 0, 0) + containerFrame:SetPoint("TOPRIGHT", searchFrame, "TOPRIGHT", 0, 0) + containerFrame:SetPoint("BOTTOM", searchFrame, "BOTTOM", 0, 0) + ns.SetRoundedRectDivider(containerFrame, 0, false) + end if escCatcher then escCatcher:Hide() end if resultsFrame.pinSeparator then resultsFrame.pinSeparator:Hide() @@ -5276,16 +6599,17 @@ function UI:ShowPinnedItems() -- used during search, so pinned items look identical in both cases. collapsedNodes = {} expandedContainers = {} - local entries = { - -- "Pinned Paths" collapsible header - { + local hideHeaders = EasyFind.db.uiHideHeaders + local entries = {} + if not hideHeaders then + entries[#entries + 1] = { isPinHeader = true, name = "Pinned Paths", depth = 0, isPathNode = true, isMatch = false, - }, - } + } + end for _, pin in ipairs(pins) do tinsert(entries, { name = pin.name, @@ -5293,6 +6617,7 @@ function UI:ShowPinnedItems() isPathNode = false, isMatch = true, isPinned = true, + isFlat = hideHeaders or nil, data = pin, }) end @@ -5467,6 +6792,7 @@ function UI:UpdateSelectionHighlight(skipRefocus) Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) if searchFrame.StopKeyRepeat then searchFrame.StopKeyRepeat() end if wasNavigating and not skipRefocus and not searchFrame.editBox:HasFocus() then + searchFrame.editBox.blockFocus = nil searchFrame.editBox:SetFocus() end end @@ -5703,6 +7029,7 @@ function UI:SelectResult(data, forceGuide) selectingResult = false self:HideResults() + -- Transmogrification panel: load and show TransmogFrame if data.steps and data.steps[1] and data.steps[1].loadTransmog then if not TransmogFrame then @@ -5715,6 +7042,16 @@ function UI:SelectResult(data, forceGuide) return end + -- Blizzard Settings panel: open the named category directly. + -- Both fast and guide modes do the same thing here -- there's no + -- multi-step guide to walk for an in-game settings category. + if data.steps and data.steps[1] and data.steps[1].settingsCategory then + if ns.BlizzOptionsSearch then + ns.BlizzOptionsSearch:HandleStep(data.steps[1]) + end + return + end + -- Outfit: equip handled by SecureActionButton (mouse click or Enter binding). if data.outfitID then return end @@ -5798,10 +7135,34 @@ function UI:SelectResult(data, forceGuide) -- Toy: handled by SecureActionButton on mousedown (UseToyByItemID is protected) if data.toyItemID then return end - -- Pet: summon/dismiss - if data.petID then - if C_PetJournal and C_PetJournal.SummonPetByGUID then - C_PetJournal.SummonPetByGUID(data.petID) + -- Ability: cast via SecureActionButton (CastSpell is protected). The spell + -- attribute was set when the row was bound, so the click already fired the + -- cast — don't try to walk the (no-op) steps[] guide here. + if data.spellID then return end + + -- Pet: summon/dismiss. Stored petID can go stale (released, caged, + -- traded) -- look up a fresh owned GUID by speciesID first, fall + -- back to the cached petID only as a last resort. + if data.petID or data.speciesID then + if C_PetJournal then + local guid = data.petID + if data.speciesID and C_PetJournal.FindPetIDByName then + -- Walk the journal to find any owned pet of this species. + if C_PetJournal.GetNumPets and C_PetJournal.GetPetInfoByIndex then + local total = C_PetJournal.GetNumPets() + for i = 1, total or 0 do + local pid, sid, owned = C_PetJournal.GetPetInfoByIndex(i) + if owned and sid == data.speciesID then + guid = pid + break + end + end + end + end + if guid and C_PetJournal.SummonPetByGUID then + C_PetJournal.SummonPetByGUID(guid) + if guid ~= data.petID then data.petID = guid end + end end return end @@ -5814,6 +7175,16 @@ function UI:SelectResult(data, forceGuide) return end + -- Macro: open MacroFrame and select the macro slot. + if data.macroIndex then + if useFast then + UI:OpenMacroFrameAt(data.macroIndex, data.macroIsChar) + return + end + if data.steps then EasyFind:StartGuide(data) end + return + end + -- Flash label if specified (e.g., for Currency searches) if data.flashLabel then self:FlashLabel(data.flashLabel) @@ -6364,6 +7735,7 @@ function UI:Focus() -- Delay by one frame so the keybind key-press doesn't get typed C_Timer.After(0, function() if searchFrame and searchFrame:IsShown() then + searchFrame.editBox.blockFocus = nil searchFrame.editBox:SetFocus() end end) @@ -6390,6 +7762,7 @@ function UI:Show(andFocus) -- this Show() doesn't get typed into the editbox. C_Timer.After(0, function() if searchFrame:IsShown() then + searchFrame.editBox.blockFocus = nil searchFrame.editBox:SetFocus() end end) @@ -6561,6 +7934,7 @@ function UI:ToggleFocus() self:Show(false) C_Timer.After(0, function() if searchFrame and searchFrame:IsShown() then + searchFrame.editBox.blockFocus = nil searchFrame.editBox:SetFocus() end end) @@ -6612,12 +7986,28 @@ function UI:RefreshResults() end end +-- Re-run the search pipeline against the current editbox text. Use this when a +-- setting flips the structure of the result list (flat vs hierarchical), since +-- RefreshResults only re-renders the cached list. +function UI:RebuildOpenResults() + if not searchFrame or not searchFrame.editBox then return end + if not resultsFrame or not resultsFrame:IsShown() then return end + local text = searchFrame.editBox:GetText() + if text and text ~= "" then + self:OnSearchTextChanged(text) + else + self:ShowPinnedItems() + end +end + function UI:UpdateOpacity() if not searchFrame then return end local alpha = EasyFind.db.searchBarOpacity or DEFAULT_OPACITY local theme = GetActiveTheme() if theme.searchBarRounded then - ns.SetSearchBorderBgAlpha(searchFrame, alpha) + if containerFrame then + ns.SetRoundedRectBorderBgAlpha(containerFrame, alpha) + end else searchFrame:SetBackdropColor(0, 0, 0, alpha) end @@ -6631,9 +8021,13 @@ function UI:UpdateSearchBarTheme() local alpha = EasyFind.db.searchBarOpacity or DEFAULT_OPACITY if theme.searchBarRounded then searchFrame:SetBackdrop(nil) - ns.SetSearchBorderShown(searchFrame, true) - ns.ScaleSearchBorder(searchFrame, scale) - ns.SetSearchBorderBgAlpha(searchFrame, alpha) + -- Pill stays hidden; container provides the rounded silhouette. + ns.SetSearchBorderShown(searchFrame, false) + if containerFrame then + ns.SetRoundedRectBorderShown(containerFrame, true) + ns.SetRoundedRectBarHeight(containerFrame, ns.SEARCHBAR_HEIGHT * scale) + ns.SetRoundedRectBorderBgAlpha(containerFrame, alpha) + end else searchFrame:SetBackdrop({ bgFile = WHITE8x8, @@ -6644,6 +8038,7 @@ function UI:UpdateSearchBarTheme() searchFrame:SetBackdropBorderColor(1, 1, 1, 1) searchFrame:SetBackdropColor(0, 0, 0, alpha) ns.SetSearchBorderShown(searchFrame, false) + if containerFrame then ns.SetRoundedRectBorderShown(containerFrame, false) end end end @@ -6760,6 +8155,60 @@ function UI:ShowWhatsNew(version) f:Show() end +-- OPEN MACRO FRAME AT SLOT +-- Midnight's MacroFrame has no SelectionBehavior; the slot ScrollBox +-- holds buttons whose elementData is a plain integer (slot index within +-- the tab). Scroll the slot into view, then walk visible frames and +-- Click() the one whose elementData matches -- this fires the same +-- internal selection path the user's mouse click would. +function UI:OpenMacroFrameAt(macroIdx, isChar) + if C_AddOns and C_AddOns.LoadAddOn then + C_AddOns.LoadAddOn("Blizzard_MacroUI") + elseif LoadAddOn then + LoadAddOn("Blizzard_MacroUI") + end + if ShowMacroFrame then ShowMacroFrame() end + local tabIdx = isChar and 2 or 1 + local slotInTab = isChar + and (macroIdx - (MAX_ACCOUNT_MACROS or 120)) + or macroIdx + local function clickSlot() + local mf = MacroFrame + if not mf or not mf:IsShown() then return false end + local tabBtn = _G["MacroFrameTab" .. tabIdx] + if tabBtn and tabBtn.Click and (mf.selectedTab or 1) ~= tabIdx then + tabBtn:Click() + end + local sb = mf.MacroSelector and mf.MacroSelector.ScrollBox + if not sb or not sb.ForEachFrame then return false end + if sb.ScrollToElementDataIndex then + sb:ScrollToElementDataIndex(slotInTab) + end + local clicked = false + sb:ForEachFrame(function(btn) + if clicked then return true end + local ed = btn.GetElementData and btn:GetElementData() + if ed == slotInTab then + if btn.Click then btn:Click() end + clicked = true + return true + end + end) + return clicked + end + if not clickSlot() then + C_Timer.After(0, function() + if not clickSlot() then + C_Timer.After(0.1, function() + if not clickSlot() then + C_Timer.After(0.3, clickSlot) + end + end) + end + end) + end +end + -- FIRST-TIME SETUP OVERLAY -- Shown once on fresh install to let the user position & scale the search bar -- and learn about Fast vs Guide mode. Persisted account-wide via @@ -7114,6 +8563,16 @@ function UI:ShowFirstTimeSetup() StopKeybindCapture(s, action) return end + -- Reject bare SPACE/ENTER/movement keys -- they're vital + -- defaults (jump, accept, WASD) and silently overwriting + -- them on a stray keypress during capture has bricked + -- spacebar after a /reload more than once. Only bind + -- these when modified. + local hasMod = IsAltKeyDown() or IsControlKeyDown() or IsShiftKeyDown() + if not hasMod and (key == "SPACE" or key == "ENTER" + or key == "W" or key == "A" or key == "S" or key == "D") then + return + end local combo = "" if IsAltKeyDown() then combo = combo .. "ALT-" end if IsControlKeyDown() then combo = combo .. "CTRL-" end @@ -7321,8 +8780,15 @@ function UI:UpdateFontSize() local alpha = EasyFind.db.searchBarOpacity or DEFAULT_OPACITY if theme.searchBarRounded then searchFrame:SetBackdrop(nil) - ns.ScaleSearchBorder(searchFrame, scale) - ns.SetSearchBorderBgAlpha(searchFrame, alpha) + if containerFrame then + ns.SetRoundedRectBarHeight(containerFrame, barH) + ns.SetRoundedRectBorderBgAlpha(containerFrame, alpha) + -- If results are open, the divider stays at the bar's + -- new bottom (= barH). + if resultsFrame and resultsFrame:IsShown() then + ns.SetRoundedRectDivider(containerFrame, barH, true) + end + end else searchFrame:SetBackdrop({ bgFile = WHITE8x8, @@ -7337,6 +8803,9 @@ function UI:UpdateFontSize() local row = resultButtons[i] ScaleFont(row.text, theme.leafFont) ScaleFont(row.tabText, theme.pathFont) + if row.pathSubtext then + ScaleFont(row.pathSubtext, theme.leafFont) + end if row.amountText then ScaleFont(row.amountText, "GameFontNormalSmall") end diff --git a/Utils.lua b/Utils.lua index f0f6e7d..42b10a3 100644 --- a/Utils.lua +++ b/Utils.lua @@ -139,6 +139,139 @@ end --- Scroll a ScrollFrame so that the given child button is visible. --- Uses the button's top/bottom relative to the scrollChild. +-- Chrome-style inline autocomplete for an editbox. The full +-- suggestion is set as the editbox text, the cursor sits at the end +-- of what the user typed, and the trailing suggestion is selected so +-- the native caret is always visible at the end of the typed prefix. +-- Tab confirms; typing a character replaces the selection (advancing +-- the prefix); backspace deletes the selection without re-applying +-- the suggestion. +-- +-- opts.findCandidate(typedText) -> string | nil +-- Returns the autocomplete suggestion for `typedText`, or nil to +-- strip any active suggestion. +-- +-- The caller is responsible for invoking editBox.UpdateAutocomplete() +-- after its search results refresh, since the suggestion source is +-- typically derived from those results. +function Utils.AttachAutocomplete(editBox, opts) + if not editBox or not opts or type(opts.findCandidate) ~= "function" then return end + + local findCandidate = opts.findCandidate + local typedText = "" + local programmatic = false + -- Last suggestion we rendered into the editbox. Used by the smooth-typing + -- fast path to extend the highlight in-place when the new typed prefix is + -- still a prefix of the same candidate, so the suggestion doesn't blink + -- between the keystroke and the deferred ApplyAutocomplete next frame. + local currentCandidate = nil + -- Set true by the smooth-typing fast path when it has already + -- rendered the correct suggestion for the current typedText, so a + -- subsequent caller-driven ApplyAutocomplete from the search-refresh + -- timer can skip the redundant findCandidate + cursor/highlight pass + -- (which would re-run the lookup and re-paint the highlight even + -- when nothing visually needs to change). + local smoothExtendDone = false + + local function StripAutocomplete() + local current = editBox:GetText() or "" + if current == typedText then return end + programmatic = true + editBox:SetText(typedText) + editBox:SetCursorPosition(#typedText) + editBox:HighlightText(0, 0) + programmatic = false + end + + local function ApplyAutocomplete() + if programmatic or typedText == "" or not editBox:HasFocus() then + return + end + -- Smooth fast path already settled the suggestion for this + -- typedText; consume the flag and bail before findCandidate + -- runs (which would re-call into the result list and re-paint + -- the highlight even if nothing changed). + if smoothExtendDone then + smoothExtendDone = false + return + end + local candidate = findCandidate(typedText) + if not candidate or candidate:lower() == typedText:lower() then + currentCandidate = nil + StripAutocomplete() + return + end + local suffix = candidate:sub(#typedText + 1):lower() + if suffix == "" then + currentCandidate = nil + StripAutocomplete() + return + end + local fullText = typedText .. suffix + currentCandidate = fullText + if editBox:GetText() == fullText then + editBox:SetCursorPosition(#typedText) + editBox:HighlightText(#typedText, #fullText) + return + end + programmatic = true + editBox:SetText(fullText) + editBox:SetCursorPosition(#typedText) + editBox:HighlightText(#typedText, #fullText) + programmatic = false + end + + editBox:HookScript("OnTextChanged", function(self) + if programmatic then return end + local current = self:GetText() or "" + local cursorPos = self:GetCursorPosition() + local typed = current:sub(1, cursorPos) + if typed == typedText then return end + local prevLen = #typedText + typedText = typed + + -- Smooth-typing fast path only when the user is GROWING the typed + -- prefix. On a shrink (backspace) the suggestion must be torn + -- down -- re-extending it here would cancel the backspace + -- visually and the user gets stuck. + if #typedText > prevLen + and currentCandidate and typedText ~= "" + and #typedText < #currentCandidate + and typedText:lower() == currentCandidate:sub(1, #typedText):lower() then + local fullText = typedText .. currentCandidate:sub(#typedText + 1) + programmatic = true + if current ~= fullText then + self:SetText(fullText) + end + self:SetCursorPosition(#typedText) + self:HighlightText(#typedText, #fullText) + programmatic = false + smoothExtendDone = true + elseif #typedText < prevLen then + currentCandidate = nil + end + end) + + editBox:HookScript("OnEditFocusLost", StripAutocomplete) + + editBox:HookScript("OnTabPressed", function(self) + local current = self:GetText() or "" + if current == "" or current == typedText then return end + programmatic = true + self:SetCursorPosition(#current) + self:HighlightText(0, 0) + programmatic = false + typedText = current + end) + + -- Public API on the editbox so callers can drive it from their + -- search-update path without holding their own state. + editBox.UpdateAutocomplete = ApplyAutocomplete + editBox.StripAutocomplete = StripAutocomplete + editBox.GetTypedText = function() return typedText end + editBox.IsAutocompleteProgrammatic = function() return programmatic end +end + function Utils.ScrollToButton(scrollFrame, button) if not scrollFrame or not button then return end local _, _, _, _, btnOffsetY = button:GetPoint(1) @@ -176,12 +309,19 @@ ns.SEARCHBAR_FONT = "EasyFindSearchFont" 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" --- Pill-shape 9-slice cap occupies 25% of the texture on each side --- (0..0.25 left, 0.75..1.0 right) so the texture's full semicircle --- end-cap renders at any bar height. Display cap width is half the --- frame height -- a true semicircle when rendered, recomputed on --- every frame resize via OnSizeChanged. -local CAP_TEX_RATIO = 0.25 +-- 9-slice cap covers the leftmost / rightmost 37.5% of the texture +-- (0..0.375, 0.625..1) -- the texture itself has the curve in the +-- outer 64 tex px (a true semicircle, radius == half texture height) +-- followed by 32 tex px of flat top/bottom that buffer the cap/mid +-- 9-slice boundary. Cutting in the flat region lets the cap (rendered +-- at one horizontal scale) and the mid (rendered at another) join +-- without a visible kink at the curve's tangent point. +-- +-- Display cap_w = 0.75 * h. The curve occupies the OUTER 2/3 of the +-- cap (= 0.5 * h = h/2), which is the true semicircle proportion; +-- the inner 1/3 (= 0.25 * h) is flat extension before mid begins. +local CAP_TEX_RATIO = 0.375 +local CAP_DISPLAY_RATIO = 0.75 local TC_LEFT = {0, CAP_TEX_RATIO, 0, 1} local TC_MID = {CAP_TEX_RATIO, 1 - CAP_TEX_RATIO, 0, 1} local TC_RIGHT = {1 - CAP_TEX_RATIO, 1, 0, 1} @@ -199,7 +339,7 @@ local function ApplyCapWidths(frame) if not frame.searchBorder then return end local h = frame:GetHeight() or 0 if h <= 0 then return end - local capW = h / 2 + local capW = h * CAP_DISPLAY_RATIO local sb = frame.searchBorder sb.fillLeft:SetWidth(capW) sb.fillRight:SetWidth(capW) @@ -245,6 +385,148 @@ function ns.ScaleSearchBorder(frame, _scale) ApplyCapWidths(frame) end +-- --------------------------------------------------------------------------- +-- Rounded-rect 9-slice (combined search bar + results dropdown silhouette) +-- +-- Used by the container frame that wraps both. Texture is 256x256 with +-- corner radius 64 (= 25% on each side), so the 9-slice cap ratio is +-- 0.25. Corner cells stay a fixed display size (cornerSize, normally +-- h_bar / 2 so it visually matches the bar's pill caps); top/bottom +-- edges stretch horizontally, left/right edges stretch vertically, +-- center fills the rest. +-- +-- When the container's height equals 2 * cornerSize, the side edges +-- collapse to zero and the silhouette becomes a horizontal pill -- +-- same shape the bar alone wants. When the container grows downward +-- (results open), only the side edges and center stretch; the +-- corners keep their shape. That gives us the "Google search bar +-- with dropdown" look in a single primitive. +-- --------------------------------------------------------------------------- +local COMBINED_TEX_FILL = "Interface\\AddOns\\EasyFind\\Textures\\CombinedFill" +local COMBINED_TEX_BORDER = "Interface\\AddOns\\EasyFind\\Textures\\CombinedBorder" +local CR = 0.25 -- 9-slice corner ratio (cornerSize_tex / texSize) for the combined texture + +local TC9 = { + tl = {0, CR, 0, CR }, + tm = {CR, 1 - CR, 0, CR }, + tr = {1 - CR, 1, 0, CR }, + ml = {0, CR, CR, 1 - CR}, + mm = {CR, 1 - CR, CR, 1 - CR}, + mr = {1 - CR, 1, CR, 1 - CR}, + bl = {0, CR, 1 - CR, 1 }, + bm = {CR, 1 - CR, 1 - CR, 1 }, + br = {1 - CR, 1, 1 - CR, 1 }, +} + +local function CreateNineSlice(frame, layer, texPath, vertR, vertG, vertB, vertA) + local nine = {} + local function P(name) + local t = frame:CreateTexture(nil, layer) + t:SetTexture(texPath) + t:SetTexCoord(unpack(TC9[name])) + t:SetVertexColor(vertR, vertG, vertB, vertA) + return t + end + nine.tl, nine.tm, nine.tr = P("tl"), P("tm"), P("tr") + nine.ml, nine.mm, nine.mr = P("ml"), P("mm"), P("mr") + nine.bl, nine.bm, nine.br = P("bl"), P("bm"), P("br") + return nine +end + +local function AnchorNineSlice(frame, n, cornerSize) + -- Corners: pinned to each frame corner with explicit size. + n.tl:ClearAllPoints(); n.tl:SetPoint("TOPLEFT"); n.tl:SetSize(cornerSize, cornerSize) + n.tr:ClearAllPoints(); n.tr:SetPoint("TOPRIGHT"); n.tr:SetSize(cornerSize, cornerSize) + n.bl:ClearAllPoints(); n.bl:SetPoint("BOTTOMLEFT"); n.bl:SetSize(cornerSize, cornerSize) + n.br:ClearAllPoints(); n.br:SetPoint("BOTTOMRIGHT"); n.br:SetSize(cornerSize, cornerSize) + -- Top edge stretches between the two top corners; same for bottom. + n.tm:ClearAllPoints() + n.tm:SetPoint("TOPLEFT", n.tl, "TOPRIGHT") + n.tm:SetPoint("BOTTOMRIGHT", n.tr, "BOTTOMLEFT") + n.bm:ClearAllPoints() + n.bm:SetPoint("TOPLEFT", n.bl, "TOPRIGHT") + n.bm:SetPoint("BOTTOMRIGHT", n.br, "BOTTOMLEFT") + -- Left/right edges stretch between top and bottom corners. + n.ml:ClearAllPoints() + n.ml:SetPoint("TOPLEFT", n.tl, "BOTTOMLEFT") + n.ml:SetPoint("BOTTOMRIGHT", n.bl, "TOPRIGHT") + n.mr:ClearAllPoints() + n.mr:SetPoint("TOPLEFT", n.tr, "BOTTOMLEFT") + n.mr:SetPoint("BOTTOMRIGHT", n.br, "TOPRIGHT") + -- Center fills between the four edges. + n.mm:ClearAllPoints() + n.mm:SetPoint("TOPLEFT", n.tl, "BOTTOMRIGHT") + n.mm:SetPoint("BOTTOMRIGHT", n.br, "TOPLEFT") +end + +local function ApplyContainerCornerSize(frame) + if not frame.combinedBorder then return end + local h = frame.cbBarHeight or frame:GetHeight() or 0 + if h <= 0 then return end + local cornerSize = h / 2 + AnchorNineSlice(frame, frame.combinedBorder.fill, cornerSize) + AnchorNineSlice(frame, frame.combinedBorder.border, cornerSize) +end + +function ns.CreateRoundedRectBorder(frame) + local fill = CreateNineSlice(frame, "BACKGROUND", COMBINED_TEX_FILL, 0, 0, 0, 1) + local border = CreateNineSlice(frame, "ARTWORK", COMBINED_TEX_BORDER, BORDER_R, BORDER_G, BORDER_B, 1) + frame.combinedBorder = { fill = fill, border = border } + ApplyContainerCornerSize(frame) + -- Keep corner cells a constant display size as the frame resizes + -- (results open / close, theme rescale, etc). The bar height drives + -- the corner; if a caller pins it via cbBarHeight the OnSizeChanged + -- still re-anchors against that pinned value. + frame:HookScript("OnSizeChanged", ApplyContainerCornerSize) +end + +-- The container's corner radius tracks the BAR height, not the +-- container's own (which grows when results open). Callers must pin the +-- bar height via this setter once on creation and again whenever +-- fontSize / theme changes the bar's pixel height. +function ns.SetRoundedRectBarHeight(frame, h) + frame.cbBarHeight = h + ApplyContainerCornerSize(frame) +end + +function ns.SetRoundedRectBorderShown(frame, shown) + if not frame.combinedBorder then return end + for _, t in pairs(frame.combinedBorder.fill) do t:SetShown(shown) end + for _, t in pairs(frame.combinedBorder.border) do t:SetShown(shown) end +end + +function ns.SetRoundedRectBorderBgAlpha(frame, alpha) + if not frame.combinedBorder then return end + for _, t in pairs(frame.combinedBorder.fill) do t:SetAlpha(alpha) end +end + +-- A 1-px horizontal divider that runs across the inside of the +-- container at the bottom of the bar's content area. Visible only +-- when the results dropdown is open; it doubles as the "bar's +-- bottom border" the user keeps as the search/results separator. +function ns.CreateRoundedRectDivider(frame) + if frame.combinedDivider then return frame.combinedDivider end + local d = frame:CreateTexture(nil, "ARTWORK") + d:SetColorTexture(BORDER_R, BORDER_G, BORDER_B, 1) + d:SetHeight(1) + d:Hide() + frame.combinedDivider = d + return d +end + +-- Position the divider at `yOffset` below the container's top, with a +-- small inset from each side so it doesn't bleed into the rounded +-- corners. yOffset should be the bar's height (= bar's bottom edge). +function ns.SetRoundedRectDivider(frame, yOffset, shown) + local d = frame.combinedDivider + if not d then return end + local cornerSize = (frame.cbBarHeight or frame:GetHeight() or 0) / 2 + d:ClearAllPoints() + d:SetPoint("TOPLEFT", frame, "TOPLEFT", cornerSize * 0.5, -yOffset) + d:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -cornerSize * 0.5, -yOffset) + d:SetShown(shown and true or false) +end + function ns.SetSearchBorderShown(frame, shown) if not frame.searchBorder then return end local sb = frame.searchBorder diff --git a/textures/CombinedBorder.tga b/textures/CombinedBorder.tga new file mode 100644 index 0000000000000000000000000000000000000000..357216ecd3efd4af05d72c1b82c9cdf73b637105 GIT binary patch literal 262162 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 LRwr-&*5&;luo^ev literal 0 HcmV?d00001 diff --git a/textures/CombinedFill.tga b/textures/CombinedFill.tga new file mode 100644 index 0000000000000000000000000000000000000000..4e960a78ec03ae2a987ac6091624368f496e5985 GIT binary patch literal 262162 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 literal 0 HcmV?d00001 diff --git a/textures/FilterButtonCircle.tga b/textures/FilterButtonCircle.tga new file mode 100644 index 0000000000000000000000000000000000000000..468c91ce87b065cbb424505a0474ee0f34589396 GIT binary patch literal 65554 zcmeI5OKTKa7=~jmD`S#%ZVcTR z`sr->ESKfp-rf{wfUDqVumv817vMek0R91on_kmP*YrJqu+A+o&6N#+Qdy9{WoJ%o}tGD@B~VH> zl~W$TrvaV;Z|x}{tr|Jx06bQ}$M$IZT1^T_vz1hHfY|ka{#Fwof7qP@(lDvx1;nd! zu(!LT>swVQAT5(P_e`8K;Bf^9f2lVGr0Gn|-v`7u3tsj{)3-iG0ckrMV-6@T;NBl& zUZA(4%K`U3khAS~6HN{f@-*pT2q=&*ZEz*_^QR)0p99SV25FAsX1fZF>nWX zt5*SeFgED@0AsGzOWQ|pQebV6{xj|}__t|%e^{>q@?v=qIlwro$^29=T_3$d0eRBb zT2uO09rp8zvHhhu6p%N4t2?J}otyR+w*s5}_#S|=YBareBW~{%ojeL!TS|Yb-FXeX zrJ;bl3RXizU#d^^mX-pmA-)HoJP&fX4ks=5dXH9~&G(W6Y<~hg=sgtgp(X|7-HC2< zfNd9nALpsbya2<>yTxw&XFDC=|1Cfa-bK9%$it4$53tSUdc*e711fO2lYIc=jsXAc z2Q;AOuH@xNyE(wR=fJm`WA@?0DIhP;b>RQ~a6@}fktlG#9sh~-Ea=DlS&_(KHVk?C z!}N~vIP~b>?2(&kBPAaIMMzbyvll0_*Z0)~fOP)f{^#EYIcr6!8E3_VPiV%liiS z+a7)MjODq!zhYhqy8_62-QUccO$FrrmQCu;y72xNSHi3U^4^d8nzeu5`}cO~n`bWX z|1__HT><2M)xD7IDq@SddvD8cXX^^M0VG zYgyKRdi}R8-%GY-{kN_G@asQHRqQPHum32n0rKm=?D$?X-M{{;@ZYchELE|yY`*@} zum85=yO`=)|K{tzM|tkYfu)R%Ca=HG|4@7f`1~(6ycf#@^S>1S`}|LK{I8n-=@j(+ zI`I5+Kpr2N|E<^uZ0AqkJj2TQ9}E9|{@47|eg5aX{eKI+mh(Rs`v9N+J>MKS zcK-Lmf4~1jp8mIW|A!(6{QfWbyprh&AkXIIVXuvTR@0+zKH}A0eJ{61Nu3*hZC=rI1dvyi=Xgs`J6+PB z>O;P!BZ1jS<XuQ|cElVAsUt4DzyhMnxy$6mH){AutKcpIz&(tNrX??P=$9J9c4U~v9R>)BBL zh3Lm~U|{A)=Y5$!o&$Z)foQ#N%7GbFc-(g->imw-*or~1D5hF#vAR<<-L24D zzZuVf>tG!`0`Gw4x(eOYr~1|y8cSnpZ2v76Q3~OQ&OJAPerN0cX4)S=1p1u52OmJy bJ+Jke-qSi-SKDY?^`XAhXHw^2+p*<8uK+~o literal 0 HcmV?d00001 diff --git a/textures/SearchBarBorder.tga b/textures/SearchBarBorder.tga index a205bb980eb2cf06eeea523ff0b4fea3a695255d..a371346348b622621da0d7ed4eb9a268ebd928cf 100644 GIT binary patch literal 131090 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 S?YM?qORlM;uEDJ*mj45jW>Z=K literal 131090 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 Date: Tue, 5 May 2026 18:19:56 -0400 Subject: [PATCH 036/103] Wizard, settings search, fonts, scaling polish - Add tutorial Wizard.lua: 4-page onboarding (welcome/features/hotkeys/ recap), gradient gloss panel, custom rounded close X, footer banner, gilded inset, hotkey capture (search bar + map), feature tiles with hover lift and click-through detail subviews. - Add settings search (BlizzOptionsSearch): walks Blizzard category tree, captures dropdown options, exposes runtime helper for the results panel inline-edit flow. - Add Inter font system: ns.RegisterAddonFont registry, FONT_CHOICES, Options font selector. Default stays Friz Quadrata. - Theme system removed (Modern only). - Rescaler updated for unified bar + results pill: shared width, height-only corner handle, flush dock with no gap. - Filter dropdown bumped to 1.5x scale; pinned-row pin indicator 10 -> 13px; minimap icon swapped to high-res Spyglass texture (sized 14x14 to fit the standard ring). - Aliases support: registry, search integration, prior session additions for macros / abilities / bags / settings. - Assets: Textures/Spyglass.tga, Textures/SpyglassMinimap.tga, Fonts/Inter-Regular/SemiBold/Bold.ttf, cursor / text-cursor TGAs. --- .luacheckrc | 22 +- Aliases.lua | 37 + BlizzOptionsSearch.lua | 996 ++++++++- Core.lua | 124 +- Database.lua | 1009 +++++++-- EasyFind.toc | 4 +- Fonts/Inter-Bold.ttf | Bin 0 -> 420428 bytes Fonts/Inter-LICENSE.txt | 92 + Fonts/Inter-Regular.ttf | Bin 0 -> 411640 bytes Fonts/Inter-SemiBold.ttf | Bin 0 -> 419744 bytes Highlight.lua | 160 +- MapSearch.lua | 190 +- Options.lua | 130 +- Rescaler.lua | 120 +- UI.lua | 3863 +++++++++++++++++++++++----------- Utils.lua | 76 + Wizard.lua | 908 ++++++++ textures/Spyglass.tga | Bin 0 -> 262188 bytes textures/SpyglassMinimap.tga | Bin 0 -> 262188 bytes textures/cursor-blank.tga | Bin 0 -> 22 bytes textures/text-cursor.tga | Bin 0 -> 4114 bytes 21 files changed, 5977 insertions(+), 1754 deletions(-) create mode 100644 Fonts/Inter-Bold.ttf create mode 100644 Fonts/Inter-LICENSE.txt create mode 100644 Fonts/Inter-Regular.ttf create mode 100644 Fonts/Inter-SemiBold.ttf create mode 100644 Wizard.lua create mode 100644 textures/Spyglass.tga create mode 100644 textures/SpyglassMinimap.tga create mode 100644 textures/cursor-blank.tga create mode 100644 textures/text-cursor.tga diff --git a/.luacheckrc b/.luacheckrc index ae0dc49..de4bce0 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -26,14 +26,15 @@ globals = { read_globals = { -- WoW Lua extensions "bit", - "debugstack", "strsplit", "strtrim", "wipe", "hooksecurefunc", + "debugstack", "debugprofilestart", "debugprofilestop", "strsplit", "strtrim", "wipe", "hooksecurefunc", "format", "time", "date", -- Core API - "CreateFrame", "CreateFont", "CreateVector2D", - "GetTime", "GetLocale", "GetCVar", "GetCursorPosition", "GetRealmName", + "CreateFrame", "CreateFont", "CreateVector2D", "CreateColor", + "GetTime", "GetLocale", "GetCVar", "SetCVar", "GetCursorPosition", "GetRealmName", "GetAddOnMetadata", "GetAtlasInfo", "GetMinimapShape", - "GetBindingKey", "GetCurrentBindingSet", "SaveBindings", "SetBinding", + "GetBindingKey", "GetBindingAction", "GetCurrentBindingSet", "SaveBindings", "SetBinding", + "GetNumBindings", "GetBinding", "GetCategoryInfo", "GetPlayerFacing", "SetPortraitTexture", "ShowUIPanel", "HideUIPanel", "ToggleWorldMap", "ToggleDropDownMenu", "InterfaceOptions_AddCategory", "InCombatLockdown", @@ -52,6 +53,7 @@ read_globals = { "SpellBookFrame", "PlayerSpellsFrame", "CollectionsJournal", "PVEFrame", "ReputationFrame", "TokenFrame", "GroupFinderFrame", "LFGListFrame", "HelpFrame", "ClassTalentFrame", + "GameMenuFrame", "MacroFrame", "GuildMicroButton", "StoreMicroButton", "PlayerFrame", "StoreFrame", "LFDParentFrame", "RaidFinderFrame", "LFGListPVEStub", "LFGListPVPStub", @@ -60,12 +62,13 @@ read_globals = { "AchievementFrameCategories_ExpandToCategory", "AchievementFrameCategories_UpdateDataProvider", "Transmog_LoadUI", - "EncounterJournal_LoadUI", "PanelTemplates_SetTab", + "EncounterJournal_LoadUI", "EncounterJournal_DisplayInstance", + "EncounterJournal_DisplayEncounter", "PanelTemplates_SetTab", "Menu", "ScrollBoxConstants", -- C_* namespaces - "C_AddOns", "C_AchievementInfo", "C_AreaPoiInfo", "C_CurrencyInfo", "C_Item", - "C_EncounterJournal", "C_GossipInfo", "C_MajorFactions", "C_Map", + "C_AddOns", "C_AchievementInfo", "C_AreaPoiInfo", "C_CurrencyInfo", "C_EquipmentSet", "C_Item", + "C_EncounterJournal", "C_GossipInfo", "C_Heirloom", "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_TransmogCollection", "C_TransmogOutfitInfo", "C_TransmogSets", "C_VignetteInfo", @@ -73,9 +76,10 @@ read_globals = { -- UI utility functions "UIFrameFadeIn", "UIFrameFadeOut", "UIFrameFadeRemoveFrame", "AchievementFrameCategories_SelectElementData", - "UnitPopup_ShowMenu", "BattlePetToolTip_ShowLink", + "UnitPopup_ShowMenu", "BattlePetToolTip_ShowLink", "BattlePetTooltip", "GetUnitSpeed", "GetItemCooldown", "EJ_GetInstanceInfo", "UnitName", "GetItemInfoInstant", "GetItemStats", "GetSpecialization", "GetSpecializationInfo", + "GetNumTitles", "GetTitleName", "IsTitleKnown", "GetCurrentTitle", "SetCurrentTitle", "UnitClass", "GetLootSpecialization", "DressUpItemLink", "DressUpTransmogSet", "GetNumClasses", "GetClassInfo", "GetNumSpecializationsForClassID", "GetSpecializationInfoForClassID", "RAID_CLASS_COLORS", @@ -85,6 +89,8 @@ read_globals = { "EJ_GetNumLoot", "EJ_GetLootInfoByIndex", "HasAction", "PlaceAction", "PickupAction", "ClearCursor", "GetActionCooldown", "PickupSpell", "PickupItem", "PickupMacro", "C_Spell", + "GetCursorInfo", "GetMouseFoci", "GetMouseFocus", + "SetCursor", "ResetCursor", "ACCEPT", "CANCEL", "StaticPopup_Show", "C_SpellBook", "C_Container", "GetNumSpellTabs", "GetSpellTabInfo", "GetSpellBookItemInfo", "GetSpellBookItemName", "GetSpellBookItemTexture", diff --git a/Aliases.lua b/Aliases.lua index d4022b8..d731846 100644 --- a/Aliases.lua +++ b/Aliases.lua @@ -53,6 +53,16 @@ function Aliases:FindEntryByKey(key) local entry = data[i] if Aliases:GetEntryKey(entry) == key then return entry end end + -- Map results don't live in uiSearchData (they come from + -- MapSearch:SearchForUI). Fall back to the snapshot stored when + -- the alias was added so the alias hit still renders. + if EasyFind and EasyFind.db and EasyFind.db.aliases then + for _, info in pairs(EasyFind.db.aliases) do + if info.key == key and info.snapshot then + return info.snapshot + end + end + end return nil end @@ -68,10 +78,37 @@ function Aliases:Add(aliasText, data) if type(EasyFind.db.aliases) ~= "table" then EasyFind.db.aliases = {} end + -- Map results aren't in uiSearchData, so FindEntryByKey can't + -- recover them later. Snapshot the renderable fields so the alias + -- hit still works after a /reload. + local snapshot + if data.mapSearchResult then + snapshot = { + name = data.name, + nameLower = data.nameLower, + category = data.category, + icon = data.icon, + mapSearchResult = true, + mapID = data.mapID, + zoneName = data.zoneName, + pathPrefix = data.pathPrefix, + x = data.x, y = data.y, + keywords = data.keywords, + isZone = data.isZone, + zoneMapID = data.zoneMapID, + entranceMapID = data.entranceMapID, + entranceX = data.entranceX, + entranceY = data.entranceY, + entranceIcon = data.entranceIcon, + entranceCategory = data.entranceCategory, + isDungeonEntrance = data.isDungeonEntrance, + } + end EasyFind.db.aliases[slower(aliasText)] = { text = aliasText, key = key, name = data.name or aliasText, + snapshot = snapshot, } return true end diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index ff83790..159733c 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -1,11 +1,8 @@ --- Blizzard Settings panel search. Walks Settings.GetCategoryList() --- after the system frames have loaded and registers each category / --- sub-category as a searchable Database entry. Selecting a hit opens --- the Settings panel to that category via Settings.OpenToCategory. --- --- Retail (10.0.2+) replaced the old InterfaceOptions tree with a flat --- registry under Settings.*, so this file targets that API only. --- Classic builds will simply skip registration when Settings is nil. +-- Blizzard Settings panel search. Walks the live SettingsPanel +-- category tree (top-level categories AND subcategories) at register +-- time, caches each category id by name and by setting variable, then +-- registers searchable entries pointing at the cached id. Click = +-- ShowUIPanel(SettingsPanel) followed by Settings.OpenToCategory. local _, ns = ... local BlizzOptionsSearch = {} @@ -15,97 +12,907 @@ local Utils = ns.Utils local tinsert = Utils.tinsert local slower = Utils.slower local SafeAfter = Utils.SafeAfter +local pcall = pcall --- Stable category-name -> id table built when the search runs. Used --- by step handlers to translate the entry's stashed name back into --- the live category ID (which can change between sessions). -local function GetCategoryID(name) - if not Settings or not Settings.GetCategoryList then return nil end - local list = Settings.GetCategoryList() - if type(list) ~= "table" then return nil end - for _, cat in ipairs(list) do - if cat and cat.GetName and cat:GetName() == name then - if cat.GetID then return cat:GetID() end +-- Curated list of individual settings for direct search. +-- Format: { display name, CVar/variable, category name, type code, [min, max, step] } +-- type: c=checkbox, d=dropdown, s=slider +-- For sliders, min/max/step let the inline slider widget render +-- correctly. Step is the value increment per slider position. +local SETTINGS_DATA = { + -- Controls + {"Sticky Targeting","deselectOnClick","Controls","c"}, + {"Auto Dismount in Flight","autoDismountFlying","Controls","c"}, + {"Auto Clear AFK","autoClearAFK","Controls","c"}, + {"Interact On Left Click","interactOnLeftClick","Controls","c"}, + {"Open Loot Window at Mouse","lootUnderMouse","Controls","c"}, + {"Auto Loot","autoLootDefault","Controls","c"}, + {"Auto Loot Key","AUTOLOOTTOGGLE","Controls","d"}, + {"Enable Interact Key","PROXY_ENABLE_INTERACT","Controls","c"}, + {"Interact Key Sound Cue","softTargettingInteractKeySound","Controls","c"}, + {"Lock Cursor to Window","ClipCursor","Controls","c"}, + {"Invert Mouse","mouseInvertPitch","Controls","c"}, + {"Mouse Look Speed","PROXY_MOUSE_LOOK_SPEED","Controls","s",90,270,18}, + {"Water Collision","cameraWaterCollision","Controls","c"}, + {"Auto Follow Speed","PROXY_CAMERA_SPEED","Controls","s",90,270,18}, + {"Camera Following Style","cameraSmoothStyle","Controls","d"}, + {"Max Camera Distance","cameraDistanceMaxZoomFactor","Controls","s",1,2,0.1}, + {"Follow Terrain","cameraTerrainTilt","Controls","c"}, + {"Head Bob","cameraBobbing","Controls","c"}, + {"Smart Pivot","cameraPivot","Controls","c"}, + -- Interface + {"My Name","UnitNameOwn","Interface","c"}, + {"NPC Names","PROXY_NPC_NAMES","Interface","d"}, + {"Critters and Companions","UnitNameNonCombatCreatureName","Interface","c"}, + {"Friendly Players","UnitNameFriendlyPlayerName","Interface","c"}, + {"Friendly Minions","UnitNameFriendlyMinionName","Interface","c"}, + {"Enemy Players","UnitNameEnemyPlayerName","Interface","c"}, + {"Enemy Minions","UnitNameEnemyMinionName","Interface","c"}, + {"Always Show Nameplates","nameplateShowAll","Interface","c"}, + {"Enemy Unit Nameplates","nameplateShowEnemies","Interface","c"}, + {"Enemy Minion Nameplates","nameplateShowEnemyMinions","Interface","c"}, + {"Minor Enemy Nameplates","nameplateShowEnemyMinus","Interface","c"}, + {"Friendly Player Nameplates","nameplateShowFriends","Interface","c"}, + {"Friendly Minion Nameplates","nameplateShowFriendlyMinions","Interface","c"}, + {"Nameplate Motion Type","nameplateMotion","Interface","d"}, + {"Nameplate Distance","nameplateMaxDistance","Interface","s",20,41,1}, + {"Nameplate Cast Bars","nameplateShowCastBars","Interface","c"}, + {"Tutorials","showTutorials","Interface","c"}, + {"Status Text","PROXY_STATUS_TEXT","Interface","d"}, + {"Chat Bubbles","PROXY_CHAT_BUBBLES","Interface","d"}, + {"Show Helm","PROXY_SHOW_HELM","Interface","c"}, + {"Show Cloak","PROXY_SHOW_CLOAK","Interface","c"}, + {"Instant Quest Text","instantQuestText","Interface","c"}, + {"Automatic Quest Tracking","autoQuestWatch","Interface","c"}, + {"Show Free Bag Space","displayFreeBagSlots","Interface","c"}, + {"Consolidate Buffs","consolidateBuffs","Interface","c"}, + {"Hide Zone Objective Tracker","hideOutdoorWorldState","Interface","c"}, + {"Show Minimap Clock","showMinimapClock","Interface","c"}, + {"Beginner Tooltips","showNewbieTips","Interface","c"}, + {"Loading Screen Tips","showLoadingScreenTips","Interface","c"}, + {"Show Enemy Cast Bar","showTargetCastbar","Interface","c"}, + {"Dynamic Buff and Debuff Size","showDynamicBuffSize","Interface","c"}, + {"Incoming Heals for Unit Frames","unitFramesDisplayIncomingHeals","Interface","c"}, + {"Classic Guild UI","useClassicGuildUI","Interface","c"}, + {"Display Power Bars","raidFramesDisplayPowerBars","Interface","c"}, + {"Display Only Healer Power Bars","raidFramesDisplayOnlyHealerPowerBars","Interface","c"}, + {"Display Class Colors","raidFramesDisplayClassColor","Interface","c"}, + {"Display Pets","raidOptionDisplayPets","Interface","c"}, + {"Display Main Tank and Assist","raidOptionDisplayMainTankAndAssist","Interface","c"}, + {"Show Debuffs","raidFramesDisplayDebuffs","Interface","c"}, + {"Display Only Dispellable Debuffs","raidFramesDisplayOnlyDispellableDebuffs","Interface","c"}, + {"Display Health Text","raidFramesHealthText","Interface","d"}, + -- Action Bars + {"Action Bar 2","PROXY_SHOW_ACTIONBAR_2","Action Bars","c"}, + {"Action Bar 3","PROXY_SHOW_ACTIONBAR_3","Action Bars","c"}, + {"Action Bar 4","PROXY_SHOW_ACTIONBAR_4","Action Bars","c"}, + {"Action Bar 5","PROXY_SHOW_ACTIONBAR_5","Action Bars","c"}, + {"Action Bar 6","PROXY_SHOW_ACTIONBAR_6","Action Bars","c"}, + {"Action Bar 7","PROXY_SHOW_ACTIONBAR_7","Action Bars","c"}, + {"Action Bar 8","PROXY_SHOW_ACTIONBAR_8","Action Bars","c"}, + {"Show Numbers for Cooldowns","countdownForCooldowns","Action Bars","c"}, + -- Combat + {"Raid Self Highlight","PROXY_SELF_HIGHLIGHT","Combat","d"}, + {"Target of Target","showTargetOfTarget","Combat","c"}, + {"Do Not Flash Screen at Low Health","doNotFlashLowHealthWarning","Combat","c"}, + {"Loss of Control Alerts","lossOfControl","Combat","c"}, + {"Enable Floating Combat Text","enableFloatingCombatText","Combat","c"}, + {"Combat Text Float Mode","floatingCombatTextFloatMode","Combat","d"}, + {"Low Mana & Health","floatingCombatTextLowManaHealth","Combat","c"}, + {"Auras","floatingCombatTextAuras","Combat","c"}, + {"Fading Auras","floatingCombatTextAuraFade","Combat","c"}, + {"Combat State","floatingCombatTextCombatState","Combat","c"}, + {"Dodges/Parries/Misses","floatingCombatTextDodgeParryMiss","Combat","c"}, + {"Damage Reduction","floatingCombatTextDamageReduction","Combat","c"}, + {"Reputation Changes","floatingCombatTextRepChanges","Combat","c"}, + {"Reactive Spells & Abilities","floatingCombatTextReactives","Combat","c"}, + {"Friendly Healer Names","floatingCombatTextFriendlyHealers","Combat","c"}, + {"Combo Points","floatingCombatTextComboPoints","Combat","c"}, + {"Energy Gains","floatingCombatTextEnergyGains","Combat","c"}, + {"Honor Gained","floatingCombatTextHonorGains","Combat","c"}, + {"Self Cast","PROXY_SELF_CAST","Combat","d"}, + {"Self Cast Key","SELFCAST","Combat","d"}, + {"Focus Cast Key","FOCUSCAST","Combat","d"}, + {"Enable Action Targeting","PROXY_ACTION_TARGETING","Combat","c"}, + {"Target Damage","floatingCombatTextCombatDamage","Combat","c"}, + {"Periodic Damage","floatingCombatTextCombatLogPeriodicSpells","Combat","c"}, + {"Pet Damage","floatingCombatTextPetMeleeDamage","Combat","c"}, + {"Healing","floatingCombatTextCombatHealing","Combat","c"}, + {"Auto Attack/Auto Shot","autoRangedCombat","Combat","c"}, + -- Social + {"Disable Chat","PROXY_DISABLE_CHAT","Social","c"}, + {"Mature Language Filter","profanityFilter","Social","c"}, + {"Guild Member Alert","guildMemberNotify","Social","c"}, + {"Block Trades","blockTrades","Social","c"}, + {"Block Guild Invites","PROXY_BLOCK_GUILD_INVITES","Social","c"}, + {"Restrict Calendar Invites","restrictCalendarInvites","Social","c"}, + {"Block Chat Channel Invites","blockChannelInvites","Social","c"}, + {"Online Friends","showToastOnline","Social","c"}, + {"Offline Friends","showToastOffline","Social","c"}, + {"Broadcast Updates","showToastBroadcast","Social","c"}, + {"Real ID and BattleTag Friend Requests","showToastFriendRequest","Social","c"}, + {"Show Toast Window","showToastWindow","Social","c"}, + {"Chat Style","chatStyle","Social","d"}, + {"New Whispers","whisperMode","Social","d"}, + {"Chat Timestamps","showTimestamps","Social","d"}, + -- Keybindings + {"Character Specific Key Bindings","PROXY_CHARACTER_SPECIFIC_BINDINGS","Keybindings","c"}, + -- General / Accessibility + {"Show Move Pad","enableMovePad","General","c"}, + {"Minimum Character Name Size","PROXY_MINIMUM_CHARACTER_NAME_SIZE","General","s",0,64,2}, + {"Motion Sickness","PROXY_SICKNESS","General","c"}, + {"Camera Shake","PROXY_SICKNESS_SHAKE","General","d"}, + {"Cursor Size","cursorSizePreferred","General","d"}, + {"Show Target Tooltip","PROXY_TARGET_TOOLTIP","General","c"}, + {"Interact Key Icons","PROXY_INTERACT_ICONS","General","d"}, + -- Colorblind Mode + {"Enable UI Colorblind Mode","colorblindMode","Colorblind Mode","c"}, + {"Colorblind Filter","colorblindSimulator","Colorblind Mode","d"}, + {"Colorblind Strength","colorblindWeaknessFactor","Colorblind Mode","s",0,1,0.05}, + -- Subtitles + {"Cinematic Subtitles","movieSubtitle","Subtitles","c"}, + {"Subtitles Background","PROXY_MOVIE_SUBTITLE_BACKGROUND","Subtitles","d"}, + -- Graphics + {"Monitor","PROXY_PRIMARY_MONITOR","Graphics","d"}, + {"Display Mode","PROXY_DISPLAY_MODE","Graphics","d"}, + {"Window Size","PROXY_RESOLUTION","Graphics","d"}, + {"Render Scale","PROXY_RESOLUTION_RENDER_SCALE","Graphics","s",0.333,2,0.05}, + {"Vertical Sync","PROXY_VERTICAL_SYNC","Graphics","d"}, + {"Low Latency Mode","LowLatencyMode","Graphics","d"}, + {"Anti-Aliasing","PROXY_ANTIALIASING","Graphics","d"}, + {"Image-Based Techniques","PROXY_FXAA","Graphics","d"}, + {"Multisample Techniques","PROXY_MSAA","Graphics","d"}, + {"Multisample Alpha-Test","PROXY_MSAA_ALPHA","Graphics","c"}, + {"Camera FOV","PROXY_CAMERA_FOV","Graphics","s"}, + {"Triple Buffering","PROXY_TRIPLE_BUFFERING","Graphics","c"}, + {"Texture Filtering","textureFilteringMode","Graphics","d"}, + {"Ray Traced Shadows","shadowrt","Graphics","d"}, + {"Resample Quality","ResampleQuality","Graphics","d"}, + {"VRS Mode","vrsValar","Graphics","d"}, + {"Graphics API","PROXY_GRAPHICS_API","Graphics","d"}, + {"Resample Sharpness","PROXY_RESAMPLE_SHARPNESS","Graphics","s"}, + {"Contrast","Contrast","Graphics","s"}, + {"Brightness","Brightness","Graphics","s"}, + {"Gamma","Gamma","Graphics","s"}, + {"Optional GPU Features","PROXY_OPT_GPU_FEATURES","Graphics","c"}, + {"Async Resource Creation","PROXY_DEVICE_MT","Graphics","c"}, + {"Multithreaded Rendering","PROXY_CMDLIST_MT","Graphics","c"}, + {"Advanced Work Submit","PROXY_ADV_WORK_SUBMIT","Graphics","c"}, + -- Audio + {"Enable Sound","Sound_EnableAllSound","Audio","c"}, + {"Master Volume","Sound_MasterVolume","Audio","s"}, + {"Music Volume","Sound_MusicVolume","Audio","s"}, + {"Effects Volume","Sound_SFXVolume","Audio","s"}, + {"Ambience Volume","Sound_AmbienceVolume","Audio","s"}, + {"Dialog Volume","Sound_DialogVolume","Audio","s"}, + {"Enable Music","Sound_EnableMusic","Audio","c"}, + {"Loop Music","Sound_ZoneMusicNoDelay","Audio","c"}, + {"Pet Battle Music","Sound_EnablePetBattleMusic","Audio","c"}, + {"Sound Effects","Sound_EnableSFX","Audio","c"}, + {"Enable Pet Sounds","Sound_EnablePetSounds","Audio","c"}, + {"Emote Sounds","Sound_EnableEmoteSounds","Audio","c"}, + {"Enable Dialog","Sound_EnableDialog","Audio","c"}, + {"Error Speech","Sound_EnableErrorSpeech","Audio","c"}, + {"Ambient Sounds","Sound_EnableAmbience","Audio","c"}, + {"Sound in Background","Sound_EnableSoundWhenGameIsInBG","Audio","c"}, + {"Enable Reverb","Sound_EnableReverb","Audio","c"}, + {"Distance Filtering","Sound_EnablePositionalLowPassFilter","Audio","c"}, + -- Network + {"Optimize Network for Speed","disableServerNagle","Network","c"}, + {"Enable IPv6 when available","useIPv6","Network","c"}, + {"Advanced Combat Logging","advancedCombatLogging","Network","c"}, +} + +local TYPE_MAP = { c = "checkbox", d = "dropdown", s = "slider" } + +-- Resolved tables, populated once at register time. +-- categoryIDByName[lowercaseName] = catID +-- categoryIDByVariable[variable] = catID (variable -> owning category) +local categoryIDByName = {} +local categoryIDByVariable = {} + +-- Tooltip cache per variable. Filled lazily from three sources in +-- order of preference: the live SettingsPanel initializer's tooltip, +-- the Setting object's GetTooltip(), and OPTION_TOOLTIP_* globals. +local settingTooltips = {} + +-- Resolve a Blizzard OPTION_TOOLTIP_* global. Tries display name and +-- CVar name, both converted to SCREAMING_SNAKE_CASE. PROXY_ vars +-- never have tooltip globals, so skip them. +local function ResolveTooltipGlobal(displayName, cvar) + if displayName then + local fromName = "OPTION_TOOLTIP_" .. displayName:upper():gsub("[%s%-]+", "_"):gsub("[^A-Z0-9_]", "") + local tip = _G[fromName] + if type(tip) == "string" and tip ~= "" then return tip end + end + if cvar and not cvar:find("^PROXY_") then + local fromCVar = "OPTION_TOOLTIP_" .. cvar:gsub("(%l)(%u)", "%1_%2"):upper() + local tip = _G[fromCVar] + if type(tip) == "string" and tip ~= "" then return tip end + end +end + +-- Look up cached tooltip; if nothing cached yet, try the live +-- SettingsPanel initializer for this variable. Falls back to nil +-- (caller should have its own fallback like the display name). +local function GetTooltipForVariable(variable, displayName) + if not variable then return nil end + local cached = settingTooltips[variable] + if cached ~= nil then + return cached ~= false and cached or nil + end + + -- Try Setting:GetTooltip() first + if Settings and Settings.GetSetting then + local sok, settObj = pcall(Settings.GetSetting, variable) + if sok and settObj and settObj.GetTooltip then + local tok, t = pcall(settObj.GetTooltip, settObj) + if tok and type(t) == "string" and t ~= "" then + settingTooltips[variable] = t + return t + end end end + + -- Try the OPTION_TOOLTIP_* global + local fromGlobal = ResolveTooltipGlobal(displayName, variable) + if fromGlobal then + settingTooltips[variable] = fromGlobal + return fromGlobal + end + + -- Cache "no tooltip" so we don't re-walk on every hover + settingTooltips[variable] = false return nil end +BlizzOptionsSearch.GetTooltipForVariable = GetTooltipForVariable + +-- Resolve dropdown options for `variable` at click time by scanning +-- live SettingsPanel layouts. Returns a normalized array of +-- { value, label } or nil if the variable isn't a dropdown / can't be +-- enumerated. Cached per variable since layouts don't change after +-- registration. False sentinel = "we looked, none available". +local optionsByVariable = {} +local function NormalizeOptionTable(opts) + if type(opts) == "function" then + local ok, o = pcall(opts) + if not ok then return nil end + opts = o + end + if type(opts) ~= "table" then return nil end + local norm = {} + for _, o in ipairs(opts) do + if type(o) == "table" and o.value ~= nil then + local lab = o.label or o.text or o.name or tostring(o.value) + tinsert(norm, { value = o.value, label = lab }) + end + end + if #norm == 0 then return nil end + return norm +end + +local function GetOptionsForVariable(variable) + if not variable then return nil end + local cached = optionsByVariable[variable] + if cached ~= nil then + return cached ~= false and cached or nil + end + if not (SettingsPanel and SettingsPanel.GetLayout + and Settings and Settings.GetCategoryList) then + return nil + end + local found + local function scan(cat) + if found or not cat then return end + local lok, layout = pcall(SettingsPanel.GetLayout, SettingsPanel, cat) + if lok and layout and layout.GetInitializers then + local iok, inits = pcall(layout.GetInitializers, layout) + if iok and inits then + for _, init in ipairs(inits) do + local setting + if init.GetSetting then + local sok, s = pcall(init.GetSetting, init) + if sok then setting = s end + end + if not setting and init.data then setting = init.data.setting end + if setting and setting.GetVariable then + local vok, v = pcall(setting.GetVariable, setting) + if vok and v == variable then + local d = init.data + local opts = (type(d) == "table") and d.options or nil + found = NormalizeOptionTable(opts) + return + end + end + end + end + end + if cat.GetSubcategories then + local sok, subs = pcall(cat.GetSubcategories, cat) + if sok and type(subs) == "table" then + for _, sub in ipairs(subs) do scan(sub) end + end + end + end + local list = Settings.GetCategoryList() + if type(list) == "table" then + for _, cat in ipairs(list) do scan(cat) end + end + optionsByVariable[variable] = found or false + return found +end +BlizzOptionsSearch.GetOptionsForVariable = GetOptionsForVariable + +-- Walk one category and its subcategories, recording id by name and +-- (where possible) by variable. SettingsPanel exposes the layout per +-- category; the layout has GetInitializers which return setting rows. +local function CrawlCategory(cat) + if not cat or not cat.GetID or not cat.GetName then return end + local catID = cat:GetID() + local catName = cat:GetName() + if catName and catName ~= "" then + categoryIDByName[slower(catName)] = catID + end + + -- Walk initializers to discover which variables this category owns. + if SettingsPanel and SettingsPanel.GetLayout then + local lok, layout = pcall(SettingsPanel.GetLayout, SettingsPanel, cat) + if lok and layout and layout.GetInitializers then + local iok, inits = pcall(layout.GetInitializers, layout) + if iok and inits then + for _, init in ipairs(inits) do + -- Try several access paths since the mixin shape varies. + local setting + if init.GetSetting then + local sok, s = pcall(init.GetSetting, init) + if sok then setting = s end + end + if not setting and init.data then + setting = init.data.setting + end + if not setting and init.GetData then + local dok, d = pcall(init.GetData, init) + if dok and d then setting = d.setting end + end + if setting and setting.GetVariable then + local vok, v = pcall(setting.GetVariable, setting) + if vok and v then + if not categoryIDByVariable[v] then + categoryIDByVariable[v] = catID + end + -- Pull tooltip text from the initializer's + -- data.tooltip option (modern WoW stores + -- it there) if not already cached. Wrap + -- in pcall: data.tooltip can be a function + -- generator (returns string) or a string, + -- and data.options can be a function too, + -- so blind indexing raises in some clients. + if settingTooltips[v] == nil then + local tip + pcall(function() + local d = init.data + if type(d) ~= "table" then return end + if type(d.tooltip) == "string" then + tip = d.tooltip + return + end + if type(d.tooltip) == "function" then + local tt = d.tooltip() + if type(tt) == "string" then tip = tt end + return + end + local opts = d.options + if type(opts) == "table" and type(opts.tooltip) == "string" then + tip = opts.tooltip + end + end) + if not tip and setting.GetTooltip then + local tok, t = pcall(setting.GetTooltip, setting) + if tok and type(t) == "string" and t ~= "" then + tip = t + end + end + if tip and tip ~= "" then + settingTooltips[v] = tip + end + end + end + end + end + end + end + end + + -- Recurse into subcategories. + if cat.GetSubcategories then + local sok, subs = pcall(cat.GetSubcategories, cat) + if sok and type(subs) == "table" then + for _, sub in ipairs(subs) do + CrawlCategory(sub) + end + end + end +end + +-- One-time crawl of the live category tree. Idempotent: re-crawling +-- after addons register late just fills in any new entries. Each +-- CrawlCategory call is pcalled so a misbehaving category (third- +-- party addon's setting registration, weird initializer shape) can't +-- abort the entire crawl and starve SETTINGS_DATA of category ids. +local function ResolveCategoryIDs() + if Settings and Settings.GetCategoryList then + local lok, list = pcall(Settings.GetCategoryList) + if lok and type(list) == "table" then + for _, cat in ipairs(list) do + pcall(CrawlCategory, cat) + end + end + end + if SettingsPanel and SettingsPanel.GetAllCategories then + local ok, all = pcall(SettingsPanel.GetAllCategories, SettingsPanel) + if ok and type(all) == "table" then + for _, cat in ipairs(all) do + pcall(CrawlCategory, cat) + end + end + end +end + +-- Open the SettingsPanel (and load it lazily if needed). This is the +-- prerequisite for Settings.OpenToCategory navigation actually showing +-- the panel; on rare clients OpenToCategory alone doesn't trigger the +-- frame to be shown if the panel was never visible this session. +local function ShowSettings() + if not SettingsPanel then return end + if SettingsPanel:IsShown() then return end + if SettingsPanel.Open then + pcall(SettingsPanel.Open, SettingsPanel) + elseif ShowUIPanel then + pcall(ShowUIPanel, SettingsPanel) + end +end + +-- Find category id by name (case-insensitive). If we haven't crawled +-- yet, do so on demand. +local function GetCategoryID(name) + if not name or name == "" then return nil end + local cached = categoryIDByName[slower(name)] + if cached then return cached end + ResolveCategoryIDs() + return categoryIDByName[slower(name)] +end --- Open the Settings panel to a given category by name. +-- Find category id that owns a given setting variable. +local function GetCategoryIDForVariable(variable) + if not variable then return nil end + local cached = categoryIDByVariable[variable] + if cached then return cached end + ResolveCategoryIDs() + return categoryIDByVariable[variable] +end +BlizzOptionsSearch.GetCategoryIDForVariable = GetCategoryIDForVariable + +-- Open settings panel to the named category. Returns true on success. local function OpenSettingsByName(name) local id = GetCategoryID(name) if not id then return false end + ShowSettings() if Settings and Settings.OpenToCategory then - Settings.OpenToCategory(id) + pcall(Settings.OpenToCategory, id) return true end return false end BlizzOptionsSearch.OpenSettingsByName = OpenSettingsByName --- Collect name/path entries from the live Settings registry. Each --- entry stores the category and sub-category names so we can resolve --- the live category ID at click time. +-- Scroll the SettingsPanel's setting list to the row matching the +-- given variable name. Called after the category opens. +local function ScrollToSettingVariable(variable) + if not SettingsPanel then return false end + local scrollBox = SettingsPanel.Container + and SettingsPanel.Container.SettingsList + and SettingsPanel.Container.SettingsList.ScrollBox + if not scrollBox then return false end + local dp = scrollBox.GetDataProvider and scrollBox:GetDataProvider() + if not dp then return false end + + local found + if dp.FindElementDataByPredicate then + found = dp:FindElementDataByPredicate(function(elementData) + local inner = elementData and (elementData.data or elementData) + local setting = inner and inner.setting + if setting and setting.GetVariable then + return setting:GetVariable() == variable + end + return false + end) + end + if not found and dp.GetSize and dp.Find then + local sz = dp:GetSize() + for si = 1, sz do + local sdata = dp:Find(si) + local inner = sdata and (sdata.data or sdata) + local setting = inner and inner.setting + if setting and setting.GetVariable + and setting:GetVariable() == variable then + found = sdata + break + end + end + end + if found then + local alignCenter = ScrollBoxConstants and ScrollBoxConstants.AlignCenter + scrollBox:ScrollToElementData(found, alignCenter) + return true + end + return false +end +BlizzOptionsSearch.ScrollToSettingVariable = ScrollToSettingVariable + +-- Collect name/path entries. SETTINGS_DATA always produces entries +-- regardless of whether the live Settings tree is available yet (the +-- category id resolves lazily at click time). Top-level / subcategory +-- entries from Settings.GetCategoryList are added on top when the +-- registry is reachable. local function CollectEntries() local entries = {} + + -- Best-effort: resolve catIDs against the live category tree. + -- Safe to call even if SettingsPanel hasn't been opened yet — + -- it just leaves the lookup tables empty. HandleStep retries + -- on demand when the user clicks an entry. + ResolveCategoryIDs() + + -- Curated individual settings (Auto Loot, Sticky Targeting, etc.). + -- These run unconditionally so they're always searchable, even on + -- a clean install where Settings.GetCategoryList isn't ready until + -- the user opens the panel. + for si = 1, #SETTINGS_DATA do + local row = SETTINGS_DATA[si] + local name, var, catName, typeCode = row[1], row[2], row[3], row[4] + local sMin, sMax, sStep = row[5], row[6], row[7] + local nameLower = slower(name) + local catLower = slower(catName) + local resolved = TYPE_MAP[typeCode] or "other" + local kw = { "setting", "option", "config", catLower, nameLower } + local catID = GetCategoryIDForVariable(var) or GetCategoryID(catName) + tinsert(entries, { + name = name, + nameLower = nameLower, + keywords = kw, + keywordsLower = kw, + category = "Game Settings", + path = { "Game Settings", catName }, + -- Icon comes from category="Game Settings" cogwheel routing in UI.lua. + settingsCategory = catName, + settingCategoryID = catID, + settingVariable = var, + settingType = resolved, + settingMin = sMin, + settingMax = sMax, + settingStep = sStep, + steps = { + { + settingsCategory = catName, + settingCategoryID = catID, + settingVariable = var, + }, + }, + }) + end + + -- Top-level + subcategory entries from the live registry. Optional: + -- if Settings.GetCategoryList isn't ready yet, the curated entries + -- above are still present. if not Settings or not Settings.GetCategoryList then return entries end local list = Settings.GetCategoryList() if type(list) ~= "table" then return entries end + local function addCategoryEntry(cat, parentName) + if not cat or not cat.GetName then return end + local catName = cat:GetName() + if not catName or catName == "" then return end + local catID = cat.GetID and cat:GetID() + local catNameLower = slower(catName) + local kw = { "settings", "options", catNameLower } + if parentName then kw[#kw + 1] = slower(parentName) end + local entry = { + name = catName, + nameLower = catNameLower, + keywords = kw, + keywordsLower = kw, + category = "Game Settings", + -- Icon comes from category="Game Settings" cogwheel routing in UI.lua. + settingsCategory = catName, + settingCategoryID = catID, + steps = { { settingsCategory = catName, settingCategoryID = catID } }, + } + if parentName then entry.path = { "Game Settings", parentName } end + tinsert(entries, entry) + end + for _, cat in ipairs(list) do - if cat and cat.GetName then - local catName = cat:GetName() - if catName and catName ~= "" then - local catNameLower = slower(catName) - local catKw = { "settings", "options", catNameLower } - tinsert(entries, { - name = catName, - nameLower = catNameLower, - keywords = catKw, - keywordsLower = catKw, - category = "Game Settings", - icon = 134399, -- Interface\\Icons\\Trade_Engineering (gear-ish) + addCategoryEntry(cat) + if cat.GetSubcategories then + local sok, subs = pcall(cat.GetSubcategories, cat) + if sok and type(subs) == "table" then + local parentName = cat.GetName and cat:GetName() + for _, sub in ipairs(subs) do + addCategoryEntry(sub, parentName) + end + end + end + end + + return entries +end + +-- Walk WoW's binding table and emit one search entry per binding. +-- WoW exposes bindings via GetNumBindings + GetBinding(index): each +-- row is either a header (skip) or a real binding (command, category, +-- key1, key2, ...). The localized display name lives in the global +-- BINDING_NAME_; the localized category in BINDING_HEADER_. +local function CollectKeybindings() + local entries = {} + if not GetNumBindings or not GetBinding then return entries end + local n = GetNumBindings() + if not n or n == 0 then return entries end + + local currentHeader = "Other" + for i = 1, n do + local action, category = GetBinding(i) + if action and (action == "HEADER_BLANK" or action:find("^HEADER_")) then + -- Header rows: stash the localized header text; falls back + -- to the raw category if the global isn't populated. + local headerKey = "BINDING_HEADER_" .. (category or action:sub(8)) + local headerLoc = _G[headerKey] + if type(headerLoc) == "string" and headerLoc ~= "" then + currentHeader = headerLoc + elseif type(category) == "string" and category ~= "" then + currentHeader = category + end + elseif action and action ~= "" then + local nameKey = "BINDING_NAME_" .. action + local displayName = _G[nameKey] + if type(displayName) ~= "string" or displayName == "" then + displayName = action + end + local nameLower = slower(displayName) + local kw = { "keybind", "binding", "key", nameLower, slower(currentHeader) } + tinsert(entries, { + name = displayName, + nameLower = nameLower, + keywords = kw, + keywordsLower = kw, + category = "Game Settings", + path = { "Game Settings", "Keybindings", currentHeader }, + settingsCategory = "Keybindings", + settingType = "keybind", + bindingAction = action, + steps = { + { settingsCategory = "Keybindings" }, + }, + }) + end + end + return entries +end +BlizzOptionsSearch.CollectKeybindings = CollectKeybindings + +-- Inspect a category's initializers and emit one inline entry per +-- setting it owns. Returns inline entries for sliders / checkboxes / +-- dropdowns and a flag when the category had no inline-friendly +-- settings (so the caller still emits the category entry as a +-- fallback). The mixin shape varies between addons; everything is +-- pcalled so a malformed initializer doesn't abort the walk. +local function WalkCategorySettings(cat, catName, catID, pathPrefix) + local out = {} + if not (cat and SettingsPanel and SettingsPanel.GetLayout) then + return out + end + local lok, layout = pcall(SettingsPanel.GetLayout, SettingsPanel, cat) + if not lok or not layout or not layout.GetInitializers then return out end + local iok, inits = pcall(layout.GetInitializers, layout) + if not iok or not inits then return out end + + for _, init in ipairs(inits) do + local setting + if init.GetSetting then + local sok, s = pcall(init.GetSetting, init) + if sok then setting = s end + end + if not setting and init.data then setting = init.data.setting end + if not setting and init.GetData then + local dok, d = pcall(init.GetData, init) + if dok and d then setting = d.setting end + end + + if setting and setting.GetVariable then + local vok, variable = pcall(setting.GetVariable, setting) + local nok, settingName = pcall(setting.GetName, setting) + if vok and variable and nok and settingName and settingName ~= "" then + -- Detect type from initializer shape and setting metadata. + -- Slider initializers expose a SliderOptions table on + -- init.data.options; checkbox initializers have a boolean + -- variable type; everything else falls into "dropdown". + local resolvedType, sMin, sMax, sStep + local d = init.data + local opts = (type(d) == "table") and d.options or nil + if type(opts) == "function" then + local ook, o = pcall(opts) + if ook then opts = o end + end + if type(opts) == "table" and opts.minValue and opts.maxValue then + resolvedType = "slider" + sMin = opts.minValue + sMax = opts.maxValue + sStep = opts.steps or opts.stepSize or 1 + elseif setting.GetVariableType then + local tok, vtype = pcall(setting.GetVariableType, setting) + if tok and vtype == "boolean" then + resolvedType = "checkbox" + end + end + if not resolvedType then resolvedType = "dropdown" end + + -- Capture dropdown option list so the row can cycle + -- through values inline. Shape varies: array of + -- { value, label } / { value, text } / Selections-style + -- objects. We normalize to { value, label } pairs and + -- skip if the table doesn't look enumerable. + local settingOptions + if resolvedType == "dropdown" and type(opts) == "table" then + local norm = {} + for _, o in ipairs(opts) do + if type(o) == "table" and o.value ~= nil then + local lab = o.label or o.text or o.name or tostring(o.value) + tinsert(norm, { value = o.value, label = lab }) + end + end + if #norm > 0 then settingOptions = norm end + end + + local nameLower = slower(settingName) + local kw = { + "addon", "setting", "option", + nameLower, slower(catName or ""), + } + local entry = { + name = settingName, + nameLower = nameLower, + keywords = kw, + keywordsLower = kw, + category = "AddOn Settings", + path = pathPrefix, settingsCategory = catName, - -- A no-op steps[] keeps the entry in the - -- "guideable" code path; the real work happens - -- in the click handler that calls OpenSettingsByName. - steps = { { settingsCategory = catName } }, - }) - - -- Sub-categories show as their own entries with the - -- parent name in the path so search results read like - -- "Combat > Self Highlight" instead of just "Self - -- Highlight" with no context. + settingCategoryID = catID, + settingVariable = variable, + settingType = resolvedType, + settingMin = sMin, + settingMax = sMax, + settingStep = sStep, + settingOptions = settingOptions, + steps = { + { + settingsCategory = catName, + settingCategoryID = catID, + settingVariable = variable, + }, + }, + } + tinsert(out, entry) + end + end + end + return out +end + +-- True iff cat belongs to the AddOns tab. Modern Settings categories +-- expose GetCategorySet() returning a Settings.CategorySet enum value; +-- if that's missing we fall back to checking a categorySet field. +local function IsAddonCategory(cat) + if not cat then return false end + local addonSet = Settings and Settings.CategorySet and Settings.CategorySet.AddOns + local set + if cat.GetCategorySet then + local ok, s = pcall(cat.GetCategorySet, cat) + if ok then set = s end + end + if set == nil then set = cat.categorySet end + if set == nil then return false end + if addonSet ~= nil then return set == addonSet end + -- Older clients use a string or numeric tag + return set == "AddOns" or set == 2 +end + +-- Walk the AddOns tab of the SettingsPanel and emit: +-- 1. one navigable entry per addon category (opens the panel), and +-- 2. one inline entry per individual setting inside each category +-- (so addon checkboxes / sliders are toggleable from the search +-- results just like Game Options). +local function CollectAddonCategories() + local entries = {} + if not Settings then return entries end + + local seenCatIDs = {} + local function emit(cat, parentName) + if not cat or not cat.GetName then return end + if not IsAddonCategory(cat) then return end + local catID = cat.GetID and cat:GetID() + if not catID or seenCatIDs[catID] then return end + seenCatIDs[catID] = true + local catName = cat:GetName() + if not catName or catName == "" then return end + local catNameLower = slower(catName) + -- Path is rooted at " Settings" (e.g. "BugSack Settings") + -- instead of "AddOn Settings > " — the latter wastes a + -- whole row level on a constant string. For nested categories + -- the subcategory name follows: "BugSack Settings > Tooltip". + local rootName = (parentName or catName) .. " Settings" + local pathPrefix = parentName and { rootName, catName } or { rootName } + + local kw = { "addon", "settings", "options", catNameLower } + if parentName then kw[#kw + 1] = slower(parentName) end + tinsert(entries, { + name = catName, + nameLower = catNameLower, + keywords = kw, + keywordsLower = kw, + category = "AddOn Settings", + -- Top-level addon: no path (the name itself reads as the + -- addon). Subcategory: path is the parent's "X Settings". + path = parentName and { rootName } or nil, + settingsCategory = catName, + settingCategoryID = catID, + steps = { { settingsCategory = catName, settingCategoryID = catID } }, + }) + + local inline = WalkCategorySettings(cat, catName, catID, pathPrefix) + for _, e in ipairs(inline) do tinsert(entries, e) end + end + + -- Try the typed accessor first (modern WoW exposes a category-set + -- arg on GetCategoryList). Fall back to walking everything and + -- filtering via IsAddonCategory. + local gotTyped = false + if Settings.CategorySet and Settings.GetCategoryList then + local ok, list = pcall(Settings.GetCategoryList, Settings.CategorySet.AddOns) + if ok and type(list) == "table" then + gotTyped = true + for _, cat in ipairs(list) do + emit(cat, nil) if cat.GetSubcategories then - local subs = cat:GetSubcategories() - if type(subs) == "table" then + local sok, subs = pcall(cat.GetSubcategories, cat) + if sok and type(subs) == "table" then + local parentName = cat.GetName and cat:GetName() + for _, sub in ipairs(subs) do emit(sub, parentName) end + end + end + end + end + end + + if not gotTyped and SettingsPanel and SettingsPanel.GetAllCategories then + local ok, all = pcall(SettingsPanel.GetAllCategories, SettingsPanel) + if ok and type(all) == "table" then + for _, cat in ipairs(all) do + emit(cat, nil) + if cat.GetSubcategories then + local sok, subs = pcall(cat.GetSubcategories, cat) + if sok and type(subs) == "table" then + local parentName = cat.GetName and cat:GetName() for _, sub in ipairs(subs) do - if sub and sub.GetName then - local subName = sub:GetName() - if subName and subName ~= "" then - local subNameLower = slower(subName) - local subKw = { "settings", "options", subNameLower, catNameLower } - tinsert(entries, { - name = subName, - nameLower = subNameLower, - keywords = subKw, - keywordsLower = subKw, - category = "Game Settings", - path = { "Game Settings", catName }, - icon = 134399, - settingsCategory = subName, - steps = { { settingsCategory = subName } }, - }) - end - end + emit(sub, parentName) end end end end end end + return entries end +BlizzOptionsSearch.CollectAddonCategories = CollectAddonCategories -- Register the collected entries into the Database. Called once -- after PLAYER_LOGIN so Settings.* is fully populated. @@ -116,14 +923,53 @@ function BlizzOptionsSearch:Populate() for i = 1, #entries do tinsert(data, entries[i]) end + local kb = CollectKeybindings() + for i = 1, #kb do + tinsert(data, kb[i]) + end + local addonEntries = CollectAddonCategories() + for i = 1, #addonEntries do + tinsert(data, addonEntries[i]) + end + if ns.Database.ResetSearchCache then ns.Database:ResetSearchCache() end end --- Step handler: open the Settings panel to a category by name. The --- Highlight engine looks for a `settingsCategory` field on a step and --- routes here when found. +-- Step handler: open the Settings panel to the cached category id and +-- (when given) scroll to the specific setting row. function BlizzOptionsSearch:HandleStep(step) - if not step or not step.settingsCategory then return false end - return OpenSettingsByName(step.settingsCategory) + if not step then return false end + + -- Prefer the cached id baked into the entry. Fall back to live + -- lookup so old SavedVariables-pinned entries still work. + local catID = step.settingCategoryID + if not catID and step.settingVariable then + catID = GetCategoryIDForVariable(step.settingVariable) + end + if not catID and step.settingsCategory then + catID = GetCategoryID(step.settingsCategory) + end + + -- Show the panel first. OpenToCategory in modern WoW is supposed + -- to do this itself, but doing it explicitly first ensures the + -- frame is up before navigation runs. + ShowSettings() + + if catID and Settings and Settings.OpenToCategory then + if step.settingVariable then + pcall(Settings.OpenToCategory, catID, step.settingVariable) + else + pcall(Settings.OpenToCategory, catID) + end + end + + -- Belt-and-suspenders scroll: some clients accept the second arg + -- to OpenToCategory, others ignore it. Scroll manually next frame. + if step.settingVariable then + SafeAfter(0, function() ScrollToSettingVariable(step.settingVariable) end) + SafeAfter(0.1, function() ScrollToSettingVariable(step.settingVariable) end) + end + + return catID ~= nil end -- Schedule registration after PLAYER_LOGIN so Settings.GetCategoryList @@ -146,13 +992,19 @@ f:SetScript("OnEvent", function() -- seconds. Uses a name-based dedupe so we don't double up. local seen = {} for _, e in ipairs(ns.Database.uiSearchData or {}) do - if e.settingsCategory then seen[e.settingsCategory] = true end + if e.settingsCategory and not e.settingVariable then + seen[e.settingsCategory] = true + end end + ResolveCategoryIDs() local fresh = CollectEntries() for _, e in ipairs(fresh) do - if not seen[e.settingsCategory] then + -- Skip individual settings: they were already injected + -- on the first pass and CollectEntries always re-emits them. + if not e.settingVariable and not seen[e.settingsCategory] then tinsert(ns.Database.uiSearchData, e) end end + if ns.Database.ResetSearchCache then ns.Database:ResetSearchCache() end end) end) diff --git a/Core.lua b/Core.lua index 0bd5ff5..bbb4761 100644 --- a/Core.lua +++ b/Core.lua @@ -23,7 +23,7 @@ EasyFind.db = {} -- SavedVariables version. Increment when changing DB schema. -- Each migration runs once: if saved dbVersion < DB_VERSION, run all steps in order. -local DB_VERSION = 6 +local DB_VERSION = 8 -- SavedVariables defaults - new keys are auto-merged for existing users local DB_DEFAULTS = { @@ -53,16 +53,20 @@ local DB_DEFAULTS = { hideSearchBarsMaximized = true, -- Hide search bars when map is full screen (opt-in fullscreen search) localMapDirectOpen = true, -- Zone bar left-click: direct navigation (breadcrumb/guide reserved for right-click Guide) globalMapDirectOpen = true, -- Global bar left-click: direct navigation (breadcrumb/guide reserved for right-click Guide) - smartShow = false, -- Hide search bar until mouse hovers nearby + autoHide = true, -- Raycast-style: bar starts hidden; bind opens, click-out hides + smartShow = false, -- Hide search bar until mouse hovers nearby (legacy alternate to autoHide) + lockPosition = false, -- Disable drag-to-move on the search bar + tutorialDone = false, -- True once the user has finished the onboarding wizard mapSmartShow = false, -- Hide map search bars until mouse hovers nearby - resultsTheme = "Retail", -- "Classic" or "Retail" + resultsTheme = "Modern", -- legacy; only "Modern" ships right now + font = "Default", -- "Default" (Friz Quadrata) or "Inter" indicatorStyle = "EasyFind Arrow", -- Indicator texture style indicatorColor = "Yellow", -- Indicator color preset uiResultsHeight = 280, -- Visible height of UI search results panel in pixels mapResultsHeight = 168, -- Visible height of map search results panel in pixels 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 + staticOpacity = true, -- Keep opacity constant while moving (default-on with toggle/autoHide UX) 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) @@ -100,7 +104,8 @@ local DB_DEFAULTS = { mapTabFilters = { -- MapTab (unified) category filters. Applied after zones = true, -- BuildResults to gate each bucket independently instances = true, -- of the global/local DBs. - travel = true, + flightpath = false, -- Off by default: zone maps are dense with flight masters + travel = true, -- Portals, ships, zeppelins, trams (separate from flight paths) services = true, rares = true, }, @@ -109,21 +114,25 @@ local DB_DEFAULTS = { mapTabRecentCount = 3, -- Number of recent searches to keep / display (1-20) mapTabAutoExpand = true, -- Auto-expand a matched parent header to show all its world-hierarchy children alwaysShowRares = false, -- Persistent rare tracking: show active rares on map without searching - uiSearchFilters = { -- UI search category filters - ui = true, -- UI elements (excludes achievement/currency/reputation entries) - achievements = true, -- Individual achievement category entries - currencies = true, -- Individual currency entries - reputations = true, -- Individual reputation entries - collections = true, -- Parent toggle for Mounts/Toys/Pets/Outfits/Appearance Sets - mounts = false, - toys = false, - pets = false, - outfits = false, - loot = false, - appearanceSets = false, - bags = false, - options = true, - map = false, + uiSearchFilters = { -- UI search category filters (all enabled by default) + ui = true, + achievements = true, + currencies = true, + reputations = true, + collections = true, + mounts = true, + toys = true, + pets = true, + outfits = true, + heirlooms = true, + loot = true, + appearanceSets = true, + bags = true, + macros = true, + options = true, + abilities = true, + bosses = true, + map = true, }, 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.) @@ -136,7 +145,7 @@ local DB_DEFAULTS = { appearanceSetPvE = true, -- Show PvE sets (Dungeon/Raid) appearanceSetPvP = true, -- Show PvP sets uiMapSearchLocal = true, -- Map search in UI bar: true = local zone only, false = global - uiHideHeaders = false, -- Flat results list: no category headers, path shown as subtext per row + uiHideHeaders = true, -- Flat results list: no category headers, path shown as subtext per row aliases = {}, -- User-defined search aliases: { [aliasText] = { kind, id, name } } uiSearchHistory = {}, -- Shell-style search history (most recent at index 1, capped at uiSearchHistoryLimit) uiSearchHistoryLimit = 500, -- Bash HISTSIZE default @@ -189,6 +198,25 @@ local DB_MIGRATIONS = { if db.localMapDirectOpen == false then db.localMapDirectOpen = true end if db.globalMapDirectOpen == false then db.globalMapDirectOpen = true end end, + -- [6] = Default flightpath filter to off in MapTab. Pre-existing + -- mapTabFilters tables won't have the key (added when we split + -- flight masters out of the Travel bucket), so they default to nil + -- = enabled. Force false unless the user explicitly turned it on. + [6] = function(db) + if db.mapTabFilters and db.mapTabFilters.flightpath == nil then + db.mapTabFilters.flightpath = false + end + end, + -- [8] = Theme rename: "Classic" deprecated, "Retail" renamed to + -- "Modern" (the new default), and "Retail" reused for the parchment + -- variant. Existing saves on the old "Retail" or "Classic" values + -- get pointed at "Modern" so nothing changes for them visually until + -- they pick the new "Retail" themselves. + [8] = function(db) + if db.resultsTheme == "Retail" or db.resultsTheme == "Classic" then + db.resultsTheme = "Modern" + end + end, } -- Fields that are runtime-only and must not persist in SavedVariables @@ -332,10 +360,24 @@ local function OnInitialize() OpenBugReport() elseif msg == "feature" then OpenFeatureRequest() - elseif msg == "setup" then - if ns.UI then - EasyFind.db.setupComplete = nil - ns.UI:ShowFirstTimeSetup() + elseif msg == "setup" or msg == "tutorial" or msg == "wizard" or msg == "welcome" then + if ns.Wizard and ns.Wizard.Show then + EasyFind.db.tutorialDone = false + ns.Wizard:Show() + end + elseif msg == "perf" then + ns.PERF = not ns.PERF + EasyFind:Print("Perf logging " .. (ns.PERF and "ON" or "OFF")) + if ns.PERF and ns.UI then + EasyFind:Print(string.format( + "Render so far: %d skipped, %d ran", + ns.UI._renderSkips or 0, ns.UI._renderRuns or 0)) + end + elseif msg == "test" or msg == "perftest" then + if ns.Perf and ns.Perf.Run then + ns.Perf:Run() + else + EasyFind:Print("Perf module not loaded") end elseif msg == "ejdump" then local info = _G["EncounterJournalEncounterFrameInfo"] @@ -448,6 +490,24 @@ local function OnPlayerLogin() if ns.Database.PopulateDynamicBags then ns.Database:PopulateDynamicBags() end + if ns.Database.PopulateDynamicHeirlooms then + ns.Database:PopulateDynamicHeirlooms() + end + if ns.Database.PopulateDynamicTitles then + ns.Database:PopulateDynamicTitles() + end + if ns.Database.PopulateDynamicGearSets then + ns.Database:PopulateDynamicGearSets() + end + if ns.Database.PopulateDynamicBosses then + -- Defer one frame: scanning every dungeon + -- and raid boss across all expansion tiers + -- can take a noticeable beat, so let the + -- bag/ability passes settle first. + SafeAfter(0, function() + ns.Database:PopulateDynamicBosses() + end) + end SafeAfter(0, function() ns.Database:SyncTransmogSetFiltersFromUI() ns.Database:PopulateDynamicTransmogSets() @@ -527,8 +587,10 @@ eventFrame:RegisterEvent("TRANSMOG_COLLECTION_UPDATED") eventFrame:RegisterEvent("UPDATE_MACROS") eventFrame:RegisterEvent("SPELLS_CHANGED") eventFrame:RegisterEvent("BAG_UPDATE_DELAYED") +eventFrame:RegisterEvent("EQUIPMENT_SETS_CHANGED") local bagRefreshTimer local spellRefreshTimer +local gearSetRefreshTimer eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2) if event == "ADDON_LOADED" and arg1 == ADDON_NAME then OnInitialize() @@ -590,6 +652,14 @@ eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2) ns.Database:PopulateDynamicBags() end end) + elseif event == "EQUIPMENT_SETS_CHANGED" then + if gearSetRefreshTimer then gearSetRefreshTimer:Cancel() end + gearSetRefreshTimer = C_Timer.NewTimer(0.3, function() + gearSetRefreshTimer = nil + if ns.Database and ns.Database.PopulateDynamicGearSets then + ns.Database:PopulateDynamicGearSets() + end + end) elseif event == "PLAYER_LOGOUT" then -- Strip runtime-only fields before SavedVariables serialization if EasyFindDB then @@ -756,8 +826,8 @@ local function CreateMinimapButton() background:SetPoint("CENTER") local icon = mmBtn:CreateTexture(nil, "ARTWORK") - icon:SetSize(18, 18) - icon:SetTexture(136460) + icon:SetSize(14, 14) + icon:SetTexture("Interface\\AddOns\\EasyFind\\Textures\\SpyglassMinimap") icon:SetPoint("CENTER") mmBtn:SetHighlightTexture(136477) diff --git a/Database.lua b/Database.lua index 31bcd47..a12752e 100644 --- a/Database.lua +++ b/Database.lua @@ -456,6 +456,15 @@ local OUTFIT_PROTO = { } local OUTFIT_MT = { __index = OUTFIT_PROTO } +local HEIRLOOM_PROTO = { + keywords = {"heirloom"}, + keywordsLower = {"heirloom"}, + category = "Heirloom", + path = {}, + steps = {}, +} +local HEIRLOOM_MT = { __index = HEIRLOOM_PROTO } + local LOOT_PROTO = { category = "Loot", path = {}, @@ -471,6 +480,24 @@ local TRANSMOG_SET_PROTO = { } local TRANSMOG_SET_MT = { __index = TRANSMOG_SET_PROTO } +local TITLE_PROTO = { + keywords = {"title"}, + keywordsLower = {"title"}, + category = "Title", + path = {}, + steps = {}, +} +local TITLE_MT = { __index = TITLE_PROTO } + +local GEAR_SET_PROTO = { + keywords = {"gear", "set", "equipment", "equip", "loadout"}, + keywordsLower = {"gear", "set", "equipment", "equip", "loadout"}, + category = "Gear Set", + path = {}, + steps = {}, +} +local GEAR_SET_MT = { __index = GEAR_SET_PROTO } + -- Map equip location strings to user-friendly search keywords local SLOT_KEYWORDS = { INVTYPE_HEAD = {"helm", "helmet", "head"}, @@ -864,6 +891,117 @@ function Database:PopulateDynamicPets() if C_PetJournal.SetSearchFilter then C_PetJournal.SetSearchFilter(savedString) end end +-- Called after PLAYER_LOGIN when C_Heirloom is available. +-- Scans the heirloom catalog and injects any owned heirloom into the +-- search database. Click handler creates the heirloom item in the +-- player's bags via C_Heirloom.CreateHeirloom. +function Database:PopulateDynamicHeirlooms() + if not C_Heirloom or not C_Heirloom.GetHeirloomItemIDs then return end + + for i = #uiSearchData, 1, -1 do + if uiSearchData[i].category == "Heirloom" then + tremove(uiSearchData, i) + end + end + + local ids = C_Heirloom.GetHeirloomItemIDs() + if type(ids) ~= "table" then return end + + local hasHeirloom = C_Heirloom.PlayerHasHeirloom + local getInfo = C_Heirloom.GetHeirloomInfo + if not getInfo then return end + + local getItemIcon = C_Item and C_Item.GetItemIconByID + for _, itemID in ipairs(ids) do + local owned = (not hasHeirloom) or hasHeirloom(itemID) + if owned then + local name, _, _, icon = getInfo(itemID) + if (not icon or icon == 0) and getItemIcon then + icon = getItemIcon(itemID) + end + if name and name ~= "" then + uiSearchData[#uiSearchData + 1] = setmetatable({ + name = name, + nameLower = slower(name), + icon = icon, + heirloomItemID = itemID, + }, HEIRLOOM_MT) + end + end + end + if self.ResetSearchCache then self:ResetSearchCache() end +end + +-- Scan known character titles and inject as search entries. Click on a +-- title row sets it as the current title via SetCurrentTitle. Titles +-- come back from the API with a "%s" placeholder for the player's +-- name; we strip it for display so the row reads "the Insane" rather +-- than "%s the Insane". +function Database:PopulateDynamicTitles() + local getNum = GetNumTitles + local getName = GetTitleName + local isKnown = IsTitleKnown + if not getNum or not getName or not isKnown then return end + + for i = #uiSearchData, 1, -1 do + if uiSearchData[i].category == "Title" then + tremove(uiSearchData, i) + end + end + + local total = getNum() + if not total or total <= 0 then return end + + for titleID = 1, total do + if isKnown(titleID) then + local raw = getName(titleID) + if raw and raw ~= "" then + local display = raw:gsub("%%s", ""):gsub("^%s+", ""):gsub("%s+$", "") + if display == "" then display = raw end + uiSearchData[#uiSearchData + 1] = setmetatable({ + name = display, + titleID = titleID, + nameLower = slower(display), + }, TITLE_MT) + end + end + end + if self.ResetSearchCache then self:ResetSearchCache() end +end + +-- Scan the player's saved Equipment Manager gear sets and inject as +-- search entries. Click on a row equips the set via +-- C_EquipmentSet.UseEquipmentSet (no protected-frame issues outside +-- combat). Per-set icons come from the saved iconFileID. +function Database:PopulateDynamicGearSets() + if not C_EquipmentSet or not C_EquipmentSet.GetEquipmentSetIDs then return end + + for i = #uiSearchData, 1, -1 do + if uiSearchData[i].category == "Gear Set" then + tremove(uiSearchData, i) + end + end + + local ids = C_EquipmentSet.GetEquipmentSetIDs() + if type(ids) ~= "table" then return end + + local getInfo = C_EquipmentSet.GetEquipmentSetInfo + if not getInfo then return end + + for _, setID in ipairs(ids) do + local name, iconFileID = getInfo(setID) + if name and name ~= "" then + uiSearchData[#uiSearchData + 1] = setmetatable({ + name = name, + icon = iconFileID, + gearSetID = setID, + nameLower = slower(name), + }, GEAR_SET_MT) + end + end + if self.ResetSearchCache then self:ResetSearchCache() 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() @@ -1450,13 +1588,39 @@ function Database:PopulateDynamicAbilities() local subName = itemInfo.subName local displayName = (subName and subName ~= "") and (name .. " (" .. subName .. ")") or name local nameLower = slower(displayName) - local kw = { "ability", "spell", "cast", slower(name) } + -- "ability" / "abilities" let multi-word queries scope + -- to abilities ("feral abilities" matches all spec + -- abilities for that line). "spell" / "cast" / similar + -- generics are deliberately omitted: they used to drag + -- every ability into single-letter-typo searches via + -- fuzzy matching. The "ability"/"abilities" pair is + -- safer because the only nearby word ("agility") is + -- in FUZZY_BLOCKLIST. + -- Spec / skill-line name (e.g. "Feral", "Restoration") + -- is included so multi-word queries like "feral + -- abilities" can scope to a spec. + local kw = { slower(name), "ability", "abilities" } + if lineInfo and lineInfo.name and lineInfo.name ~= "" then + kw[#kw + 1] = slower(lineInfo.name) + end + -- offSpecID > 0 means this line belongs to a non-active + -- specialization (the greyed-out tabs in the spellbook). + -- We still index the spells so users can search across all + -- specs, but mark them so the row renderer can desaturate + -- the icon and dim the text the way the spellbook does. + local isOffSpec = lineInfo and lineInfo.offSpecID + and lineInfo.offSpecID ~= 0 uiSearchData[#uiSearchData + 1] = { name = displayName, nameLower = nameLower, keywords = kw, keywordsLower = kw, category = "Ability", + -- Spec / class skill-line name ("Feral", "Druid", "Restoration"). + -- Used by the row renderer to show "Feral Ability" subtext + -- instead of the bare "Ability" category label. + treeName = lineInfo and lineInfo.name, + isOffSpec = isOffSpec, icon = itemInfo.iconID, spellID = itemInfo.actionID, spellName = name, @@ -1470,6 +1634,148 @@ function Database:PopulateDynamicAbilities() end end +-- Common community abbreviations for dungeons/raids whose initials +-- skip non-leading letters (e.g. "BWL" picks the W from blackWing) so +-- the standard initials/prefix scoring can't reach them. Listed as +-- per-instance keyword aliases — typing "bwl" gets the same 2-3 char +-- exact-match boost (140) that "icc" already gets via the prefix path. +-- Keys are lowercased instance names returned by EJ_GetInstanceByIndex. +-- Exposed on ns so MapSearch can inject the same aliases onto its +-- dungeon-entrance POIs and the two surfaces stay in sync. +local INSTANCE_ABBRS = { + ["blackwing lair"] = {"bwl"}, + ["blackrock depths"] = {"brd"}, + ["blackfathom deeps"] = {"bfd"}, + ["ragefire chasm"] = {"rfc"}, + ["razorfen downs"] = {"rfd"}, + ["razorfen kraul"] = {"rfk"}, + ["scarlet monastery"] = {"sm"}, + ["scarlet halls"] = {"sm"}, + ["shadowfang keep"] = {"sfk"}, + ["zul'farrak"] = {"zf"}, + ["wailing caverns"] = {"wc"}, + ["icecrown citadel"] = {"icc"}, + ["black temple"] = {"bt"}, + ["naxxramas"] = {"nax", "naxx"}, +} +ns.INSTANCE_ABBRS = INSTANCE_ABBRS + +-- Inject one entry per dungeon/raid boss across every expansion tier. +-- Click navigates the Encounter Journal to that boss. Icon is the +-- boss's first creature portrait (EJ_GetCreatureInfo[5]) so results +-- look like the EJ's own boss list. +function Database:PopulateDynamicBosses() + -- Strip prior pass so re-runs don't double up. + for i = #uiSearchData, 1, -1 do + if uiSearchData[i].category == "Boss" then + tremove(uiSearchData, i) + end + end + if self.ResetSearchCache then self:ResetSearchCache() end + + if not EncounterJournal then + EncounterJournal_LoadUI() + end + + local getNumTiers = EJ("GetNumTiers") or _G.EJ_GetNumTiers + local getCurrentTier = EJ("GetCurrentTier") or _G.EJ_GetCurrentTier + local selectTier = EJ("SelectTier") or _G.EJ_SelectTier + local getInstanceByIdx = EJ("GetInstanceByIndex") + local selectInstance = EJ("SelectInstance") + local getEncounterByIdx = EJ("GetEncounterInfoByIndex") + local getCreatureInfo = EJ("GetCreatureInfo") or _G.EJ_GetCreatureInfo + if not getInstanceByIdx or not getEncounterByIdx or not selectTier then + return + end + + -- Suppress EJ UI events during the scan so opening the journal + -- mid-scan can't fight us. Restore at the end. + local ejFrame = _G["EncounterJournal"] + local savedOnEvent + if ejFrame then + savedOnEvent = ejFrame:GetScript("OnEvent") + ejFrame:SetScript("OnEvent", nil) + end + + local savedTier = getCurrentTier and getCurrentTier() + local numTiers = (getNumTiers and getNumTiers()) or 10 + + for tier = 1, numTiers do + selectTier(tier) + for _, isRaid in ipairs({ false, true }) do + local idx = 1 + while true do + local instID, instName = getInstanceByIdx(idx, isRaid) + if not instID then break end + if selectInstance then selectInstance(instID) end + + local encIdx = 1 + while true do + local encName, _, encID = getEncounterByIdx(encIdx) + if not encName then break end + + local nameLower = slower(encName) + local instLower = slower(instName or "") + -- Keywords intentionally exclude generic "raid"/"dungeon" + -- so typing those alone doesn't flood with every boss. + -- The instance name + "boss" let " boss" queries + -- match (e.g. "icc boss" -> all Icecrown Citadel bosses). + -- SearchUI further gates instance-keyword matches behind + -- the presence of "boss" in the query, so plain "icc" + -- still won't flood with bosses either. + local kw = { instLower, "boss", "bosses" } + local abbrs = INSTANCE_ABBRS[instLower] + if abbrs then + for ai = 1, #abbrs do + kw[#kw + 1] = abbrs[ai] + end + end + + -- Pull the first creature's portrait icon if EJ exposes it. + local icon + if getCreatureInfo then + local ok, _, _, _, _, iconImage = pcall(getCreatureInfo, 1, encID) + if ok and iconImage and iconImage ~= 0 then + icon = iconImage + end + end + + uiSearchData[#uiSearchData + 1] = { + name = encName, + nameLower = nameLower, + keywords = kw, + keywordsLower = kw, + category = "Boss", + icon = icon, + encounterID = encID, + instanceID = instID, + instanceName = instName, + instanceNameLower = instLower, + isRaidBoss = isRaid, + ejTier = tier, + path = { isRaid and "Raid" or "Dungeon", instName }, + steps = { + { buttonFrame = "EJMicroButton" }, + { waitForFrame = "EncounterJournal", ejTier = tier, ejTabIsRaid = isRaid }, + { waitForFrame = "EncounterJournal", ejInstance = instName, ejInstanceID = instID }, + { waitForFrame = "EncounterJournal", ejBoss = encName, ejEncounterID = encID }, + }, + } + + encIdx = encIdx + 1 + end + + idx = idx + 1 + end + end + end + + -- Restore prior tier and EJ event handler so the journal behaves + -- normally for the user's next interaction. + if savedTier and selectTier then selectTier(savedTier) end + if ejFrame and savedOnEvent then ejFrame:SetScript("OnEvent", savedOnEvent) end +end + -- Inject one entry per unique item carried in the player's bags. The -- entry stores the first occupied location so guide mode can highlight -- the right slot; drag-to-pickup uses the item ID to put the item on @@ -1541,6 +1847,7 @@ function Database:PopulateDynamicBags() buttonFrame = bagBtn, steps = { { buttonFrame = bagBtn }, + { containerBag = first.bag, containerSlot = first.slot, allLocations = info.locations }, }, } end @@ -2477,7 +2784,7 @@ function Database:BuildUIDatabase() name = "Bags / Inventory", keywords = {"bags", "bag", "inventory", "backpack", "items", "storage"}, category = "Inventory", - icon = 130716, + iconAtlas = "bag-main", steps = {{ buttonFrame = "MainMenuBarBackpackButton" }}, }, { @@ -2506,20 +2813,26 @@ function Database:BuildUIDatabase() -- Flatten the tree into the flat uiSearchData array self:FlattenTree(uiTree) - -- Pre-lowercase names and keywords for search performance - -- Also track which currencyIDs are in the static database for _, item in ipairs(uiSearchData) do item.nameLower = slower(item.name) if item.keywords then - item.keywordsLower = {} - for i, kw in ipairs(item.keywords) do - item.keywordsLower[i] = slower(kw) + local kws = item.keywords + local needsCopy = false + for i = 1, #kws do + local k = kws[i] + if k ~= slower(k) then needsCopy = true; break end + end + if needsCopy then + local lowered = {} + for i = 1, #kws do lowered[i] = slower(kws[i]) end + item.keywordsLower = lowered + else + item.keywordsLower = kws end end if not item.icon and not item.buttonFrame then item.icon = 134400 end - -- Word cache fills lazily on first search (avoids init-time peak) end -- Tag entries with isPvP / isPvE for filter support @@ -2575,6 +2888,26 @@ end local FUZZY_BLOCKLIST = { ["pvp"] = { ["pve"] = true }, ["pve"] = { ["pvp"] = true }, + -- "ability" / "abilities" appear as keywords on every ability entry + -- so multi-word queries like "feral abilities" can scope to a spec. + -- Block fuzzy hits between them and the literal stat "agility" so + -- typing "agility" doesn't drag every ability into the results. + ["agility"] = { ["ability"] = true, ["abilities"] = true }, + ["ability"] = { ["agility"] = true }, + ["abilities"] = { ["agility"] = true }, +} + +-- Words that real abbreviations skip without comment ("lfg" = "Looking For +-- Group", "tot" = "Throne of Thunder"). Initials Strategy 2 may step over +-- these for free; skipping any non-stopword (a content word like "atrocity" +-- in "Shurrai, Atrocity of the Undersea") breaks the chain so unrelated +-- queries don't get a misleading high score from accidental letter +-- alignment. +local INITIALS_STOPWORDS = { + ["of"] = true, ["the"] = true, ["a"] = true, ["an"] = true, + ["and"] = true, ["or"] = true, ["in"] = true, ["on"] = true, + ["at"] = true, ["by"] = true, ["for"] = true, ["to"] = true, + ["with"] = true, ["from"] = true, } -- A word boundary is the start of the string or right after a space/punctuation. @@ -2637,14 +2970,20 @@ function Database:ScoreInitials(text, query) end end - -- Strategy 2: Prefix-of-words - each query segment matches the start of a word - -- "raba" → "ra(ndom) ba(ttleground)" - greedily consume query chars across words - local qi = 1 -- position in query + -- Strategy 2: Prefix-of-words - each query segment matches the start + -- of a word, walking left-to-right. "raba" → "ra(ndom) ba(ttleground)" + -- greedily consumes query chars across consecutive words. + -- Once the chain has started (wordsMatched > 0), encountering a + -- non-stopword that doesn't match breaks the chain. Without this, + -- queries like "sound" wrongly initial-match "Shurrai, Atrocity of + -- the Undersea" via S(hurrai) o(f) und(ersea), skipping the content + -- word "atrocity" silently. Stopwords (of/the/and/...) may still be + -- skipped — that's what lets "tot" → "Throne of Thunder" work. + local qi = 1 local wordsMatched = 0 for wi = 1, numWords do if qi > queryLen then break end local w = words[wi] - -- How many chars from the start of this word match the query at position qi? local matchLen = 0 while qi + matchLen <= queryLen and matchLen < #w do if ssub(query, qi + matchLen, qi + matchLen) == ssub(w, matchLen + 1, matchLen + 1) then @@ -2656,11 +2995,11 @@ function Database:ScoreInitials(text, query) if matchLen > 0 then qi = qi + matchLen wordsMatched = wordsMatched + 1 + elseif wordsMatched > 0 and not INITIALS_STOPWORDS[w] then + break end end - -- Did we consume the entire query across multiple words? if qi > queryLen and wordsMatched >= 2 then - -- Score based on how many words were matched (more = better abbreviation) return 110 + mmin(wordsMatched * 3, 20) end @@ -2672,22 +3011,38 @@ end -- Returns a score > 0 if a close match is found, 0 otherwise. function Database:ScoreFuzzy(text, query, queryLen) + -- Length-scaled typo tolerance (1 edit per ~4 chars), the same + -- shape Algolia / Elasticsearch AUTO mode use. Without this scale, + -- a 5-char query like "skull" would fuzzy-match "spell" (2 edits) + -- and similar 40%-different words, drowning real results. + local maxEdits + if queryLen >= 8 then maxEdits = 2 + elseif queryLen >= 4 then maxEdits = 1 + else return 0 -- queries under 4 chars: no fuzzy, substring covers them + end + + local queryFirst = ssub(query, 1, 1) local bestScore = 0 local blocked = FUZZY_BLOCKLIST[query] local textWords = GetWords(text) for wi = 1, #textWords do local word = textWords[wi] - if not (blocked and blocked[word]) then + -- First-letter constraint (Algolia default). "easter" vs + -- "master" is a 1-edit match technically, but they're + -- semantically unrelated — typos almost never change the + -- first character, and this rule kills the false-positive + -- flood without dropping real typos like "achievmnts" → + -- "achievements". + if ssub(word, 1, 1) == queryFirst + and not (blocked and blocked[word]) then local wordLen = #word local lenDiff = wordLen - queryLen if lenDiff < 0 then lenDiff = -lenDiff end - -- ±1 for short queries, ±2 for 6+ char queries - local maxLenDiff = queryLen >= 6 and 2 or 1 - if lenDiff <= maxLenDiff then + if lenDiff <= maxEdits then local dist = Database:DamerauLevenshtein(query, word, queryLen, wordLen) - if dist == 1 then + if dist == 1 and maxEdits >= 1 then bestScore = mmax(bestScore, 85) - elseif dist == 2 and queryLen >= 5 then + elseif dist == 2 and maxEdits >= 2 then bestScore = mmax(bestScore, 45) end end @@ -2849,7 +3204,10 @@ function Database:ScoreName(nameLower, query, queryLen, optQueryWords) -- Per-word matching for multi-word queries: -- Split query into words and score each against name words. -- All query words must match for a score to be awarded. - if score < 50 and sfind(query, " ", 1, true) then + -- Fires even when an inferior path already returned a low score + -- (e.g. mid-word substring 30) so a multi-word typo match like + -- "estern kingd" → "Eastern Kingdoms" can still surface. + if score < 100 and sfind(query, " ", 1, true) then local queryWords = optQueryWords if not queryWords then queryWords = {} @@ -2877,13 +3235,14 @@ function Database:ScoreName(nameLower, query, queryLen, optQueryWords) ws = 90 elseif sfind(nw, qw, 1, true) then ws = 50 - elseif qwLen >= 4 then + elseif qwLen >= 4 and ssub(nw, 1, 1) == ssub(qw, 1, 1) then local nwLen = #nw - if nwLen >= qwLen - 2 and nwLen <= qwLen + 2 then + local maxEdits = qwLen >= 8 and 2 or 1 + if nwLen >= qwLen - maxEdits and nwLen <= qwLen + maxEdits then local dist = Database:DamerauLevenshtein(qw, nw, qwLen, nwLen) if dist == 1 then ws = 75 - elseif dist == 2 and qwLen >= 5 then + elseif dist == 2 and maxEdits >= 2 then ws = 40 end end @@ -3009,15 +3368,19 @@ function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) end -- 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. + -- All query words >= 2 chars must match at least one keyword; a single + -- unmatched word zeroes the total to prevent common words like "of" from + -- producing false positives. Single-character words are SKIPPED (not + -- failed) so a mid-type query like "feral a" still passes through entries + -- that match "feral" — otherwise the prevCandidates incremental narrowing + -- chain inherits an empty set and "feral abil" silently returns nothing. local total = 0 for qwi = 1, #queryWords do local queryWord = queryWords[qwi] local queryWordLen = #queryWord local bestScore = 0 - if queryWordLen >= 2 then -- single-char words too ambiguous for keyword matching + if queryWordLen >= 2 then for ki = 1, numKeywords do local kw = keywordsLower[ki] local kwScore = 0 @@ -3046,12 +3409,12 @@ function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) bestScore = kwScore end end + if bestScore == 0 then + return 0 + end + total = total + bestScore end - - if bestScore == 0 then - return 0 - end - total = total + bestScore + -- queryWordLen < 2: skipped (mid-type), don't fail entry end return total @@ -3063,19 +3426,285 @@ local prevQuery = "" local prevSkipKey = "" local prevCandidates = {} --- Clear the incremental search cache. Called after uiSearchData is mutated --- (e.g. PopulateDynamicTransmogSets) so the next query doesn't reuse a --- stale candidate list that was built against the old dataset. +-- --------------------------------------------------------------------- +-- Inverted prefix indexes. Without these, every keystroke that isn't +-- a forward extension of the previous query (every backspace, every +-- fresh query, every typo correction) falls back to a full scan over +-- ~10K entries — held backspace stutters every frame. +-- +-- We keep two indexes side-by-side: +-- prefix2Index["mo"] = { entries with any name/keyword word starting "mo" } +-- prefix1Index["m"] = { entries with any name/keyword word starting "m" } +-- +-- Lookup tries 2-char first (smallest bucket, ~50-300 entries). +-- Falls back to 1-char if the 2-char bucket is empty (covers rare +-- prefixes and entries whose words are < 2 chars). Falls back to a +-- full scan as a final safety net (should never happen in practice). +-- +-- Built at the end of init and rebuilt any time uiSearchData mutates +-- (ResetSearchCache invalidates the indexes; next SearchUI rebuilds). +-- --------------------------------------------------------------------- +local prefix1Index = nil +local prefix2Index = nil + +local function indexAddWord(seen1, seen2, entry, word) + if not word or word == "" then return end + local c1 = ssub(word, 1, 1) + if not seen1[c1] then + seen1[c1] = true + local b1 = prefix1Index[c1] + if not b1 then b1 = {}; prefix1Index[c1] = b1 end + b1[#b1 + 1] = entry + end + if #word >= 2 then + local c2 = ssub(word, 1, 2) + if not seen2[c2] then + seen2[c2] = true + local b2 = prefix2Index[c2] + if not b2 then b2 = {}; prefix2Index[c2] = b2 end + b2[#b2 + 1] = entry + end + end +end + +local function rebuildPrefixIndexes() + prefix1Index = {} + prefix2Index = {} + local seen1, seen2 = {}, {} + for ei = 1, #uiSearchData do + local entry = uiSearchData[ei] + for k in pairs(seen1) do seen1[k] = nil end + for k in pairs(seen2) do seen2[k] = nil end + local nameLower = entry.nameLower + if nameLower then + for word in nameLower:gmatch("%S+") do + indexAddWord(seen1, seen2, entry, word) + end + end + local kwl = entry.keywordsLower + if kwl then + for ki = 1, #kwl do + local kw = kwl[ki] + if type(kw) == "string" then + for word in kw:gmatch("%S+") do + indexAddWord(seen1, seen2, entry, word) + end + end + end + end + end +end + +local sortChildrenNode +local function compareChildByScore(a, b) + local children = sortChildrenNode.children + local sa = children[a].bestScore or 0 + local sb = children[b].bestScore or 0 + if sa ~= sb then return sa > sb end + return a < b +end +local function sortChildren(node) + sortChildrenNode = node + tsort(node.childOrder, compareChildByScore) + local children = node.children + local order = node.childOrder + for i = 1, #order do + sortChildren(children[order[i]]) + end +end + +local function GetOrCreateNode(root, pathParts) + local node = root + for i = 1, #pathParts do + local part = pathParts[i] + local children = node.children + local existing = children[part] + if not existing then + existing = { + name = part, + children = {}, + childOrder = {}, + bestScore = 0, + } + children[part] = existing + node.childOrder[#node.childOrder + 1] = part + end + node = existing + end + return node +end + +local function HasActualContent(node) + if node._hac ~= nil then return node._hac end + if node.data then + local nodeData = node.data + local isCurrencyNode = nodeData.category == "Currency" + if not isCurrencyNode then + if nodeData.available and not nodeData.available() then + node._hac = false + return false + end + node._hac = true + return true + end + + local hasCurrencyID = false + if nodeData.steps and C_CurrencyInfo then + local steps = nodeData.steps + for i = 1, #steps do + if steps[i].currencyID then + hasCurrencyID = true + break + end + end + end + + if hasCurrencyID then + local steps = nodeData.steps + local isLegacyCurrency = false + for i = 1, #steps do + if steps[i].currencyHeader == "Legacy" then + isLegacyCurrency = true + break + end + end + + for i = 1, #steps do + local step = steps[i] + if step.currencyID then + local currencyInfo = C_CurrencyInfo.GetCurrencyInfo(step.currencyID) + if not currencyInfo then node._hac = false; return false end + if currencyInfo.quantity == 0 then + local isDiscovered = (currencyInfo.totalEarned and currencyInfo.totalEarned > 0) + or currencyInfo.useTotalEarnedForMaxQty + or (currencyInfo.discovered == true) + if isLegacyCurrency and not isDiscovered then + node._hac = false + return false + end + end + end + end + node._hac = true + return true + end + + if nodeData.category == "Currency" and nodeData.steps then + local steps = nodeData.steps + local isLegacyParent = false + for i = 1, #steps do + if steps[i].currencyHeader == "Legacy" then + isLegacyParent = true + break + end + end + if not isLegacyParent then + node._hac = true + return true + end + end + end + + local order = node.childOrder + local children = node.children + for i = 1, #order do + if HasActualContent(children[order[i]]) then + node._hac = true + return true + end + end + node._hac = false + return false +end + +local flattenScratch = {} +local hierEntryPool = {} +local hierEntryPoolN = 0 + +local function FlattenNode(self, node, depth, out, containerPaths) + local order = node.childOrder + local children = node.children + for ci = 1, #order do + local child = children[order[ci]] + local hasChildren = #child.childOrder > 0 + + if HasActualContent(child) then + local isContainer = false + if not hasChildren and child.data and child.data.path then + local path = child.data.path + local pathLen = #path + for pi = 1, pathLen do flattenScratch[pi] = path[pi] end + flattenScratch[pathLen + 1] = child.name + for pi = pathLen + 2, #flattenScratch do flattenScratch[pi] = nil end + isContainer = containerPaths[tconcat(flattenScratch, "\1", 1, pathLen + 1)] or false + elseif not hasChildren and child.data then + isContainer = containerPaths[child.name] or false + end + + hierEntryPoolN = hierEntryPoolN + 1 + local entry = hierEntryPool[hierEntryPoolN] + if not entry then + entry = {} + hierEntryPool[hierEntryPoolN] = entry + end + entry.name = child.name + entry.depth = depth + entry.isPathNode = hasChildren or isContainer + entry.isMatch = child.isMatch or false + entry.data = child.data or self:FindItemByName(child.name) + entry.isContainer = isContainer or nil + entry.isPinHeader = nil + entry.isPinned = nil + entry.isSectionHeader = nil + entry.isFlat = nil + entry.flatCatKey = nil + out[#out + 1] = entry + + if hasChildren then + FlattenNode(self, child, depth + 1, out, containerPaths) + end + end + end +end + +function Database:ResetHierEntryPool() + hierEntryPoolN = 0 +end + +local containerPathCache +local nameLookup +local function GetContainerPathSet() + if containerPathCache then return containerPathCache end + local cache = {} + for i = 1, #uiSearchData do + local data = uiSearchData[i] + if data.path then + cache[tconcat(data.path, "\1")] = true + end + end + containerPathCache = cache + return cache +end + function Database:ResetSearchCache() prevQuery = "" prevSkipKey = "" wipe(prevCandidates) + containerPathCache = nil + nameLookup = nil + prefix1Index = nil + prefix2Index = nil end +local resultsBuf = {} +local resultsQueryWords = {} +local resultEntryPool = {} + function Database:SearchUI(query, skipCategories) if not query or query == "" or #query < 2 then prevQuery = "" - return {} + wipe(resultsBuf) + return resultsBuf end query = slower(query) @@ -3085,21 +3714,44 @@ function Database:SearchUI(query, skipCategories) if ssub(query, queryLen, queryLen) == " " then query = query:match("^(.-)%s+$") or query queryLen = #query - if queryLen == 0 then prevQuery = ""; return {} end + if queryLen == 0 then prevQuery = ""; wipe(resultsBuf); return resultsBuf end end - -- Pre-split query words once (passed to scoring to avoid per-entry gmatch) - local queryWords = {} + wipe(resultsQueryWords) + local queryWords = resultsQueryWords for w in query:gmatch("%S+") do queryWords[#queryWords + 1] = w end + -- Detect " boss" style queries. When the user mentions + -- "boss" anywhere in the query, bosses are allowed to match by + -- their instance keyword (e.g. "icc boss" -> all ICC bosses). + -- Without "boss" present, bosses are restricted to name-only + -- matches so plain "raid"/"dungeon"/"icc" don't flood with them. + local bossQueryWord = false + -- Same gate for achievement entries: there are ~175 of them, and + -- nearly any common word (easter, mounts, kill, dungeon...) shows + -- up in some achievement keyword. Gate them behind the user + -- typing "ach"/"achievement"/etc. or a strong name match. + local achQueryWord = false + for qi = 1, #queryWords do + local qw = queryWords[qi] + if qw == "boss" or qw == "bosses" then + bossQueryWord = true + end + if ssub(qw, 1, 3) == "ach" or qw == "stat" or qw == "stats" + or qw == "statistic" or qw == "statistics" then + achQueryWord = true + end + end + -- Determine candidate set: incremental (previous matches) or full database local skipKey = skipCategories and ( (skipCategories["Mount"] and "M" or "") .. (skipCategories["Toy"] and "T" or "") .. (skipCategories["Pet"] and "P" or "") .. (skipCategories["Outfit"] and "O" or "") .. + (skipCategories["Heirloom"] and "H" or "") .. (skipCategories["Loot"] and "L" or "") ) or "" @@ -3107,12 +3759,32 @@ function Database:SearchUI(query, skipCategories) local prevLen = #prevQuery if prevLen > 0 and skipKey == prevSkipKey and queryLen > prevLen and ssub(query, 1, prevLen) == prevQuery then + -- Forward extension of the prior query: re-score the prior + -- candidates only. (Cheapest path.) searchSet = prevCandidates else - searchSet = uiSearchData - end - - local results = {} + -- Fresh query / backspace / typo correction: look up the + -- 2-char prefix bucket (smallest), fall back to 1-char (covers + -- single-char-word entries and rare prefixes), and only as a + -- last resort go full-scan. In practice the 2-char hit covers + -- ~all real queries. + if not prefix2Index then rebuildPrefixIndexes() end + local key2 = ssub(query, 1, 2) + searchSet = prefix2Index[key2] + if not searchSet then + searchSet = prefix1Index[ssub(query, 1, 1)] + end + if not searchSet then + wipe(resultsBuf) + prevQuery = query + prevSkipKey = skipKey + return resultsBuf + end + end + + wipe(resultsBuf) + local results = resultsBuf + local resultsN = 0 local candidateIdx = 0 local searchCount = #searchSet @@ -3188,13 +3860,46 @@ function Database:SearchUI(query, skipCategories) end score = totalScore - else + elseif data.category == "Boss" and not bossQueryWord then + -- Bosses match by name only when the query doesn't + -- mention "boss". This prevents plain "raid"/"dungeon" + -- /"icc" from dragging every encounter into the list. score = Database:ScoreName(nameLower, query, queryLen, queryWords) - score = score + Database:ScoreKeywords(data.keywordsLower, query, queryLen, queryWords) + else + local cat = data.category + local isAchEntry = cat == "Achievements" or cat == "Guild Achievements" + or cat == "Statistics" + if isAchEntry and not achQueryWord then + -- Restrict achievement entries to strong name matches + -- only (exact / prefix / starts-with-word). Skip the + -- keyword score entirely so short common keyword + -- aliases don't drag every achievement category in. + if nameLower == query then + score = 200 + elseif ssub(nameLower, 1, queryLen) == query then + score = 150 + elseif Database:FindAtWordBoundary(nameLower, query) then + score = 120 + else + score = 0 + end + else + score = Database:ScoreName(nameLower, query, queryLen, queryWords) + score = score + Database:ScoreKeywords(data.keywordsLower, query, queryLen, queryWords) + end end if score >= 30 then - results[#results + 1] = { data = data, score = score } + resultsN = resultsN + 1 + local r = resultEntryPool[resultsN] + if not r then + r = {} + resultEntryPool[resultsN] = r + end + r.data = data + r.score = score + r.isAlias = nil + results[resultsN] = r candidateIdx = candidateIdx + 1 prevCandidates[candidateIdx] = data end @@ -3208,6 +3913,16 @@ function Database:SearchUI(query, skipCategories) prevSkipKey = skipKey tsort(results, scoreDescending) + -- Cap to top-N by score. Beyond ~100 results the per-keystroke + -- bucket-build / sort / hierarchical-flatten pipeline pays in full + -- for entries the user can't see (the row pool is 50 anyway), and + -- "525 results for `mo`" hits 70ms+ in build alone. Capping the + -- raw score-sorted list before the downstream pipeline makes the + -- hot path proportional to visible work, not to dataset size. + local SEARCH_RESULT_CAP = 100 + if resultsN > SEARCH_RESULT_CAP then + for i = SEARCH_RESULT_CAP + 1, resultsN do results[i] = nil end + end return results end @@ -3219,41 +3934,19 @@ function Database:BuildHierarchicalResults(results) return {} end - -- Step 1 - Build a virtual tree from all results. - -- Each node: { name, children (name→node), childOrder (list of names), - -- data (search result entry or nil), bestScore } local root = { children = {}, childOrder = {} } - local function getOrCreateNode(pathParts) - local node = root - for _, part in ipairs(pathParts) do - if not node.children[part] then - node.children[part] = { - name = part, - children = {}, - childOrder = {}, - bestScore = 0, - } - node.childOrder[#node.childOrder + 1] = part - end - node = node.children[part] - end - return node - end - - for _, item in ipairs(results) do - -- item = {data = originalEntry, score = N} from SearchUI + for ri = 1, #results do + local item = results[ri] local itemData = item.data local path = itemData.path or {} - local parentNode = getOrCreateNode(path) + local parentNode = GetOrCreateNode(root, path) local itemScore = item.score or 0 + local existing = parentNode.children[itemData.name] - if parentNode.children[itemData.name] then - -- Node already exists (created as a path ancestor of another result). - -- Attach the actual result data so it becomes navigable. - local existing = parentNode.children[itemData.name] + if existing then if not existing.data then existing.data = itemData end - existing.isMatch = true -- direct search match + existing.isMatch = true if itemScore > existing.bestScore then existing.bestScore = itemScore end @@ -3264,172 +3957,24 @@ function Database:BuildHierarchicalResults(results) childOrder = {}, data = itemData, bestScore = itemScore, - isMatch = true, -- direct search match + isMatch = true, } parentNode.childOrder[#parentNode.childOrder + 1] = itemData.name end - -- Propagate best score upward so ancestor branches sort correctly. local node = root - for _, part in ipairs(path) do - node = node.children[part] + for pi = 1, #path do + node = node.children[path[pi]] if itemScore > node.bestScore then node.bestScore = itemScore end end end - -- Step 2 - Sort children at every level: best score desc, then name asc. - local function sortChildren(node) - tsort(node.childOrder, function(a, b) - local sa = node.children[a].bestScore or 0 - local sb = node.children[b].bestScore or 0 - if sa ~= sb then return sa > sb end - return a < b - end) - for _, childName in ipairs(node.childOrder) do - sortChildren(node.children[childName]) - end - end sortChildren(root) - -- Step 3 - DFS flatten into the display list. - -- Only include path nodes that have actual leaf content (not just empty descendants) local hierarchical = {} - - -- Check if a node or any of its descendants has actual data that's available to the player - local function hasActualContent(node) - if node.data then - -- Check if this is a currency node (leaf or parent) - local isCurrencyNode = node.data.category == "Currency" - - -- For non-currency items, use the availability function - if not isCurrencyNode then - if node.data.available and not node.data.available() then - return false - end - return true - end - - -- For currency items, check if it's a leaf node (has actual currency) vs parent node (just a header) - local hasCurrencyID = false - if node.data.steps and C_CurrencyInfo then - for _, step in ipairs(node.data.steps) do - if step.currencyID then - hasCurrencyID = true - break - end - end - end - - -- Only process currency discovery checks for leaf nodes (nodes with actual currencyID) - if hasCurrencyID then - -- Check if this is under the Legacy tab - local isLegacyCurrency = false - for _, step in ipairs(node.data.steps) do - if step.currencyHeader == "Legacy" then - isLegacyCurrency = true - break - end - end - - for _, step in ipairs(node.data.steps) do - if step.currencyID then - local currencyInfo = C_CurrencyInfo.GetCurrencyInfo(step.currencyID) - -- If currency info doesn't exist, it's not available for this character - if not currencyInfo then - return false - end - -- Check if currency has been discovered - if currencyInfo.quantity == 0 then - local isDiscovered = (currencyInfo.totalEarned and currencyInfo.totalEarned > 0) or - (currencyInfo.useTotalEarnedForMaxQty) or - (currencyInfo.discovered == true) - -- Only filter out undiscovered Legacy currencies - -- Current content currencies are always shown (UI will grey them out) - if isLegacyCurrency and not isDiscovered then - return false - end - end - end - end - -- Leaf node passed all checks - return true - end - - -- For parent nodes (no currencyID), check if it's a non-Legacy currency tab - -- Non-Legacy currency tabs (D&R, Misc, PvP) should always show even with no children - if node.data.category == "Currency" and node.data.steps then - local isLegacyParent = false - for _, step in ipairs(node.data.steps) do - if step.currencyHeader == "Legacy" then - isLegacyParent = true - break - end - end - -- Non-Legacy currency parent tabs always show (UI will grey them out if needed) - if not isLegacyParent then - return true - end - end - -- For Legacy parent nodes and non-currency nodes, fall through to check children - end - - -- Check if any child has actual available content - for _, childName in ipairs(node.childOrder) do - if hasActualContent(node.children[childName]) then - return true - end - end - return false - end - - -- Build a set of "container paths" - path prefixes that have children - -- in the full database. Used to detect leaf search results that should - -- render as expandable path nodes (e.g. "Reputation", "Rated"). - local containerPaths = {} - for _, data in ipairs(uiSearchData) do - if data.path then - local key = tconcat(data.path, "\1") - containerPaths[key] = true - end - end - - local function flatten(node, depth) - for _, childName in ipairs(node.childOrder) do - local child = node.children[childName] - local hasChildren = #child.childOrder > 0 - - if hasActualContent(child) then - -- Check if this leaf node is actually a container in the database - -- (has children that didn't match the search query). - local isContainer = false - if not hasChildren and child.data then - local fp = {} - if child.data.path then - for _, p in ipairs(child.data.path) do fp[#fp + 1] = p end - end - fp[#fp + 1] = child.name - isContainer = containerPaths[tconcat(fp, "\1")] or false - end - - hierarchical[#hierarchical + 1] = { - name = child.name, - depth = depth, - isPathNode = hasChildren or isContainer, - isMatch = child.isMatch or false, - data = child.data or self:FindItemByName(child.name), - isContainer = isContainer or nil, - } - - if hasChildren then - flatten(child, depth + 1) - end - end - end - end - flatten(root, 0) - + FlattenNode(self, root, 0, hierarchical, GetContainerPathSet()) return hierarchical end @@ -3463,12 +4008,16 @@ function Database:GetContainerChildren(containerData) end function Database:FindItemByName(name) - for _, data in ipairs(uiSearchData) do - if data.name == name then - return data + if not nameLookup then + nameLookup = {} + for i = 1, #uiSearchData do + local d = uiSearchData[i] + if not nameLookup[d.name] then + nameLookup[d.name] = d + end end end - return nil + return nameLookup[name] end -- Static UI database must be built at load time (before ADDON_LOADED) so other diff --git a/EasyFind.toc b/EasyFind.toc index eff5b54..94ec153 100644 --- a/EasyFind.toc +++ b/EasyFind.toc @@ -3,7 +3,7 @@ ## Notes: Search and locate UI elements and map locations with ease ## Author: justawower ## Version: 1.5.0 -## IconTexture: 136460 +## IconTexture: Interface\AddOns\EasyFind\Textures\SpyglassMinimap ## Category: UI ## AddonCompartmentFunc: EasyFind_OnAddonCompartmentClick ## SavedVariables: EasyFindDB @@ -17,6 +17,8 @@ TutorialBox.lua ChevronConfig.lua Aliases.lua UI.lua +Wizard.lua +Perf.lua Highlight.lua MapSearch.lua MapTab.lua diff --git a/Fonts/Inter-Bold.ttf b/Fonts/Inter-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9fb9b751e5b2441054f6eef670da7b3f53a3505a GIT binary patch literal 420428 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 45vPrwCj8&%8F=IDq-RRM%sJ98 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 literal 0 HcmV?d00001 diff --git a/Fonts/Inter-SemiBold.ttf b/Fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..47f8ab1d68144fc004b310d65d95180d502b449b GIT binary patch literal 419744 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�# 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 literal 0 HcmV?d00001 diff --git a/Highlight.lua b/Highlight.lua index ca65ebc..9eae51b 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -339,14 +339,48 @@ function Highlight:UpdateGuide() return end + -- Game Menu button by text (e.g. "Macros", "Options"). The + -- buttons in GameMenuFrame are unnamed (dynamic hex IDs) but + -- carry their label text, so look up by GetText() on each child. + if step.gameMenuText then + if not GameMenuFrame or not GameMenuFrame:IsShown() then + -- Menu closed: in guide mode rewind to reopen, in DirectOpen + -- mode (left-click "open + show me") just cancel since the + -- user has dismissed the destination. + if currentGuide and currentGuide.noCourseCorrect then + self:Cancel() + else + currentStepIndex = 1 + self:HideHighlight() + end + return + end + local btn = self:FindGameMenuButton(step.gameMenuText) + if btn then + self:HighlightFrame(btn) + if canHoverDismiss() and btn:IsMouseOver() then + self:Cancel() + return + end + else + self:HideHighlight() + self:ShowInstruction("'" .. step.gameMenuText .. "' is not in the Game Menu") + C_Timer.After(2.5, function() self:Cancel() end) + end + return + end + -- Portrait menu step 2: find and highlight the correct menu option if step.portraitMenuOption then -- Check if portrait menu is still open if not self:IsPortraitMenuOpen() then - -- Menu was closed, go back to portrait step self:HideContextTooltip() - currentStepIndex = currentStepIndex - 1 - self:HideHighlight() + if currentGuide and currentGuide.noCourseCorrect then + self:Cancel() + else + currentStepIndex = currentStepIndex - 1 + self:HideHighlight() + end return end @@ -374,9 +408,12 @@ function Highlight:UpdateGuide() local frame = self:GetFrameByPath(step.waitForFrame) if not frame or not frame:IsShown() then - -- Frame closed, go back to step 1 - currentStepIndex = 1 - self:HideHighlight() + if currentGuide and currentGuide.noCourseCorrect then + self:Cancel() + else + currentStepIndex = 1 + self:HideHighlight() + end return end @@ -408,6 +445,27 @@ function Highlight:UpdateGuide() return end + -- EJ tier + dungeon/raid tab (boss navigation). Sets the tier on + -- the EJ then highlights the correct tab; advances when both + -- the tab is active and the InstanceSelect ScrollBox is showing. + if step.waitForFrame == "EncounterJournal" and step.ejTier then + local tabIdx = step.ejTabIsRaid and 5 or 4 + if not step._tierApplied then + step._tierApplied = true + if EJ_SelectTier then EJ_SelectTier(step.ejTier) end + end + if self:IsTabSelected("EncounterJournal", tabIdx) then + local instSelect = _G["EncounterJournalInstanceSelect"] + if instSelect and instSelect:IsShown() then + self:AdvanceStep() + return + end + end + local tabBtn = self:GetTabButton("EncounterJournal", tabIdx) + if tabBtn then self:HighlightFrame(tabBtn) end + return + end + -- Side tab (like Dungeon Finder / Raid Finder / Premade Groups in Group Finder) if step.sideTabIndex then -- First check: are we still on the correct main tab? @@ -1068,6 +1126,18 @@ function Highlight:UpdateGuide() end end + -- Validate EJ tier (user picked a different tier dropdown). + -- If the live tier no longer matches, rewind so we re-set it. + if prev.ejTier then + local liveTier = EJ_GetCurrentTier and EJ_GetCurrentTier() + if liveTier and liveTier ~= prev.ejTier then + currentStepIndex = i + prev._tierApplied = nil + self:HideHighlight() + return + end + end + -- Validate EJ boss (encounterID changed = user clicked different boss) if prev.ejEncounterID then local infoFrame = _G["EncounterJournalEncounterFrameInfo"] @@ -1099,6 +1169,60 @@ function Highlight:UpdateGuide() end end + -- Bag slot highlight: open all bags containing the item, glow + -- the first matching slot, and accent secondaries with the bag + -- search overlay so duplicates across bags are still visible. + if step.containerBag ~= nil and step.containerSlot then + local locations = step.allLocations or {{ bag = step.containerBag, slot = step.containerSlot }} + local CONT = C_Container + local openBag = (CONT and CONT.OpenBag) or OpenBag + local getSlots = (CONT and CONT.GetContainerNumSlots) or GetContainerNumSlots + + if openBag then + local seen = {} + for _, loc in ipairs(locations) do + if not seen[loc.bag] then + seen[loc.bag] = true + pcall(openBag, loc.bag) + end + end + end + + local found = false + for _, loc in ipairs(locations) do + for i = 1, 13 do + local cf = _G["ContainerFrame" .. i] + if cf and cf:IsShown() and cf:GetID() == loc.bag then + local ok, n = pcall(function() return getSlots and getSlots(loc.bag) end) + local numSlots = (ok and n) or 0 + if numSlots == 0 then + for c = 1, 40 do + if not _G["ContainerFrame" .. i .. "Item" .. c] then + numSlots = c - 1; break + end + end + end + local btnIdx = numSlots - loc.slot + 1 + local itemBtn = btnIdx >= 1 and _G["ContainerFrame" .. i .. "Item" .. btnIdx] or nil + if itemBtn and itemBtn:IsShown() then + if not found then + self:HighlightFrame(itemBtn) + found = true + else + local glow = _G[itemBtn:GetName() .. "SearchOverlay"] + if glow then + glow:SetVertexColor(1, 1, 0, 0.5) + glow:Show() + end + end + end + break + end + end + end + return + 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 @@ -2458,6 +2582,30 @@ function Highlight:GetPortraitMenuFrame() return nil end +-- Find a button in GameMenuFrame by its label text. The buttons in +-- modern GameMenuFrame are unnamed (dynamic hex IDs change each +-- session) so name-based lookup doesn't work; iterate visible +-- children and match GetText(). +function Highlight:FindGameMenuButton(label) + if not GameMenuFrame or not label then return nil end + local target = slower(label) + local ok, nChildren = pcall(function() return select("#", GameMenuFrame:GetChildren()) end) + if not ok or not nChildren then return nil end + for i = 1, nChildren do + local child = select(i, GameMenuFrame:GetChildren()) + if child then + local sok, shown = pcall(child.IsShown, child) + if sok and shown and child.GetText then + local tok, text = pcall(child.GetText, child) + if tok and text and slower(text) == target then + return child + end + end + end + end + return nil +end + function Highlight:FindPortraitMenuOption(optionName) local optionNameLower = slower(optionName) diff --git a/MapSearch.lua b/MapSearch.lua index c7ca0fe..9dc5c8c 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -1509,22 +1509,10 @@ function MapSearch:CreateSearchFrame() searchFrame:SetPoint("TOPLEFT", WorldMapFrame.ScrollContainer, "BOTTOMLEFT", 0, yOff) end - local WHITE8x8 = "Interface\\BUTTONS\\WHITE8x8" ns.CreateSearchBorder(searchFrame) - if (EasyFind.db.resultsTheme or "Classic") == "Retail" then - searchFrame:SetBackdrop(nil) - ns.SetSearchBorderShown(searchFrame, true) - ns.SetSearchBorderBgAlpha(searchFrame, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) - else - searchFrame:SetBackdrop({ - bgFile = WHITE8x8, - edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 } - }) - searchFrame:SetBackdropColor(0, 0, 0, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) - ns.SetSearchBorderShown(searchFrame, false) - end + searchFrame:SetBackdrop(nil) + ns.SetSearchBorderShown(searchFrame, true) + ns.SetSearchBorderBgAlpha(searchFrame, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) searchFrame:SetClipsChildren(true) -- Draggable with Shift key (constrained to map edge) @@ -1907,20 +1895,9 @@ function MapSearch:CreateSearchFrame() end ns.CreateSearchBorder(globalSearchFrame) - if (EasyFind.db.resultsTheme or "Classic") == "Retail" then - globalSearchFrame:SetBackdrop(nil) - ns.SetSearchBorderShown(globalSearchFrame, true) - ns.SetSearchBorderBgAlpha(globalSearchFrame, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) - else - globalSearchFrame:SetBackdrop({ - bgFile = WHITE8x8, - edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 } - }) - globalSearchFrame:SetBackdropColor(0, 0, 0, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) - ns.SetSearchBorderShown(globalSearchFrame, false) - end + globalSearchFrame:SetBackdrop(nil) + ns.SetSearchBorderShown(globalSearchFrame, true) + ns.SetSearchBorderBgAlpha(globalSearchFrame, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) globalSearchFrame:SetClipsChildren(true) -- Draggable with Shift key (constrained to map edge) @@ -2530,28 +2507,19 @@ function MapSearch:CreateResultsFrame() resultsFrame:SetPoint("TOPLEFT", searchFrame, "BOTTOMLEFT", 0, 2) - if (EasyFind.db.resultsTheme or "Classic") == "Retail" then - resultsFrame:SetBackdrop({ - edgeFile = TOOLTIP_BORDER, - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 }, - }) - resultsFrame:SetBackdropBorderColor(0.50, 0.48, 0.45, 1.0) - local bgTex = resultsFrame:CreateTexture(nil, "BACKGROUND", nil, -1) - bgTex:SetPoint("TOPLEFT", 4, -4) - bgTex:SetPoint("BOTTOMRIGHT", -4, 4) - bgTex:SetAtlas("QuestLog-main-background", false) - bgTex:Show() - resultsFrame:SetClipsChildren(true) - resultsFrame.bgAtlasTex = bgTex - else - resultsFrame:SetBackdrop({ - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", - edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", - tile = true, tileSize = 32, edgeSize = 20, - insets = { left = 5, right = 5, top = 5, bottom = 5 }, - }) - end + resultsFrame:SetBackdrop({ + edgeFile = TOOLTIP_BORDER, + edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 }, + }) + resultsFrame:SetBackdropBorderColor(0.50, 0.48, 0.45, 1.0) + local bgTex = resultsFrame:CreateTexture(nil, "BACKGROUND", nil, -1) + bgTex:SetPoint("TOPLEFT", 4, -4) + bgTex:SetPoint("BOTTOMRIGHT", -4, 4) + bgTex:SetAtlas("QuestLog-main-background", false) + bgTex:Show() + resultsFrame:SetClipsChildren(true) + resultsFrame.bgAtlasTex = bgTex resultsFrame:Hide() @@ -5211,6 +5179,11 @@ function MapSearch:ScanDungeonEntrances(mapID) local _, _, _, _, _, _, _, _, _, _, _, entIsRaid = EJ_GetInstanceInfo(entrance.journalInstanceID) if entIsRaid then cat = "raid" end end + local kw = {cat, "instance", "entrance"} + local abbrs = ns.INSTANCE_ABBRS and ns.INSTANCE_ABBRS[slower(entrance.name)] + if abbrs then + for ai = 1, #abbrs do kw[#kw + 1] = abbrs[ai] end + end tinsert(results, { name = entrance.name, category = cat, @@ -5221,7 +5194,7 @@ function MapSearch:ScanDungeonEntrances(mapID) x = ex, y = ey, pathPrefix = parentLabel, - keywords = {cat, "instance", "entrance"}, + keywords = kw, }) end @@ -8304,15 +8277,10 @@ end function MapSearch:UpdateOpacity() local alpha = EasyFind.db.searchBarOpacity or DEFAULT_OPACITY - local isRetail = (EasyFind.db.resultsTheme or "Retail") == "Retail" local frames = {searchFrame, globalSearchFrame} for _, frame in ipairs(frames) do if frame then - if isRetail then - ns.SetSearchBorderBgAlpha(frame, alpha) - else - frame:SetBackdropColor(0, 0, 0, alpha) - end + ns.SetSearchBorderBgAlpha(frame, alpha) end end end @@ -8364,62 +8332,34 @@ function MapSearch:UpdateFontSize() end function MapSearch:UpdateSearchBarTheme() - local isRetail = (EasyFind.db.resultsTheme or "Retail") == "Retail" local scale = EasyFind.db.mapFontSize or 1.0 - local edge = 16 * scale - local inset = 4 * scale - local WHITE8x8 = "Interface\\BUTTONS\\WHITE8x8" local alpha = EasyFind.db.searchBarOpacity or DEFAULT_OPACITY local frames = {searchFrame, globalSearchFrame} for _, frame in ipairs(frames) do if frame then - if isRetail then - frame:SetBackdrop(nil) - ns.SetSearchBorderShown(frame, true) - ns.ScaleSearchBorder(frame, scale) - ns.SetSearchBorderBgAlpha(frame, alpha) - else - frame:SetBackdrop({ - bgFile = WHITE8x8, - edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", - edgeSize = edge, - insets = { left = inset, right = inset, top = inset, bottom = inset } - }) - frame:SetBackdropBorderColor(1, 1, 1, 1) - frame:SetBackdropColor(0, 0, 0, alpha) - ns.SetSearchBorderShown(frame, false) - end + frame:SetBackdrop(nil) + ns.SetSearchBorderShown(frame, true) + ns.ScaleSearchBorder(frame, scale) + ns.SetSearchBorderBgAlpha(frame, alpha) end end if resultsFrame then - if isRetail then - resultsFrame:SetBackdrop({ - edgeFile = TOOLTIP_BORDER, - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 }, - }) - resultsFrame:SetBackdropBorderColor(0.50, 0.48, 0.45, 1.0) - if not resultsFrame.bgAtlasTex then - local tex = resultsFrame:CreateTexture(nil, "BACKGROUND", nil, -1) - tex:SetPoint("TOPLEFT", 4, -4) - tex:SetPoint("BOTTOMRIGHT", -4, 4) - resultsFrame.bgAtlasTex = tex - end - resultsFrame.bgAtlasTex:SetAtlas("QuestLog-main-background", false) - resultsFrame.bgAtlasTex:Show() - resultsFrame:SetClipsChildren(true) - else - resultsFrame:SetBackdrop({ - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", - edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", - tile = true, tileSize = 32, edgeSize = 20, - insets = { left = 5, right = 5, top = 5, bottom = 5 }, - }) - if resultsFrame.bgAtlasTex then - resultsFrame.bgAtlasTex:Hide() - end - end + resultsFrame:SetBackdrop({ + edgeFile = TOOLTIP_BORDER, + edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 }, + }) + resultsFrame:SetBackdropBorderColor(0.50, 0.48, 0.45, 1.0) + if not resultsFrame.bgAtlasTex then + local tex = resultsFrame:CreateTexture(nil, "BACKGROUND", nil, -1) + tex:SetPoint("TOPLEFT", 4, -4) + tex:SetPoint("BOTTOMRIGHT", -4, 4) + resultsFrame.bgAtlasTex = tex + end + resultsFrame.bgAtlasTex:SetAtlas("QuestLog-main-background", false) + resultsFrame.bgAtlasTex:Show() + resultsFrame:SetClipsChildren(true) end end @@ -8707,8 +8647,16 @@ function MapSearch:SearchForUI(query) } end end + -- Wipe stale per-POI score/dedupe state. SearchPOIs trusts + -- poi.score on isZone entries as a shortcut (the "zone results + -- already scored by SearchZones" branch), so leftover scores + -- from a prior query (e.g. "raid" scored every instance 150) + -- would otherwise leak into the next unrelated query like "3v". for i = 1, #promotedInstancePOIs do local p = promotedInstancePOIs[i] + p.score = nil + p.duplicateKey = nil + p.allInstances = nil if not zoneNames[slower(p.name)] and not zoneNames[normalizeName(p.name)] then pois[#pois + 1] = p end @@ -8752,7 +8700,8 @@ function MapSearch:SearchForUI(query) icon = r.icon or GetCategoryIcon(cat), mapSearchResult = true, mapID = r.mapID or r.zoneMapID or r.entranceMapID or searchMapID, - zoneName = r.zoneName, + zoneName = r.zoneName or r.pathPrefix, + pathPrefix = r.pathPrefix, x = r.x or r.entranceX, y = r.y or r.entranceY, keywords = r.keywords, @@ -8791,7 +8740,7 @@ end -- Global results (zones, instances): -- Fast mode: open map directly and run SelectResult (highlight/waypoint). -- Standard mode: guide to open map, then run SelectResult. -function MapSearch:HandleUISearchClick(data) +function MapSearch:HandleUISearchClick(data, forceGuide) if not data then return end local isGlobalResult = data.isZone or data.isDungeonEntrance @@ -8809,13 +8758,16 @@ function MapSearch:HandleUISearchClick(data) if isGlobalResult then -- Open the world map at the target and show a waypoint/zone. - -- Guide/breadcrumb mode is reserved for an explicit right-click - -- → Guide flow; a plain UI-search click should always direct- - -- open so the user gets there in one action. + -- Guide/breadcrumb mode (forceGuide=true) walks parent zones via + -- breadcrumb arrows; fast mode jumps straight to the entrance. if not WorldMapFrame or not WorldMapFrame:IsShown() then ToggleWorldMap() end - if data.entranceMapID and data.entranceX and data.entranceY then + if forceGuide then + -- Reuse the MapSearch SelectResult path with directOverride=false + -- so breadcrumb/teaching mode kicks in (HighlightBreadcrumb...). + self:SelectResult(data, false) + elseif data.entranceMapID and data.entranceX and data.entranceY then WorldMapFrame:SetMapID(data.entranceMapID) self:ShowWaypointAt(data.entranceX, data.entranceY, data.entranceIcon or data.icon, data.entranceCategory or data.category) @@ -8823,15 +8775,14 @@ function MapSearch:HandleUISearchClick(data) WorldMapFrame:SetMapID(data.zoneMapID) end else - -- Local POI: place the tracked waypoint AND open the world map - -- at the POI's zone so the user can see where it is. Tracking- - -- without-opening was a holdover from the old fast-mode flow. + -- Local POI: open the world map at the POI's zone and show the + -- visual pin on the canvas. We deliberately do NOT call + -- SetUserWaypoint / SetSuperTrackedUserWaypoint here — the user + -- has to click the on-canvas pin's tracking icon to actually + -- start tracking, matching the way clicking the small map pin + -- works elsewhere in the addon (and Blizzard's own UI). local x, y = data.x, data.y if data.mapID and x and y and x >= 0 and x <= 1 and y >= 0 and y <= 1 then - SetUserWaypoint(UiMapPoint.CreateFromCoordinates(data.mapID, x, y)) - efPlacedWaypoint = true - C_SuperTrack.SetSuperTrackedUserWaypoint(true) - ShowSuperTrackGlow() activePinState = { mapID = data.mapID, x = x, y = y, @@ -8839,9 +8790,6 @@ function MapSearch:HandleUISearchClick(data) isLocal = true, } MapSearch:RefreshAllClearButtons() - -- Open the world map and ensure it's showing the POI's - -- zone, then drop the visual pin on the canvas. ShowWaypointAt - -- expects the canvas to be the matching map. if not WorldMapFrame or not WorldMapFrame:IsShown() then ToggleWorldMap() end diff --git a/Options.lua b/Options.lua index c277a64..b207991 100644 --- a/Options.lua +++ b/Options.lua @@ -921,30 +921,28 @@ function Options:Initialize() optionsFrame.colorSwatch = colorSwatch optionsFrame.colorFlyout = colorFlyout - local themeLabel = sec3:CreateFontString(nil, "OVERLAY", "GameFontNormal") - themeLabel:SetPoint("TOPLEFT", colorLabel, "BOTTOMLEFT", 0, -20) - themeLabel:SetText("Theme:") + local fontLabel = sec3:CreateFontString(nil, "OVERLAY", "GameFontNormal") + fontLabel:SetPoint("TOPLEFT", colorLabel, "BOTTOMLEFT", 0, -20) + fontLabel:SetText("Font:") - local themeChoices = {"Classic", "Retail"} - - local themeBtnFrame, themeBtnText = CreateFlyoutSelector( - sec3, "EasyFindTheme", 120, themeLabel, EasyFind.db.resultsTheme or "Retail" + local fontChoices = ns.FONT_CHOICES or {"Default", "Inter"} + local fontBtnFrame, fontBtnText = CreateFlyoutSelector( + sec3, "EasyFindFont", 120, fontLabel, EasyFind.db.font or "Default" ) - themeBtnFrame:ClearAllPoints() - themeBtnFrame:SetPoint("LEFT", themeLabel, "LEFT", BTN_OFFSET, 0) - local themeFlyout = CreateFlyoutPanel(themeBtnFrame, "EasyFindTheme", 120, #themeChoices) - AddFlyoutOptions(themeFlyout, themeChoices, 114, function(name) - EasyFind.db.resultsTheme = name - themeBtnText:SetText(name) - if ns.UI and ns.UI.RefreshResults then ns.UI:RefreshResults() end - if ns.MapSearch and ns.MapSearch.UpdateSearchBarTheme then ns.MapSearch:UpdateSearchBarTheme() end + fontBtnFrame:ClearAllPoints() + fontBtnFrame:SetPoint("LEFT", fontLabel, "LEFT", BTN_OFFSET, 0) + local fontFlyout = CreateFlyoutPanel(fontBtnFrame, "EasyFindFont", 120, #fontChoices) + AddFlyoutOptions(fontFlyout, fontChoices, 114, function(name) + EasyFind.db.font = name + fontBtnText:SetText(name) + if ns.RefreshAddonFont then ns.RefreshAddonFont() end end) - optionsFrame.themeBtnText = themeBtnText - optionsFrame.themeFlyout = themeFlyout + optionsFrame.fontBtnText = fontBtnText + optionsFrame.fontFlyout = fontFlyout local panelOpacitySlider = CreateSlider(sec3, "PanelOpacity", "Options Menu Opacity", 0.3, 1.0, 0.05, "Adjusts the opacity of the options panel background.", nil, 0.9) - panelOpacitySlider:SetPoint("TOPLEFT", themeLabel, "BOTTOMLEFT", 0, -30) + panelOpacitySlider:SetPoint("TOPLEFT", fontLabel, "BOTTOMLEFT", 0, -30) panelOpacitySlider:SetValue(EasyFind.db.panelOpacity or 0.9) panelOpacitySlider:HookScript("OnValueChanged", function(self, value) EasyFind.db.panelOpacity = value @@ -1019,9 +1017,29 @@ function Options:Initialize() end) resizeUIBtn:SetScript("OnLeave", GameTooltip_Hide) - local smartShowCheckbox = CreateCheckbox(sec1, "SmartShow", "Smart Show |cFF888888(Recommended)|r", - "When enabled, the UI search bar hides itself until you move your mouse near its position.\n\nThe bar reappears when your mouse enters the area and fades away when you move away.\n\nA separate Smart Show toggle for map search bars is available in the Map Search section.") - smartShowCheckbox:SetPoint("TOPLEFT", sec1, "TOPLEFT", 16, -38) + local autoHideCheckbox = CreateCheckbox(sec1, "AutoHide", "Auto-Hide |cFF888888(Recommended)|r", + "Raycast-style: the search bar starts hidden and only appears when you press the keybind. Clicking outside the bar, results, or filter menu hides it again, as does selecting a result.\n\nWhen enabled, Smart Show is ignored.") + autoHideCheckbox:SetPoint("TOPLEFT", sec1, "TOPLEFT", 16, -38) + autoHideCheckbox:SetChecked(EasyFind.db.autoHide ~= false) + autoHideCheckbox:SetScript("OnClick", function(self) + local on = self:GetChecked() + EasyFind.db.autoHide = on + if on then + if ns.UI and ns.UI.Hide then ns.UI:Hide() end + else + if ns.UI and ns.UI.Show then ns.UI:Show(false) end + end + if optionsFrame.smartShowCheckbox then + optionsFrame.smartShowCheckbox:SetEnabled(not on) + local lbl = optionsFrame.smartShowCheckbox:GetFontString() + if lbl then lbl:SetTextColor(on and 0.5 or 1, on and 0.5 or 1, on and 0.5 or 1) end + end + end) + optionsFrame.autoHideCheckbox = autoHideCheckbox + + local smartShowCheckbox = CreateCheckbox(sec1, "SmartShow", "Smart Show", + "When enabled, the UI search bar hides itself until you move your mouse near its position.\n\nThe bar reappears when your mouse enters the area and fades away when you move away.\n\nIgnored when Auto-Hide is enabled. A separate Smart Show toggle for map search bars is available in the Map Search section.") + smartShowCheckbox:SetPoint("TOPLEFT", autoHideCheckbox, "BOTTOMLEFT", 0, -2) smartShowCheckbox:SetChecked(EasyFind.db.smartShow or false) smartShowCheckbox:SetScript("OnClick", function(self) EasyFind.db.smartShow = self:GetChecked() @@ -1029,6 +1047,11 @@ function Options:Initialize() ns.UI:UpdateSmartShow() end end) + if EasyFind.db.autoHide ~= false then + smartShowCheckbox:SetEnabled(false) + local lbl = smartShowCheckbox:GetFontString() + if lbl then lbl:SetTextColor(0.5, 0.5, 0.5) end + end optionsFrame.smartShowCheckbox = smartShowCheckbox local staticOpacityCheckbox = CreateCheckbox(sec1, "StaticOpacity", "Static Opacity", @@ -1043,9 +1066,18 @@ function Options:Initialize() end) optionsFrame.staticOpacityCheckbox = staticOpacityCheckbox + local lockPositionCheckbox = CreateCheckbox(sec1, "LockPosition", "Lock Position", + "When enabled, the search bar can't be dragged. Useful if you've placed it exactly where you want and don't want to bump it by accident.\n\nReset Positions and the :reset command still work.") + lockPositionCheckbox:SetPoint("TOPLEFT", staticOpacityCheckbox, "BOTTOMLEFT", 0, -2) + lockPositionCheckbox:SetChecked(EasyFind.db.lockPosition or false) + lockPositionCheckbox:SetScript("OnClick", function(self) + EasyFind.db.lockPosition = self:GetChecked() + end) + optionsFrame.lockPositionCheckbox = lockPositionCheckbox + local uiResultsAboveCheckbox = CreateCheckbox(sec1, "UIResultsAbove", "UI Results Above", "When enabled, the UI search bar shows results above the bar instead of below.\n\nUseful if you place the search bar near the bottom of your screen.") - uiResultsAboveCheckbox:SetPoint("TOPLEFT", staticOpacityCheckbox, "BOTTOMLEFT", 0, -2) + uiResultsAboveCheckbox:SetPoint("TOPLEFT", lockPositionCheckbox, "BOTTOMLEFT", 0, -2) uiResultsAboveCheckbox:SetChecked(EasyFind.db.uiResultsAbove or false) uiResultsAboveCheckbox:SetScript("OnClick", function(self) EasyFind.db.uiResultsAbove = self:GetChecked() @@ -1055,7 +1087,7 @@ function Options:Initialize() local hideHeadersCheckbox = CreateCheckbox(sec1, "HideHeaders", "Flat Results (no headers)", "Show results as a single flat list with no category headers. Each row displays the entry name with its full path as subtext underneath.") hideHeadersCheckbox:SetPoint("TOPLEFT", uiResultsAboveCheckbox, "BOTTOMLEFT", 0, -2) - hideHeadersCheckbox:SetChecked(EasyFind.db.uiHideHeaders or false) + hideHeadersCheckbox:SetChecked(EasyFind.db.uiHideHeaders ~= false) hideHeadersCheckbox:SetScript("OnClick", function(self) EasyFind.db.uiHideHeaders = self:GetChecked() -- Re-run the search so results rebuild in the chosen layout mode. @@ -1093,7 +1125,7 @@ function Options:Initialize() StaticPopup_Show("EASYFIND_RESET_UI_POS") end) - uiControls = { resizeUIBtn, resetUIBtn, resetUIPosBtn, uiFontSlider, smartShowCheckbox, staticOpacityCheckbox, uiResultsAboveCheckbox, hideHeadersCheckbox } + uiControls = { resizeUIBtn, resetUIBtn, resetUIPosBtn, uiFontSlider, autoHideCheckbox, smartShowCheckbox, staticOpacityCheckbox, lockPositionCheckbox, uiResultsAboveCheckbox, hideHeadersCheckbox } UpdateUIToggleVisual() -- SECTION 3: Map Search @@ -1672,7 +1704,11 @@ function Options:DoResetAll() EasyFind.db.globalSearchPositionMax = nil EasyFind.db.mapSearchYOffset = 0 EasyFind.db.smartShow = false - EasyFind.db.resultsTheme = "Retail" + EasyFind.db.autoHide = true + EasyFind.db.lockPosition = false + EasyFind.db.tutorialDone = false + EasyFind.db.resultsTheme = "Modern" + EasyFind.db.font = "Default" EasyFind.db.uiResultsHeight = 280 EasyFind.db.mapResultsHeight = 168 EasyFind.db.pinsCollapsed = false @@ -1684,7 +1720,7 @@ function Options:DoResetAll() EasyFind.db.showLoginMessage = true EasyFind.db.uiResultsAbove = false EasyFind.db.mapResultsAbove = false - EasyFind.db.uiHideHeaders = false + EasyFind.db.uiHideHeaders = true EasyFind.db.showMinimapButton = true EasyFind.db.minimapButtonAngle = 200 EasyFind.db.arrivalDistance = 10 @@ -1704,7 +1740,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, rares = true } - EasyFind.db.uiSearchFilters = { ui = true, achievements = true, currencies = true, reputations = true, collections = true, mounts = false, toys = false, pets = false, outfits = false, loot = false, appearanceSets = false, map = false } + EasyFind.db.uiSearchFilters = { ui = true, achievements = true, currencies = true, reputations = true, collections = true, mounts = true, toys = true, pets = true, outfits = true, heirlooms = true, loot = true, appearanceSets = true, bags = true, macros = true, options = true, abilities = true, bosses = true, map = true } EasyFind.db.lootSpecs = nil -- nil = current spec only EasyFind.db.lootSearchSlots = true EasyFind.db.lootSearchStats = true @@ -1760,10 +1796,16 @@ function Options:DoResetAll() optionsFrame.opacitySlider:SetValue(DEFAULT_OPACITY) optionsFrame.uiFontSlider:SetValue(0.9) optionsFrame.smartShowCheckbox:SetChecked(false) + if optionsFrame.autoHideCheckbox then + optionsFrame.autoHideCheckbox:SetChecked(true) + end + if optionsFrame.lockPositionCheckbox then + optionsFrame.lockPositionCheckbox:SetChecked(false) + end optionsFrame.staticOpacityCheckbox:SetChecked(false) optionsFrame.loginMessageCheckbox:SetChecked(true) optionsFrame.uiResultsAboveCheckbox:SetChecked(false) - if optionsFrame.hideHeadersCheckbox then optionsFrame.hideHeadersCheckbox:SetChecked(false) end + if optionsFrame.hideHeadersCheckbox then optionsFrame.hideHeadersCheckbox:SetChecked(true) end optionsFrame.minimapBtnCheckbox:SetChecked(true) if optionsFrame.rareTrackCheckbox then optionsFrame.rareTrackCheckbox:SetChecked(false) end if optionsFrame.mapPinGroup then optionsFrame.mapPinGroup:UpdateVisuals() end @@ -1773,9 +1815,10 @@ function Options:DoResetAll() if optionsFrame.UpdateMapToggleVisual then optionsFrame.UpdateMapToggleVisual() end optionsFrame.arrivalSlider:SetValue(10) optionsFrame.circleScaleSlider:SetValue(1.0) - optionsFrame.themeBtnText:SetText("Retail") optionsFrame.indicatorBtnText:SetText("EasyFind Arrow") optionsFrame.colorBtnText:SetText("Yellow") + if optionsFrame.fontBtnText then optionsFrame.fontBtnText:SetText("Default") end + if ns.RefreshAddonFont then ns.RefreshAddonFont() end local defaultRGB = ns.INDICATOR_COLORS["Yellow"] optionsFrame.colorBtnText:SetTextColor(defaultRGB[1], defaultRGB[2], defaultRGB[3]) optionsFrame.colorSwatch:SetColorTexture(defaultRGB[1], defaultRGB[2], defaultRGB[3], 1) @@ -1816,6 +1859,8 @@ end function Options:DoResetUI() EasyFind.db.smartShow = false + EasyFind.db.autoHide = true + EasyFind.db.lockPosition = false EasyFind.db.staticOpacity = false EasyFind.db.uiResultsAbove = false EasyFind.db.fontSize = 0.9 @@ -1825,7 +1870,7 @@ function Options:DoResetUI() EasyFind.db.uiResultsWidth = 350 EasyFind.db.uiSearchPosition = nil EasyFind.db.uiResultsHeight = 280 - EasyFind.db.uiSearchFilters = { ui = true, achievements = true, currencies = true, reputations = true, collections = true, mounts = false, toys = false, pets = false, outfits = false, loot = false, appearanceSets = false, map = false } + EasyFind.db.uiSearchFilters = { ui = true, achievements = true, currencies = true, reputations = true, collections = true, mounts = true, toys = true, pets = true, outfits = true, heirlooms = true, loot = true, appearanceSets = true, bags = true, macros = true, options = true, abilities = true, bosses = true, map = true } EasyFind.db.lootSpecs = nil EasyFind.db.lootSearchSlots = true EasyFind.db.lootSearchStats = true @@ -1839,6 +1884,12 @@ function Options:DoResetUI() EasyFind.db.uiMapSearchLocal = true optionsFrame.smartShowCheckbox:SetChecked(false) + if optionsFrame.autoHideCheckbox then + optionsFrame.autoHideCheckbox:SetChecked(true) + end + if optionsFrame.lockPositionCheckbox then + optionsFrame.lockPositionCheckbox:SetChecked(false) + end optionsFrame.staticOpacityCheckbox:SetChecked(false) optionsFrame.uiResultsAboveCheckbox:SetChecked(false) optionsFrame.uiFontSlider:SetValue(0.9) @@ -2023,16 +2074,33 @@ function Options:Show() if optionsFrame.uiFontSlider then optionsFrame.uiFontSlider:SetValue(EasyFind.db.fontSize or 0.9) end if optionsFrame.mapIconSlider then optionsFrame.mapIconSlider:SetValue(EasyFind.db.iconScale or 0.8) end if optionsFrame.arrivalSlider then optionsFrame.arrivalSlider:SetValue(EasyFind.db.arrivalDistance or 10) end + if optionsFrame.autoHideCheckbox then + optionsFrame.autoHideCheckbox:SetChecked(EasyFind.db.autoHide ~= false) + end optionsFrame.smartShowCheckbox:SetChecked(EasyFind.db.smartShow or false) + do + local autoOn = EasyFind.db.autoHide ~= false + optionsFrame.smartShowCheckbox:SetEnabled(not autoOn) + local lbl = optionsFrame.smartShowCheckbox:GetFontString() + if lbl then + local g = autoOn and 0.5 or 1 + lbl:SetTextColor(g, g, g) + end + end optionsFrame.staticOpacityCheckbox:SetChecked(EasyFind.db.staticOpacity or false) + if optionsFrame.lockPositionCheckbox then + optionsFrame.lockPositionCheckbox:SetChecked(EasyFind.db.lockPosition or false) + end optionsFrame.loginMessageCheckbox:SetChecked(EasyFind.db.showLoginMessage ~= false) optionsFrame.uiResultsAboveCheckbox:SetChecked(EasyFind.db.uiResultsAbove or false) optionsFrame.minimapBtnCheckbox:SetChecked(EasyFind.db.showMinimapButton or false) if optionsFrame.mapPinGroup then optionsFrame.mapPinGroup:UpdateVisuals() end if optionsFrame.minimapGroup then optionsFrame.minimapGroup:UpdateVisuals() end if optionsFrame.automationGroup then optionsFrame.automationGroup:UpdateVisuals() end - optionsFrame.themeBtnText:SetText(EasyFind.db.resultsTheme or "Retail") optionsFrame.indicatorBtnText:SetText(EasyFind.db.indicatorStyle or "EasyFind Arrow") + if optionsFrame.fontBtnText then + optionsFrame.fontBtnText:SetText(EasyFind.db.font or "Default") + end local clr = EasyFind.db.indicatorColor or "Yellow" local rgb = ns.INDICATOR_COLORS[clr] or ns.INDICATOR_COLORS.Yellow optionsFrame.colorBtnText:SetText(clr) diff --git a/Rescaler.lua b/Rescaler.lua index f2f014b..d2efae4 100644 --- a/Rescaler.lua +++ b/Rescaler.lua @@ -240,7 +240,7 @@ end -- Preview results (fake rows to show results area) -local function CreatePreviewResults(parent, targetFrame, width, heightPx, anchorAbove, leftAligned) +local function CreatePreviewResults(parent, targetFrame, width, heightPx, anchorAbove, leftAligned, flushDock) local fontScale = GetFontScale() local rowH = PREVIEW_ROW_H * fontScale local frame = CreateFrame("Frame", nil, parent, "BackdropTemplate") @@ -249,17 +249,18 @@ local function CreatePreviewResults(parent, targetFrame, width, heightPx, anchor frame:SetWidth(width) frame:SetHeight(heightPx) + local gap = flushDock and 0 or 2 if leftAligned then if anchorAbove then - frame:SetPoint("BOTTOMLEFT", targetFrame, "TOPLEFT", 0, 2) + frame:SetPoint("BOTTOMLEFT", targetFrame, "TOPLEFT", 0, gap) else - frame:SetPoint("TOPLEFT", targetFrame, "BOTTOMLEFT", 0, -2) + frame:SetPoint("TOPLEFT", targetFrame, "BOTTOMLEFT", 0, -gap) end else if anchorAbove then - frame:SetPoint("BOTTOM", targetFrame, "TOP", 0, 2) + frame:SetPoint("BOTTOM", targetFrame, "TOP", 0, gap) else - frame:SetPoint("TOP", targetFrame, "BOTTOM", 0, -2) + frame:SetPoint("TOP", targetFrame, "BOTTOM", 0, -gap) end end @@ -706,24 +707,25 @@ function Rescaler:Enter(mode) searchBar:Show() searchBar:SetAlpha(1.0) - getBarWidth = function() return searchBar:GetWidth() end - setBarWidth = function(w) + -- UI mode is the unified rounded-pill design: bar + results + -- share one rounded silhouette and one width. Both setters + -- below drive the same width so the bar overlay and results + -- overlay handles all stay in sync. + local function setUiWidth(w) w = ClampWidth(w) searchBar:SetWidth(w) EasyFind.db.uiSearchWidth = w / 250 + EasyFind.db.uiResultsWidth = w + if resultsFrame then resultsFrame:SetWidth(w) end end + getBarWidth = function() return searchBar:GetWidth() end + setBarWidth = setUiWidth + getResultsScale = function() return EasyFind.db.uiResultsScale or 1.0 end - getResultsWidth = function() - if resultsFrame then return resultsFrame:GetWidth() end - return 380 - end - setResultsWidth = function(w) - w = ClampWidth(w) - EasyFind.db.uiResultsWidth = w - if resultsFrame then resultsFrame:SetWidth(w) end - end + getResultsWidth = function() return searchBar:GetWidth() end + setResultsWidth = setUiWidth elseif mode == "map" then -- Open the world map if not already visible (search bars are anchored to it) @@ -789,7 +791,10 @@ function Rescaler:Enter(mode) local previewW = getResultsWidth() local currentH = mmin(GetResultsHeight(), GetScreenMaxHeight(resultsAbove)) local leftAligned = (mode == "map") - previewResults = CreatePreviewResults(bg, searchBar, previewW, currentH, resultsAbove, leftAligned) + -- UI mode is the unified pill: dock the preview flush against the + -- bar (zero gap) so width changes look continuous. + local flushDock = (mode == "ui") + previewResults = CreatePreviewResults(bg, searchBar, previewW, currentH, resultsAbove, leftAligned, flushDock) previewResults:SetScale(getResultsScale()) previewResults:Show() @@ -816,15 +821,18 @@ function Rescaler:Enter(mode) resultsOverlay.heightInside = true end - -- Wire bar width drag - SetupWidthDrag(barOverlay.leftHandle, getBarWidth, setBarWidth, barOverlay.widthBox, "LEFT") - SetupWidthDrag(barOverlay.rightHandle, getBarWidth, setBarWidth, barOverlay.widthBox, "RIGHT") - WireDimLabel(barOverlay.widthBox, getBarWidth, function(v) - setBarWidth(v) - end) + -- Wire bar width drag. In UI mode the preview is the live results + -- area; resize it in lockstep so the unified pill stays continuous. + local function setBarWidthAndPreview(w) + setBarWidth(w) + if mode == "ui" then previewResults:SetWidth(searchBar:GetWidth()) end + end + SetupWidthDrag(barOverlay.leftHandle, getBarWidth, setBarWidthAndPreview, barOverlay.widthBox, "LEFT") + SetupWidthDrag(barOverlay.rightHandle, getBarWidth, setBarWidthAndPreview, barOverlay.widthBox, "RIGHT") + WireDimLabel(barOverlay.widthBox, getBarWidth, setBarWidthAndPreview) AddResetButton(barOverlay.widthBox, function() local defW = 250 * 0.88 -- 220px (matches DB_DEFAULTS searchWidth = 0.88) - setBarWidth(defW) + setBarWidthAndPreview(defW) barOverlay.widthBox:SetText(mfloor(defW + 0.5)) end) @@ -855,25 +863,37 @@ function Rescaler:Enter(mode) end) SetupFontDrag(barOverlay.bottomHandle, barOverlay.fontBox, previewResults) - -- Wire results width drag - SetupWidthDrag(resultsOverlay.leftHandle, getResultsWidth, function(w) - setResultsWidth(w) - previewResults:SetWidth(w) - end, resultsOverlay.widthBox, "LEFT") - SetupWidthDrag(resultsOverlay.rightHandle, getResultsWidth, function(w) - setResultsWidth(w) - previewResults:SetWidth(w) - end, resultsOverlay.widthBox, "RIGHT") - WireDimLabel(resultsOverlay.widthBox, getResultsWidth, function(v) - setResultsWidth(v) - previewResults:SetWidth(v) - end) - AddResetButton(resultsOverlay.widthBox, function() - local defW = 300 - setResultsWidth(defW) - previewResults:SetWidth(defW) - resultsOverlay.widthBox:SetText(mfloor(defW + 0.5)) - end) + -- Wire results width drag. UI mode locks results width to bar + -- width (single rounded pill), so the results overlay's width + -- handles + width box are redundant -- hide them entirely. The + -- bar's width handles already update the preview width via + -- setBarWidth above. + if mode == "ui" then + resultsOverlay.leftHandle:Hide() + resultsOverlay.rightHandle:Hide() + resultsOverlay.widthBox:Hide() + if resultsOverlay.widthBox.prefix then resultsOverlay.widthBox.prefix:Hide() end + if resultsOverlay.widthBox.suffix then resultsOverlay.widthBox.suffix:Hide() end + else + SetupWidthDrag(resultsOverlay.leftHandle, getResultsWidth, function(w) + setResultsWidth(w) + previewResults:SetWidth(w) + end, resultsOverlay.widthBox, "LEFT") + SetupWidthDrag(resultsOverlay.rightHandle, getResultsWidth, function(w) + setResultsWidth(w) + previewResults:SetWidth(w) + end, resultsOverlay.widthBox, "RIGHT") + WireDimLabel(resultsOverlay.widthBox, getResultsWidth, function(v) + setResultsWidth(v) + previewResults:SetWidth(v) + end) + AddResetButton(resultsOverlay.widthBox, function() + local defW = 300 + setResultsWidth(defW) + previewResults:SetWidth(defW) + resultsOverlay.widthBox:SetText(mfloor(defW + 0.5)) + end) + end -- Wire results corner (width + height combo) resultsOverlay.heightBox:SetText(currentH) @@ -909,10 +929,16 @@ function Rescaler:Enter(mode) heightReset:SetPoint("BOTTOM", resultsOverlay.heightBox, "TOP", 0, 2) end end - SetupCornerDrag(resultsOverlay.scaleHandle, previewResults, getResultsWidth, function(w) - setResultsWidth(w) - previewResults:SetWidth(w) - end, resultsOverlay.widthBox, resultsOverlay.heightBox, resultsAbove) + if mode == "ui" then + -- Width is locked to the bar in UI mode; the corner handle + -- only changes height. Reuse the height-only handler. + SetupHeightDrag(resultsOverlay.scaleHandle, previewResults, resultsOverlay.heightBox, resultsAbove) + else + SetupCornerDrag(resultsOverlay.scaleHandle, previewResults, getResultsWidth, function(w) + setResultsWidth(w) + previewResults:SetWidth(w) + end, resultsOverlay.widthBox, resultsOverlay.heightBox, resultsAbove) + end -- Wire results bottom edge (height) SetupHeightDrag(resultsOverlay.heightHandle, previewResults, resultsOverlay.heightBox, resultsAbove) diff --git a/UI.lua b/UI.lua index bf0e99c..94a8ba7 100644 --- a/UI.lua +++ b/UI.lua @@ -11,6 +11,8 @@ local select, ipairs, pairs = Utils.select, Utils.ipairs, Utils.pairs local sfind, slower = Utils.sfind, Utils.slower local tinsert, tconcat, tremove, tsort = Utils.tinsert, Utils.tconcat, Utils.tremove, Utils.tsort local mmin, mmax = Utils.mmin, Utils.mmax +local mfloor = Utils.mfloor +local sformat = Utils.sformat local GOLD_COLOR = ns.GOLD_COLOR local DEFAULT_OPACITY = ns.DEFAULT_OPACITY @@ -65,7 +67,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", "petID", "speciesID", "outfitID", + "mountID", "spellID", "toyItemID", "petID", "speciesID", "outfitID", "heirloomItemID", + "macroIndex", "macroIsChar", "bagID", "bagSlot", "bagItemLink", "itemID", "encounterID", "instanceID", "lootSlotName", "lootSourceName", "lootInstanceName", "lootSourceType", "transmogSetID", "factionID", "hasRepBar", "canQueue", "isPvP", "isPvE"} @@ -105,6 +108,7 @@ end -- All other pins are account-wide. local function IsCollectionPin(data) return data and (data.mountID or data.toyItemID or data.petID or data.outfitID + or data.heirloomItemID or (data.itemID and data.category == "Loot")) end @@ -118,11 +122,53 @@ local FLAT_CATEGORY_ICONS = { toy = { tex = 454046 }, pet = { tex = 631719 }, outfit = { tex = 132649 }, + heirloom = { tex = 133877 }, appearanceSet = { tex = "Interface\\Icons\\INV_Helmet_03" }, currency = { tex = 136452 }, -- Same coin/AH glyph the map uses reputation = { tex = 1121272, coords = { 0.3783, 0.4072, 0.9066, 0.9350 } }, + map = { tex = 1121272, coords = { 0.3457, 0.3856, 0.2549, 0.2951 } }, + -- Ability / boss: matches the filter-menu icons (boss tab + overview tab + -- glyphs from the Encounter Journal spritesheet). The row's per-entry + -- icon (spell icon / boss portrait) is pushed to the RIGHT side. + ability = { tex = 522972, coords = { 0.904, 0.996, 0.707, 0.748 } }, + boss = { tex = 522972, coords = { 0.855, 0.949, 0.524, 0.566 } }, + macro = { tex = "Interface\\MacroFrame\\MacroFrame-Icon" }, + bag = { atlas = "bag-main" }, + loot = { tex = 522972, coords = { 0.730, 0.824, 0.618, 0.660 } }, + setting = { atlas = "QuestLog-icon-setting" }, + -- Addon settings get a warm tint so they're distinguishable at a + -- glance from the silvery-grey game-settings cogwheel. + settingAddon = { atlas = "QuestLog-icon-setting", color = { 1.0, 0.78, 0.35 } }, + title = { tex = 514608, coords = { 0.016, 0.531, 0.324, 0.461 } }, + -- Resolved lazily from PaperDollSidebarTab3 so the icon always + -- matches whatever sprite-sheet region Blizzard uses for the + -- Equipment Manager sidebar tab. Filled in by ResolveGearSetIcon(). + gearSet = { atlas = "equipmentmanager-spec-border" }, } +local function ResolveGearSetIcon() + local entry = FLAT_CATEGORY_ICONS.gearSet + if entry and entry._resolved then return end + local tab = _G["PaperDollSidebarTab3"] + if not tab or not tab.GetRegions then return end + for ri = 1, select("#", tab:GetRegions()) do + local region = select(ri, tab:GetRegions()) + if region and region:GetObjectType() == "Texture" + and region:GetDrawLayer() == "ARTWORK" then + local tex = region:GetTexture() + if tex and not (type(tex) == "string" and tex:find("^RT")) then + local ulX, ulY, _, _, _, _, lrX, lrY = region:GetTexCoord() + FLAT_CATEGORY_ICONS.gearSet = { + tex = tex, + coords = { ulX, lrX, ulY, lrY }, + _resolved = true, + } + end + return + end + end +end + -- Reputation icon by faction side. Either-faction (nil) uses the same -- crest as the filter button; Alliance/Horde get their faction-specific -- crests. All cropped from the shared 1121272 sprite sheet. @@ -138,11 +184,25 @@ local function GetFlatCategoryIcon(data) if data.toyItemID then return FLAT_CATEGORY_ICONS.toy end if data.petID then return FLAT_CATEGORY_ICONS.pet end if data.outfitID then return FLAT_CATEGORY_ICONS.outfit end + if data.heirloomItemID then return FLAT_CATEGORY_ICONS.heirloom end if data.transmogSetID then return FLAT_CATEGORY_ICONS.appearanceSet end + if data.spellID and data.category == "Ability" then return FLAT_CATEGORY_ICONS.ability end + if data.encounterID and data.category == "Boss" then return FLAT_CATEGORY_ICONS.boss end + if data.macroIndex and data.category == "Macro" then return FLAT_CATEGORY_ICONS.macro end + if data.bagID and data.category == "Bag" then return FLAT_CATEGORY_ICONS.bag end + if data.itemID and data.category == "Loot" then return FLAT_CATEGORY_ICONS.loot end + if data.category == "Game Settings" then return FLAT_CATEGORY_ICONS.setting end + if data.category == "AddOn Settings" then return FLAT_CATEGORY_ICONS.settingAddon end if data.category == "Currency" then return FLAT_CATEGORY_ICONS.currency end + if data.titleID then return FLAT_CATEGORY_ICONS.title end + if data.gearSetID then + ResolveGearSetIcon() + return FLAT_CATEGORY_ICONS.gearSet + end if data.category == "Reputation" and data.factionID then return REP_FACTION_ICONS[data.factionSide or "either"] end + if data.mapSearchResult then return FLAT_CATEGORY_ICONS.map end return nil end @@ -155,19 +215,109 @@ local function GetFlatSubtext(data) return tconcat(data.path, " > ") end if data.mapSearchResult then - return data.zoneName or "Map" + local cat = data.category + local typeLabel + if cat == "dungeon" then typeLabel = "Dungeon" + elseif cat == "raid" then typeLabel = "Raid" + elseif cat == "delve" then typeLabel = "Delve" + end + -- Use only the immediate parent zone, not the full continent path. + -- pathPrefix can be "Continent > Region > Zone"; take the last segment. + local zone = data.zoneName or data.pathPrefix + if zone then + local lastSep = zone:find(">[^>]*$") + if lastSep then + zone = zone:sub(lastSep + 1):match("^%s*(.-)%s*$") + end + end + if typeLabel and zone and zone ~= "" then + return typeLabel .. ": " .. zone + elseif typeLabel then + return typeLabel + end + return zone or "Map" end if data.mountID then return "Mount" end if data.toyItemID then return "Toy" end if data.petID then return "Pet" end if data.outfitID then return "Outfit" end + if data.heirloomItemID then return "Heirloom" end if data.transmogSetID then return "Appearance Set" end if data.itemID and data.category == "Loot" then return data.lootInstanceName or "Loot" end + if data.category == "Ability" and data.treeName and data.treeName ~= "" then + return data.treeName .. " Ability" + end return data.category or "" end +-- Hint shown only on the currently-selected row, replacing the normal +-- subtext so the user knows what Enter / left-click will do without +-- cluttering every other row. Returns nil for entries whose action +-- isn't worth labelling (UI navigation, settings — the row name itself +-- already tells you what happens). +local function GetActionHint(data) + if not data then return nil end + if data.titleID then return "Select to apply as your title" end + if data.mountID then return "Select to summon mount" end + if data.petID then return "Select to summon pet" end + if data.toyItemID then return "Select to use toy" end + if data.heirloomItemID then return "Select to add heirloom to bags" end + if data.outfitID then return "Select to wear outfit" end + if data.gearSetID then return "Select to equip gear set" end + if data.transmogSetID then return "Select to preview appearance set" end + if data.spellID and data.category == "Ability" then return "Select to cast" end + if data.macroIndex then return "Select to run macro" end + if data.itemID and data.category == "Bag" then + if data.equipLoc and data.equipLoc ~= "" then + return "Select to equip item" + end + return "Select to use item" + end + if data.mapSearchResult then + if data.isZone then return "Select to open map to location" end + return "Select to pin location on map" + end + if data.encounterID and data.category == "Boss" then + return "Select to open Encounter Journal" + end + if data.settingType == "dropdown" and data.settingVariable then + return "Select to cycle value" + end + if data.settingType == "checkbox" and data.settingVariable then + return "Select to toggle" + end + return nil +end + +-- Tracks the row currently displaying an action hint so we can restore +-- its normal subtext when selection moves away. +local actionHintRow + +-- Restore the canonical pathSubtext on the row currently showing a hint. +local function ClearActionHint() + if actionHintRow and actionHintRow.pathSubtext then + actionHintRow.pathSubtext:SetText(GetFlatSubtext(actionHintRow.data)) + actionHintRow.pathSubtext:SetTextColor(0.55, 0.55, 0.55, 1.0) + end + actionHintRow = nil +end + +-- Apply the action hint to a row's pathSubtext if one exists for its +-- data. Restores any previously hinted row first so only one row carries +-- a hint at a time. +local function ApplyActionHint(row) + if not row or not row.pathSubtext or not row.pathSubtext:IsShown() then return end + local hint = GetActionHint(row.data) + if not hint then return end + if actionHintRow == row then return end + ClearActionHint() + row.pathSubtext:SetText(hint) + row.pathSubtext:SetTextColor(0.85, 0.78, 0.55, 1.0) + actionHintRow = row +end + local charKey -- "Name-Realm", set on first use local function GetCharKey() if not charKey then @@ -337,32 +487,11 @@ local function CreatePinMenuRow(parent) end -- Hide when the cursor leaves the popup's bounding box. A child row's OnLeave --- fires when the cursor crosses to a sibling row too, so IsMouseOver on the --- parent discriminates "left the menu entirely" from "moved between rows". -local function pinPopupRowOnLeave() - if pinPopup and not pinPopup:IsMouseOver() then pinPopup:Hide() end -end - -local pinPopupBlocker local function ShowPinPopup(btn, isPinned, onPinAction, onGuide, onAddAlias) - if not pinPopupBlocker then - -- Full-screen click sink one level below the popup. Any click - -- not on the popup dismisses; the click is consumed (single- - -- click dismissal, click again for next action). - pinPopupBlocker = CreateFrame("Button", nil, UIParent) - pinPopupBlocker:SetFrameStrata("TOOLTIP") - pinPopupBlocker:SetFrameLevel(9999) - pinPopupBlocker:SetAllPoints(UIParent) - pinPopupBlocker:RegisterForClicks("AnyDown") - pinPopupBlocker:SetScript("OnClick", function() - if pinPopup then pinPopup:Hide() end - end) - pinPopupBlocker:Hide() - end if not pinPopup then pinPopup = CreateFrame("Frame", "EasyFindPinPopup", UIParent, "BackdropTemplate") - pinPopup:SetFrameStrata("TOOLTIP") - pinPopup:SetFrameLevel(10000) + pinPopup:SetFrameStrata("FULLSCREEN_DIALOG") + pinPopup:SetToplevel(true) pinPopup:EnableMouse(true) pinPopup:SetBackdrop({ bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", @@ -379,29 +508,42 @@ local function ShowPinPopup(btn, isPinned, onPinAction, onGuide, onAddAlias) pinPopup.aliasRow = CreatePinMenuRow(pinPopup) pinPopup.aliasRow.label:SetText("Add Alias") - pinPopup.pinRow:SetScript("OnLeave", pinPopupRowOnLeave) - pinPopup.guideRow:SetScript("OnLeave", pinPopupRowOnLeave) - pinPopup.aliasRow:SetScript("OnLeave", pinPopupRowOnLeave) - - -- Dismiss on any click outside the popup itself. Both - -- GLOBAL_MOUSE_DOWN and GLOBAL_MOUSE_UP are registered: - -- DOWN catches the press from any other frame (most common - -- case), and UP is the safety net in case a higher-strata - -- frame somewhere ate the DOWN event before our handler ran. - -- The first click after Show would otherwise be the - -- right-click that opened the popup itself, which fires - -- before OnShow registers — so we also gate on a tiny grace - -- window to skip that initial event. + -- Continuous outside-cursor poll: hides the popup once the + -- cursor has been outside for a short grace window. Combined + -- with GLOBAL_MOUSE_DOWN/UP for instant click-out dismissal + -- (with a small post-show grace so the right-click that + -- opened the popup doesn't immediately close it). pinPopup:SetScript("OnShow", function(self) self._showedAt = GetTime() + self._outsideSince = nil + self._hasEntered = false self:RegisterEvent("GLOBAL_MOUSE_DOWN") self:RegisterEvent("GLOBAL_MOUSE_UP") - if pinPopupBlocker then pinPopupBlocker:Show() end end) pinPopup:SetScript("OnHide", function(self) + self._outsideSince = nil + self._hasEntered = false self:UnregisterEvent("GLOBAL_MOUSE_DOWN") self:UnregisterEvent("GLOBAL_MOUSE_UP") - if pinPopupBlocker then pinPopupBlocker:Hide() end + end) + pinPopup:SetScript("OnUpdate", function(self) + if self:IsMouseOver() then + self._outsideSince = nil + self._hasEntered = true + return + end + -- Don't start the outside timer until the cursor has reached + -- the popup at least once. Otherwise the popup can close + -- before the user has a chance to move the mouse onto it. + if not self._hasEntered then return end + local now = GetTime() + if not self._outsideSince then + self._outsideSince = now + return + end + if now - self._outsideSince > 0.3 then + self:Hide() + end end) pinPopup:SetScript("OnEvent", function(self, event) if event ~= "GLOBAL_MOUSE_DOWN" and event ~= "GLOBAL_MOUSE_UP" then @@ -427,20 +569,19 @@ local function ShowPinPopup(btn, isPinned, onPinAction, onGuide, onAddAlias) -- Pin/Unpin, Add Alias (if applicable). Each anchor chains off -- the previous visible row so dropping one shifts the rest up. local rowsShown = 0 + local lastStackedRow local function StackRow(row) row:ClearAllPoints() if rowsShown == 0 then row:SetPoint("TOPLEFT", pinPopup, "TOPLEFT", 4, -4) row:SetPoint("TOPRIGHT", pinPopup, "TOPRIGHT", -4, -4) else - local prev = (rowsShown == 1 and pinPopup.guideRow:IsShown()) and pinPopup.guideRow - or (rowsShown == 2 and pinPopup.pinRow:IsShown()) and pinPopup.pinRow - or pinPopup.guideRow - row:SetPoint("TOPLEFT", prev, "BOTTOMLEFT", 0, 0) - row:SetPoint("TOPRIGHT", prev, "BOTTOMRIGHT", 0, 0) + row:SetPoint("TOPLEFT", lastStackedRow, "BOTTOMLEFT", 0, 0) + row:SetPoint("TOPRIGHT", lastStackedRow, "BOTTOMRIGHT", 0, 0) end row:Show() rowsShown = rowsShown + 1 + lastStackedRow = row end if onGuide then @@ -479,6 +620,25 @@ end -- Centralized icon setter - resets texture state before applying to prevent -- atlas/texture bleed between rows. local function SetRowIcon(btn, kind, value, iconSize) + -- Cache the (kind, value, size) we last applied. The hot path on + -- every keystroke re-renders rows whose icon hasn't changed at all + -- (incremental narrowing keeps top-N rows the same), so skipping + -- the SetTexture / SetAtlas / SetTexCoord burst when nothing + -- changed is one of the bigger per-keystroke wins. + local sz = iconSize or 16 + -- Cache key is the raw value (numeric fileID or the full coords + -- table). Table identity is what we want: two distinct sprite-sheet + -- entries with the same fileID but different coords are distinct + -- tables, so they correctly miss the cache. + if btn._iconKind == kind and btn._iconValKey == value and btn._iconSize == sz then + if kind == "hidden" then return end + btn.icon:Show() + return + end + btn._iconKind = kind + btn._iconValKey = value + btn._iconSize = sz + btn.icon:SetTexture(nil) btn.icon:SetTexCoord(0, 1, 0, 1) btn.icon:SetVertexColor(1, 1, 1, 1) @@ -488,6 +648,8 @@ local function SetRowIcon(btn, kind, value, iconSize) btn.icon.petID = nil btn.icon.spellID = nil btn.icon.outfitID = nil + btn.icon.heirloomItemID = nil + btn.icon.bagItemID = nil btn.icon.lootItemID = nil if btn.iconCooldown then btn.iconCooldown:Hide() end if btn._lockOverlay then btn._lockOverlay:Hide() end @@ -507,7 +669,7 @@ local function SetRowIcon(btn, kind, value, iconSize) btn.icon:Hide() return end - btn.icon:SetSize(iconSize or 16, iconSize or 16) + btn.icon:SetSize(sz, sz) btn.icon:Show() end @@ -516,55 +678,13 @@ 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 +local activeKeybindBtn -- THEME DEFINITIONS local THEMES = {} --- Classic: colorful tree connectors, +/- icons, gold leaf text -THEMES["Classic"] = { - rowHeight = 18, - indentPx = 20, - lineWidth = 2, - resultsWidth = 350, - resultsPadTop = 8, - resultsPadBot = 8, - btnWidth = 360, - iconSize = 16, - pathIconSize = 14, - -- fonts - pathFont = ns.SEARCHBAR_FONT, - leafFont = ns.LEAF_FONT, - pathColor = {0.7, 0.7, 0.7}, - leafColor = GOLD_COLOR, - -- tree lines - showTreeLines = true, - indentColors = { - {0.40, 0.85, 1.00, 0.80}, - {1.00, 0.55, 0.10, 0.80}, - {0.55, 1.00, 0.35, 0.80}, - {1.00, 0.40, 0.70, 0.80}, - {0.70, 0.55, 1.00, 0.80}, - {1.00, 0.90, 0.20, 0.80}, - }, - -- icons for collapse/expand - expandIcon = "Interface\\Buttons\\UI-PlusButton-Up", - collapseIcon = "Interface\\Buttons\\UI-MinusButton-Up", - -- highlight - highlightTex = "Interface\\QuestFrame\\UI-QuestTitleHighlight", - selectionColor = {0.3, 0.6, 1.0, 0.4}, - -- header bar (disabled in classic) - showHeaderBar = false, - -- results backdrop - resultsBackdrop = { - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", - edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", - tile = true, tileSize = 32, edgeSize = 20, - insets = { left = 5, right = 5, top = 5, bottom = 5 }, - }, -} - --- Retail: quest-log style - raised tab headers, golden tree lines, grey border -THEMES["Retail"] = { +-- Modern: quest-log style - raised tab headers, golden tree lines, grey border +THEMES["Modern"] = { rowHeight = 22, indentPx = 20, -- matches INDENT_PX so tree lines align lineWidth = 2, @@ -625,7 +745,7 @@ THEMES["Retail"] = { } local function GetActiveTheme() - return THEMES[EasyFind.db.resultsTheme or "Classic"] or THEMES["Classic"] + return THEMES["Modern"] end function UI:CreateUnearnedTooltip() @@ -666,9 +786,10 @@ function UI:Initialize() self:CreateResultsFrame() self:RegisterCombatEvents() - if EasyFind.db.visible ~= false then + if EasyFind.db.autoHide then + searchFrame:Hide() + elseif EasyFind.db.visible ~= false then searchFrame:Show() - -- Apply smart show on startup if EasyFind.db.smartShow then searchFrame.hoverZone:Show() searchFrame:SetAlpha(0) @@ -703,10 +824,23 @@ function UI:Initialize() end) end) - -- First-time setup overlay for new installs - if EasyFind.db.firstInstall and not EasyFind.db.setupComplete then - C_Timer.After(0.3, function() self:ShowFirstTimeSetup() end) + -- First-run wizard for new installs (sleek central modal, no in-place + -- overlay). Bar stays hidden until the user finishes the tutorial. + if not EasyFind.db.tutorialDone then + C_Timer.After(0.3, function() + if ns.Wizard and ns.Wizard.Show then ns.Wizard:Show() end + end) end + + -- Prewarm: 3s after login (after dynamic data has finished loading) + -- run a throwaway search so the first-char inverted index gets + -- built and the per-bucket scratch tables get allocated. Without + -- this, the user's first real keystroke pays a 100ms+ cold tax. + C_Timer.After(3, function() + if ns.Database and ns.Database.SearchUI then + ns.Database:SearchUI("zz") + end + end) end function UI:RegisterCombatEvents() @@ -721,15 +855,16 @@ function UI:RegisterCombatEvents() searchFrame.editBox:ClearFocus() elseif event == "PLAYER_REGEN_ENABLED" then inCombat = false - if EasyFind.db.visible ~= false then - searchFrame:Show() - if EasyFind.db.smartShow then - searchFrame.hoverZone:Show() - searchFrame:SetAlpha(0) - searchFrame.setSmartShowVisible(false) - end - else - if EasyFind.db.smartShow then + -- autoHide stays hidden after combat; reopens via bind. + if not EasyFind.db.autoHide then + if EasyFind.db.visible ~= false then + searchFrame:Show() + if EasyFind.db.smartShow then + searchFrame.hoverZone:Show() + searchFrame:SetAlpha(0) + searchFrame.setSmartShowVisible(false) + end + elseif EasyFind.db.smartShow then searchFrame.hoverZone:Show() end end @@ -787,26 +922,14 @@ function UI:CreateSearchFrame() end) if not searchFrame:IsShown() then containerFrame:Hide() end - if theme.searchBarRounded then - searchFrame:SetBackdrop(nil) - -- Pill on searchFrame is hidden; the container provides the - -- visual now. Pill setup is still kept (CreateSearchBorder - -- above) so anything that pokes searchFrame.searchBorder - -- doesn't crash, but the textures stay invisible. - ns.SetSearchBorderShown(searchFrame, false) - ns.SetRoundedRectBorderShown(containerFrame, true) - ns.SetRoundedRectBorderBgAlpha(containerFrame, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) - else - searchFrame:SetBackdrop({ - bgFile = WHITE8x8, - edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", - edgeSize = 20, - insets = { left = 5, right = 5, top = 5, bottom = 5 } - }) - searchFrame:SetBackdropColor(0, 0, 0, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) - ns.SetSearchBorderShown(searchFrame, false) - ns.SetRoundedRectBorderShown(containerFrame, false) - end + searchFrame:SetBackdrop(nil) + -- Pill on searchFrame is hidden; the container provides the + -- visual now. Pill setup is still kept (CreateSearchBorder + -- above) so anything that pokes searchFrame.searchBorder + -- doesn't crash, but the textures stay invisible. + ns.SetSearchBorderShown(searchFrame, false) + ns.SetRoundedRectBorderShown(containerFrame, true) + ns.SetRoundedRectBorderBgAlpha(containerFrame, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) -- Static magnifying-glass icon (non-interactive, flush left) local contentSz = ns.SEARCHBAR_HEIGHT * ns.SEARCHBAR_FILL @@ -839,32 +962,41 @@ function UI:CreateSearchFrame() editBox:SetAutoFocus(false) editBox:SetMaxLetters(50) - -- Block focus when Shift is held (shift = drag, not type) unless already typing - editBox:HookScript("OnMouseDown", function(self) - if IsShiftKeyDown() and not self:HasFocus() then - self.blockFocus = true - searchFrame:StartMoving() - end + -- Editbox click handling: plain left-click focuses the input + -- (native behavior). Shift+left-click drags the bar -- the + -- editbox consumes presses so the parent's RegisterForDrag never + -- fires over the input area; the drag has to live here. + editBox:HookScript("OnMouseDown", function(self, button) if searchFrame.setupMode then self.blockFocus = true + return end + if button ~= "LeftButton" then return end + if EasyFind.db.lockPosition then return end + if not IsShiftKeyDown() then return end + self.blockFocus = true + self._dragMoving = true + if self:HasFocus() then self:ClearFocus() end + searchFrame:StartMoving() end) editBox:HookScript("OnMouseUp", function(self) - self.blockFocus = nil - if searchFrame:IsMovable() then + if self._dragMoving and searchFrame:IsMovable() then searchFrame:StopMovingOrSizing() local point, _, relPoint, x, y = searchFrame:GetPoint() EasyFind.db.uiSearchPosition = {point, relPoint, x, y} end + self._dragMoving = false + self.blockFocus = nil end) + local placeholder = editBox:CreateFontString(nil, "ARTWORK", ns.SEARCHBAR_FONT) placeholder:SetPoint("LEFT", 2, 0) placeholder:SetPoint("RIGHT", editBox, "RIGHT", -2, 0) placeholder:SetJustifyH("LEFT") placeholder:SetWordWrap(false) placeholder:SetTextColor(0.5, 0.5, 0.5, 1.0) - placeholder:SetText("Search your UI here") + placeholder:SetText("Type to search...") editBox.placeholder = placeholder editBox:SetScript("OnEditFocusGained", function(self) @@ -873,20 +1005,66 @@ function UI:CreateSearchFrame() return end if escCatcher then escCatcher:Hide() end - self.placeholder:Hide() if selectedIndex > 0 then selectedIndex = 0 toggleFocused = false UI:UpdateSelectionHighlight(true) end - if self:GetText() == "" then + local text = self:GetText() or "" + if text == "" then UI:ShowPinnedItems() + else + -- Refocus with leftover text: select all so the user can + -- start typing fresh (overwrites) or hit Right Arrow / + -- Ctrl+L to keep editing from the end. + self:HighlightText(0, #text) + self:SetCursorPosition(#text) + -- Re-show results if they were closed by a prior click-out. + if resultsFrame and not resultsFrame:IsShown() then + UI:OnSearchTextChanged(text, true) + end end end) editBox:SetScript("OnEditFocusLost", function(self) -- Skip cleanup when SelectResult is actively clearing text/focus if selectingResult then return end + -- Drop any active text highlight (the focus-gained "select all" + -- or autocomplete suffix) so leftover text doesn't keep its + -- selection box after we click away. Re-focus re-applies it. + self:HighlightText(0, 0) + -- Entering keyboard-nav mode (Enter / DOWN from the search bar) + -- programmatically yanks focus so navFrame can take the keys. + -- Don't treat that as a click-outside; the click-outside path + -- is handled by resultsFrame's GLOBAL_MOUSE_DOWN handler. + if selectedIndex > 0 then return end + -- Click on a guard frame (results, dropdown, popups) keeps + -- the results visible. Anywhere else (including empty world) + -- hides them in one click instead of needing a second click + -- after the autocomplete strip. + local onGuard = false + if resultsFrame and resultsFrame:IsMouseOver() then onGuard = true end + if not onGuard then + local guards = { + _G["EasyFindUIFilterDropdown"], + _G["EasyFindPinPopup"], + _G["EasyFindAsOptionsPopup"], + _G["EasyFindAsClassPopup"], + _G["EasyFindGearOptionsPopup"], + _G["EasyFindDiffPopup"], + _G["EasyFindSpecPopup"], + _G["EasyFindSpecFlyout"], + } + for _, g in ipairs(guards) do + if g and g:IsShown() and g:IsMouseOver() then + onGuard = true + break + end + end + end + if not onGuard and resultsFrame and resultsFrame:IsShown() then + UI:HideResults() + end if strtrim(self:GetText()) == "" then self:SetText("") -- Clear any stray whitespace self.placeholder:Show() @@ -915,19 +1093,20 @@ function UI:CreateSearchFrame() local pendingUISearchTimer local lastTypedLen = 0 + local lastSearchTime = 0 + local SEARCH_THROTTLE = 0.05 -- 50ms cap on search/render frequency editBox:SetScript("OnTextChanged", function(self, userInput) - if self:GetText() ~= "" then - self.placeholder:Hide() - end - -- Autocomplete-driven SetText: text changed only because the - -- helper re-rendered the same candidate the user is already - -- looking at. Skipping here is what makes "type the next - -- highlighted char" feel like a no-op visually -- no fresh - -- search, no fresh findCandidate, no flash. - if editBox.IsAutocompleteProgrammatic - and editBox.IsAutocompleteProgrammatic() then - return - end + self.placeholder:SetShown(self:GetText() == "") + -- Skip every non-user text change. WoW defers OnTextChanged + -- dispatch by one frame, so by the time the autocomplete's + -- programmatic SetText fires this handler the in-band + -- programmatic flag has already been reset to false. The only + -- reliable signal is the userInput parameter WoW passes us: + -- true for real keystrokes / paste, false for SetText / Insert + -- / Clear / etc. Without this gate every keystroke produces + -- two searches (the user's and the autocomplete suffix's), + -- doubling per-keystroke cost. + if not userInput then return end historyIndex = 0 historyDraft = "" if pendingUISearchTimer then pendingUISearchTimer:Cancel() end @@ -940,8 +1119,11 @@ function UI:CreateSearchFrame() local typedNow = (self:GetText() or ""):sub(1, cursorPos) local grew = #typedNow > lastTypedLen lastTypedLen = #typedNow - pendingUISearchTimer = C_Timer.NewTimer(0, function() + local elapsed = GetTime() - lastSearchTime + local delay = elapsed >= SEARCH_THROTTLE and 0 or (SEARCH_THROTTLE - elapsed) + pendingUISearchTimer = C_Timer.NewTimer(delay, function() pendingUISearchTimer = nil + lastSearchTime = GetTime() UI:OnSearchTextChanged(typedNow) if grew and editBox.UpdateAutocomplete then editBox.UpdateAutocomplete() @@ -950,33 +1132,91 @@ function UI:CreateSearchFrame() end) editBox:SetScript("OnEnterPressed", function(self) - -- Push the typed query into shell-style history so UP/Ctrl+K - -- can recall it later. Empty queries (and pure whitespace) are - -- ignored. Dedupe by removing any prior occurrence and - -- re-inserting at the front, mirroring bash's HISTCONTROL - -- "ignoredups + erasedups" behavior. + -- Strip any visible autocomplete suffix first. WoW's EditBox + -- swallows the Enter when there's a text selection (treating + -- it like a "deselect" rather than confirm), so without this + -- the user's first Enter visually clears the highlight but + -- doesn't focus the result row — they'd have to press Enter + -- a second time. Stripping here puts the text back to what + -- the user typed before WoW's default handler runs. + if self.StripAutocomplete then self:StripAutocomplete() end local typed = strtrim(self:GetText() or "") + + -- /command parser. Anything starting with "/" is treated as a + -- bar command, not a search query. /reset snaps the bar back to + -- the top of the screen, /resize opens the drag-to-resize overlay. + if typed:sub(1, 1) == "/" then + local cmd = typed:lower():sub(2) + self:SetText("") + self.placeholder:Show() + UI:HideResults() + if cmd == "reset" or cmd == "resetpos" or cmd == "resetposition" then + UI:ResetPosition() + EasyFind:Print("Search bar position reset.") + elseif cmd == "resize" or cmd == "rescale" then + if ns.Rescaler and ns.Rescaler.Enter then + ns.Rescaler:Enter("ui") + end + elseif cmd == "options" or cmd == "o" or cmd == "config" or cmd == "settings" then + EasyFind:OpenOptions() + elseif cmd == "tutorial" or cmd == "wizard" or cmd == "welcome" then + if ns.Wizard and ns.Wizard.Show then + EasyFind.db.tutorialDone = false + ns.Wizard:Show() + end + else + EasyFind:Print("Unknown command: /" .. cmd) + end + return + end + if typed ~= "" then UI:PushSearchHistory(typed) end historyIndex = 0 historyDraft = "" - -- First Enter from the editbox jumps focus into the results - -- and highlights the first row (much like DOWN does), so the - -- user can continue keyboard-navigating instead of being - -- forced to commit to the first match. A second Enter, with a - -- row already highlighted, activates as before. + -- Enter on the search bar: focus the first result. Prefer the + -- first non-pinned row so a fresh search jumps past leftover + -- pinned shortcuts; fall back to the first pinned row when + -- pinned results are all that's available. A second Enter on + -- the focused row activates it. if selectedIndex == 0 then - UI:MoveSelection(1) + local target, firstPinned + for i = 1, MAX_BUTTON_POOL do + local row = resultButtons[i] + if not row or not row:IsShown() then break end + if not row.isPinHeader then + if row.isPinned then + if not firstPinned then firstPinned = i end + else + target = i + break + end + end + end + local idx = target or firstPinned + if not idx then return end + selectedIndex = idx + toggleFocused = false + UI:UpdateSelectionHighlight() return end UI:ActivateSelected() end) editBox:SetScript("OnEscapePressed", function(self) - self:ClearFocus() - -- Text and results stay visible; user can click back in to resume. + if UI:CloseFilterDropdownIfOpen() then + self:SetFocus() + return + end + if self:GetText() == "" then + UI:Hide() + return + end + self:SetText("") + self.placeholder:Show() + UI:HideResults() end) -- Chrome-style inline autocomplete: same helper MapTab uses. @@ -1003,6 +1243,16 @@ function UI:CreateSearchFrame() end, }) + -- After Tab confirms an autocomplete suggestion, re-run the search + -- so the result list reflects the now-full text. Without this the + -- visible results stay on the original typed prefix even though the + -- editbox shows the confirmed candidate. + editBox:HookScript("OnTabPressed", function(self) + local current = self:GetText() or "" + if current == "" then return end + UI:OnSearchTextChanged(current, true) + end) + -- Shift+click link insertion: when the search bar has focus, shift-clicking -- an item in bags / an achievement in the achievement frame / a spell in -- the spellbook etc. routes the link's display name into our editbox the @@ -1023,26 +1273,6 @@ function UI:CreateSearchFrame() end) end - -- Clear-text X button (grey circle X, matching retail quest log style) - -- Only visible when there is text in the editbox. - local clearTextBtn = Utils.CreateClearButton(searchFrame, "EasyFindClearTextButton") - clearTextBtn:SetFrameLevel(searchFrame:GetFrameLevel() + 10) - - clearTextBtn:SetScript("OnClick", function() - editBox:SetText("") - 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 and active highlights") - GameTooltip:Show() - end) - clearTextBtn:SetScript("OnLeave", GameTooltip_Hide) - searchFrame.clearTextBtn = clearTextBtn - -- Filter button (inside search bar, flush right) local filterBtn = CreateFrame("Button", "EasyFindUIFilterButton", searchFrame) filterBtn:SetPoint("TOP", searchFrame, "TOP", 0, 0) @@ -1142,11 +1372,7 @@ function UI:CreateSearchFrame() end) searchFrame.filterBtn = filterBtn - -- Reposition clear button to left of filter button - clearTextBtn:ClearAllPoints() - clearTextBtn:SetPoint("RIGHT", filterBtn, "LEFT", -2, 0) - - -- Anchor editBox right edge to left of clear button area (filter button zone) + -- Anchor editBox right edge to the filter button zone. editBox:ClearAllPoints() editBox:SetPoint("LEFT", iconHolder, "RIGHT", 0, 0) editBox:SetPoint("RIGHT", filterBtn, "LEFT", -4, 0) @@ -1160,52 +1386,16 @@ function UI:CreateSearchFrame() searchFrame:HookScript("OnMouseDown", function(self, button) if button ~= "LeftButton" or IsShiftKeyDown() or self.setupMode then return end if (filterBtn and filterBtn:IsMouseOver()) - or (iconHolder and iconHolder:IsMouseOver()) - or (clearTextBtn and clearTextBtn:IsShown() and clearTextBtn:IsMouseOver()) then + or (iconHolder and iconHolder:IsMouseOver()) then return end - -- Explicit user click: lift the persistent auto-focus block. + if activeKeybindBtn and activeKeybindBtn._stopCapture then + activeKeybindBtn._stopCapture(activeKeybindBtn) + end editBox.blockFocus = nil editBox:SetFocus() end) - -- Show/hide the clear X based on whether there's text, an active - -- UI highlight guide, or active map navigation (waypoint, SuperTrack, - -- pin). So the player can press it to cancel ANY kind of active - -- navigation without typing /ef c. - local lastClearShown - local function UpdateClearButtonVisibility() - local hasText = editBox:GetText() ~= "" - local focused = editBox:HasFocus() - local guideActive = ns.Highlight and ns.Highlight:IsActive() - local mapActive = ns.MapSearch and ns.MapSearch.HasActiveNavigation - and ns.MapSearch:HasActiveNavigation() - local shouldShow = hasText or focused or guideActive or mapActive - if shouldShow ~= lastClearShown then - clearTextBtn:SetShown(shouldShow) - lastClearShown = shouldShow - end - end - editBox:HookScript("OnTextChanged", UpdateClearButtonVisibility) - editBox:HookScript("OnEditFocusGained", UpdateClearButtonVisibility) - editBox:HookScript("OnEditFocusLost", UpdateClearButtonVisibility) - searchFrame.UpdateClearButtonVisibility = UpdateClearButtonVisibility - - -- Highlight and MapSearch state changes happen outside the - -- editbox event flow, so the focus-event hooks above can leave - -- the clear X stuck on after a guide finishes or a navigation - -- arrival clears. A throttled OnUpdate hook (4 Hz) re-runs the - -- visibility check so the X disappears within ~250ms of the - -- underlying state going false. HookScript (not SetScript) so - -- the keyRepeat OnUpdate registered above keeps running. - local clearPollAccum = 0 - searchFrame:HookScript("OnUpdate", function(_, elapsed) - clearPollAccum = clearPollAccum + (elapsed or 0) - if clearPollAccum < 0.25 then return end - clearPollAccum = 0 - UpdateClearButtonVisibility() - end) - -- Shared key-repeat helper (also used by MapTab). Attaches its own -- OnUpdate to searchFrame; action fires immediately on Start, then -- at an accelerating cadence while the key is held. @@ -1217,9 +1407,33 @@ function UI:CreateSearchFrame() searchFrame.IsRepeatKey = keyRepeat.IsKey -- Arrow key / Tab navigation for results dropdown. - -- IMPORTANT: Always block propagation while the editbox has focus so that - -- typed letters never trigger the player's game keybinds. + -- IMPORTANT: Block propagation while the editbox has focus so that + -- typed letters never trigger the player's game keybinds. The one + -- exception is EasyFind's own bindings (TOGGLE_FOCUS, MAP_FOCUS, + -- CLEAR): the toggle key has to close the bar from inside the + -- editbox, otherwise it just types as a character and the user + -- can't dismiss with the same key they used to open. editBox:SetScript("OnKeyDown", function(self, key) + -- Match the keybind capture's combo format: ALT-CTRL-SHIFT-key. + -- GetBindingAction is the correct API (GetBindingByKey doesn't + -- exist) and returns the binding name for a given key combo. + local mod = "" + if IsAltKeyDown() then mod = mod .. "ALT-" end + if IsControlKeyDown() then mod = mod .. "CTRL-" end + if IsShiftKeyDown() then mod = mod .. "SHIFT-" end + local boundAction = GetBindingAction and GetBindingAction(mod .. key) + if boundAction and string.sub(boundAction, 1, 9) == "EASYFIND_" then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) + return + end + -- ENTER with autocomplete highlight visible: WoW's default + -- editbox processing treats the first Enter as "deselect" + -- and silently swallows it without firing OnEnterPressed. + -- Strip the suggestion now so Enter falls through cleanly + -- on the user's first press instead of needing two presses. + if key == "ENTER" and self.StripAutocomplete then + self:StripAutocomplete() + end -- Shell-style history. UP walks back toward older entries -- (capped at the oldest); DOWN walks forward toward newer -- entries until we land back on the live draft, then drops @@ -1256,12 +1470,16 @@ function UI:CreateSearchFrame() end -- Ctrl+J/K (and the emacs-equivalent Ctrl+N/P) walk into the -- result list once history navigation has been exhausted by - -- the branch above. Holding accelerates like UP/DOWN. + -- the branch above. Single-step only (no key-repeat) because + -- MoveSelection transfers keyboard focus to navFrame and the + -- subsequent KeyUp event gets lost in the focus transition, + -- leaving the repeat ticker firing forever and cascading + -- through the entire result list. if IsControlKeyDown() then if key == "J" or key == "N" then - StartKeyRepeat(key, function() UI:MoveSelection(1) end) + UI:MoveSelection(1) elseif key == "K" or key == "P" then - StartKeyRepeat(key, function() UI:MoveSelection(-1) end) + UI:MoveSelection(-1) end end Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) @@ -1290,12 +1508,7 @@ function UI:CreateSearchFrame() tbHL:SetVertexColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.5) local function GetToolbarControls() - local controls = {} - if clearTextBtn:IsShown() then - tinsert(controls, clearTextBtn) - end - tinsert(controls, filterBtn) - return controls + return { filterBtn } end local function SetToolbarFocus(idx) @@ -1508,7 +1721,11 @@ function UI:CreateSearchFrame() UI:ActivateSelected() end elseif key == "ESCAPE" then - if toolbarFocus > 0 then + if UI:CloseFilterDropdownIfOpen() then + if searchFrame and searchFrame.editBox then + searchFrame.editBox:SetFocus() + end + elseif toolbarFocus > 0 then ClearToolbarFocus() if selectedIndex == 0 then Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) @@ -1541,18 +1758,50 @@ 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). + -- Secure-action rows: let Enter propagate to the override + -- binding so the secure click dispatch fires (same as a mouse + -- click). Without this navFrame swallows Enter and the + -- override binding never sees the key — abilities, mounts, + -- macros, bag items, toys, outfits all need this gate. 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 + if rd and (rd.outfitID or rd.toyItemID or rd.spellID + or rd.mountID or rd.macroIndex + or (rd.itemID and rd.category == "Bag")) then Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) return end end - Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) - HandleNavKeyDown(key) + -- Decide whether this key is one we actually use for nav. If + -- not (e.g. WASD movement, plain SPACE for jumping, ability + -- bar keys), propagate to the game so the player isn't + -- stranded just because a result is highlighted. + local consume = false + if key == "DOWN" or key == "UP" or key == "PAGEDOWN" or key == "PAGEUP" + or key == "HOME" or key == "END" or key == "TAB" or key == "ENTER" + or key == "ESCAPE" then + consume = true + elseif IsControlKeyDown() and (key == "J" or key == "N" or key == "K" + or key == "P" or key == "L" or key == "H") then + consume = true + elseif key == "SPACE" then + -- Only consume SPACE when it would do something here: + -- toggling the collapse on the focused header. A leaf row + -- has no toggle, so SPACE means "jump" and must reach the + -- game. + local row = selectedIndex > 0 and resultButtons[selectedIndex] + local hasToggle = row and ( + (row.isPinHeader and row.pinToggle and row.pinToggle:IsShown()) + or (row.toggleBtn and row.toggleBtn:IsShown())) + if hasToggle then consume = true end + end + if consume then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + HandleNavKeyDown(key) + else + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) + end end) navFrame:SetScript("OnKeyUp", function(_, key) if keyRepeat.IsKey(key) then StopKeyRepeat(key) end @@ -1581,12 +1830,13 @@ function UI:CreateSearchFrame() -- filter buttons) is handled by Left/Right and Ctrl+H/Ctrl+L -- elsewhere; routing Tab into it stomped the autocomplete confirm. - -- Draggable with Shift key + -- Plain drag moves the bar (no modifier required). Lock Position + -- in Options disables movement entirely. The editbox area uses + -- manual movement detection above so a click can still focus it. searchFrame:RegisterForDrag("LeftButton") searchFrame:SetScript("OnDragStart", function(self) - if IsShiftKeyDown() then - self:StartMoving() - end + if EasyFind.db.lockPosition then return end + self:StartMoving() end) searchFrame:SetScript("OnDragStop", function(self) self:StopMovingOrSizing() @@ -1723,6 +1973,42 @@ function UI:CreateSearchFrame() -- UI search filter dropdown self:CreateUIFilterDropdown(filterBtn, searchFrame, editBox) + + searchFrame:HookScript("OnShow", function(self) + if EasyFind.db.autoHide then + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end + end) + searchFrame:HookScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + end) + searchFrame:HookScript("OnEvent", function(self, event) + if event ~= "GLOBAL_MOUSE_DOWN" then return end + if not EasyFind.db.autoHide then return end + if self:IsMouseOver() then return end + if resultsFrame and resultsFrame:IsShown() and resultsFrame:IsMouseOver() then return end + if activeKeybindBtn then return end + local dropdown = self.filterDropdown + if dropdown and dropdown:IsShown() and dropdown:IsMouseOver() then return end + -- Every sub-popup the filter dropdown spawns (flyout sub-filters, + -- options popups, spec/class flyouts, etc.) registers itself in + -- dropdown.guardFrames. Walk that list so a click inside any of + -- them never dismisses the search bar. + if dropdown and dropdown.guardFrames then + for i = 1, #dropdown.guardFrames do + local g = dropdown.guardFrames[i] + if g and g:IsShown() and g:IsMouseOver() then return end + end + end + local extras = { + _G["EasyFindPinPopup"], + _G["EasyFindUIWizard"], + } + for _, g in ipairs(extras) do + if g and g:IsShown() and g:IsMouseOver() then return end + end + UI:Hide() + end) end -- Top-level filter list, alphabetical. Collections stays compact via a @@ -1731,26 +2017,45 @@ end -- Appearance Sets). Each sub-filter still owns its own filters[key] -- value; only the rendering changed. local UI_FILTER_OPTIONS = { - { key = "achievements", label = "Achievements", iconTex = 235413 }, - -- Appearance Sets is top-level (rather than nested under - -- Collections) because it owns extra inline controls (class - -- selector + collected/PvE/PvP filter button) that need a stable - -- dropdown row to anchor them. - { key = "appearanceSets", label = "Appearance Sets", iconTex = "Interface\\Icons\\INV_Helmet_03" }, - { key = "bags", label = "Bags", iconTex = 133634 }, - { key = "collections", label = "Collections", iconTex = 132834, + -- Abilities: boss-skull icon from the Encounter Journal boss tab + -- spritesheet (texture 522972). + { key = "abilities", label = "Abilities", iconTex = 522972, + iconCoords = { 0.904, 0.996, 0.707, 0.748 } }, + { key = "achievements", label = "Achievements", iconAtlas = "UI-HUD-MicroMenu-Achievements-Up" }, + { key = "bags", label = "Bags", iconAtlas = "bag-main" }, + -- Bosses: EJ overview tab icon from texture 522972. + { key = "bosses", label = "Bosses", iconTex = 522972, + iconCoords = { 0.855, 0.949, 0.524, 0.566 } }, + { key = "macros", label = "Macros", iconTex = "Interface\\MacroFrame\\MacroFrame-Icon" }, + { key = "collections", label = "Collections", iconAtlas = "UI-HUD-MicroMenu-Collections-Up", flyoutSubFilters = { + { key = "appearanceSets", label = "Appearance Sets", iconTex = "Interface\\Icons\\INV_Helmet_03", hasOptions = true }, + { key = "gearSets", label = "Gear Sets", iconAtlas = "equipmentmanager-spec-border" }, + { key = "heirlooms", label = "Heirlooms", iconTex = 133877 }, { key = "mounts", label = "Mounts", iconTex = 132261 }, { key = "outfits", label = "Outfits", iconTex = 132649 }, { key = "pets", label = "Pets", iconTex = 631719 }, { key = "toys", label = "Toys", iconTex = 454046 }, } }, - { key = "currencies", label = "Currencies", iconTex = 133784 }, - { key = "loot", label = "Gear", iconTex = 132281 }, - { key = "map", label = "Map Search", iconAtlas = "Waypoint-MapPin-ChatIcon" }, - { key = "options", label = "Game Options", iconAtlas = "QuestLog-icon-setting" }, + { key = "currencies", label = "Currencies", iconTex = 136452 }, + -- Gear: treasure-chest icon from the Encounter Journal loot tab + -- spritesheet (texture 522972) for visual consistency with the + -- in-game loot UI. + { key = "loot", label = "Gear", iconTex = 522972, + iconCoords = { 0.730, 0.824, 0.618, 0.660 } }, + { key = "map", label = "Map Search", iconTex = 1121272, + iconCoords = { 0.3457, 0.3856, 0.2549, 0.2951 } }, + { key = "options", label = "Options", iconTex = 1121272, + iconCoords = { 0.4451, 0.4705, 0.8079, 0.8344 }, + flyoutSubFilters = { + { key = "gameOptions", label = "Game Options", iconAtlas = "QuestLog-icon-setting" }, + { key = "addonOptions", label = "AddOn Options", iconAtlas = "QuestLog-icon-setting", iconColor = { 1.0, 0.78, 0.35 } }, + } }, { key = "reputations", label = "Reputations", iconTex = 1121272, iconCoords = { 0.3783, 0.4072, 0.9066, 0.9350 } }, + -- Title icon from PaperDollSidebarTab2 (Titles tab) spritesheet 514608. + { key = "titles", label = "Titles", iconTex = 514608, + iconCoords = { 0.016, 0.531, 0.324, 0.461 } }, { key = "ui", label = "UI Elements", iconAtlas = "common-search-magnifyingglass" }, } @@ -1771,26 +2076,313 @@ end -- "achievements" / "currencies" / "reputations" / "ui" (UI elements). -- Used both in the per-keystroke filter and category sort. local UI_BUCKET_BY_CATEGORY = { + ["Ability"] = "abilities", + ["Boss"] = "bosses", ["Achievements"] = "achievements", ["Guild Achievements"] = "achievements", ["Statistics"] = "achievements", ["Currency"] = "currencies", ["Reputation"] = "reputations", ["Bag"] = "bags", - ["Game Settings"] = "options", + ["Macro"] = "macros", + ["Game Settings"] = "gameOptions", + ["AddOn Settings"] = "addonOptions", } local function GetUIBucket(d) -- Returns one of the bucket keys for non-collection / non-map UI -- entries, or nil for entries handled by a separate dedicated filter. if not d then return nil end - if d.mountID or d.toyItemID or d.petID or d.outfitID or d.transmogSetID + if d.mountID or d.toyItemID or d.petID or d.outfitID or d.heirloomItemID + or d.transmogSetID or (d.itemID and d.category == "Loot") or d.mapSearchResult then return nil end return UI_BUCKET_BY_CATEGORY[d.category] or "ui" end +-- Builds the Appearance Sets options popup: a class selector button + +-- four checkboxes (Collected, Not Collected, PvE, PvP). Returns the +-- popup frame and a sync function that re-reads EasyFind.db state. +-- Caller positions/shows the popup and decides when to call sync. +function UI:BuildAppearanceSetOptionsPopup(StylePopup, CreateRadioTexture, + ROW_HIGHLIGHT_COLOR, CHECK_SIZE, searchEditBox) + local FLYOUT_ROW_H = 20 + local CLASSPOPUP_WIDTH = 160 + local OPTIONS_WIDTH = 160 + local CB_ROW_H = 22 + local CLASS_BTN_H = 27 + local PAD = 6 + + local CLASS_COLORS = RAID_CLASS_COLORS + local classes = {} + for classIdx = 1, GetNumClasses() do + local className, classFile, classID = GetClassInfo(classIdx) + if className and classFile then + classes[#classes + 1] = { + classID = classID, className = className, classFile = classFile, + } + end + end + + local function ClassColorString(classFile) + local c = CLASS_COLORS[classFile] + return c and string.format("|cff%02x%02x%02x", c.r * 255, c.g * 255, c.b * 255) or "" + end + + local function ApplyFilterSelection() + if ns.Database and ns.Database.PopulateDynamicTransmogSets then + ns.Database:PopulateDynamicTransmogSets() + end + if searchEditBox and searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end + + local optionsPopup = CreateFrame("Frame", "EasyFindAsOptionsPopup", UIParent, "BackdropTemplate") + optionsPopup:SetFrameStrata("TOOLTIP") + StylePopup(optionsPopup) + optionsPopup:EnableMouse(true) + optionsPopup:Hide() + + -- Class selector button at the top + local classBtn = CreateFrame("Button", nil, optionsPopup) + classBtn:SetSize(OPTIONS_WIDTH - PAD * 2, CLASS_BTN_H) + classBtn:SetPoint("TOPLEFT", optionsPopup, "TOPLEFT", PAD, -PAD) + local cbBg = classBtn:CreateTexture(nil, "BACKGROUND") + cbBg:SetAtlas("common-dropdown-textholder") + cbBg:SetAllPoints() + local cbArrow = classBtn:CreateTexture(nil, "OVERLAY") + cbArrow:SetAtlas("common-dropdown-a-button-hover") + cbArrow:SetSize(20, 20) + cbArrow:SetPoint("RIGHT", -2, -1) + cbArrow:SetVertexColor(0.7, 0.7, 0.7) + local cbLabel = classBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + cbLabel:SetPoint("LEFT", 8, 0) + cbLabel:SetPoint("RIGHT", cbArrow, "LEFT", -2, 0) + cbLabel:SetJustifyH("LEFT") + cbLabel:SetWordWrap(false) + classBtn:SetScript("OnEnter", function() cbArrow:SetVertexColor(1, 1, 1) end) + classBtn:SetScript("OnLeave", function() cbArrow:SetVertexColor(0.7, 0.7, 0.7) end) + + local function UpdateClassLabel() + local cf = EasyFind.db.appearanceSetClass + if not cf then + local _, _, cid = UnitClass("player") + for _, cls in ipairs(classes) do + if cls.classID == cid then + cbLabel:SetText(ClassColorString(cls.classFile) .. cls.className .. "|r") + return + end + end + elseif cf == "all" then + cbLabel:SetText("All Classes") + return + elseif type(cf) == "table" and cf.classID then + for _, cls in ipairs(classes) do + if cls.classID == cf.classID then + cbLabel:SetText(ClassColorString(cls.classFile) .. cls.className .. "|r") + return + end + end + end + cbLabel:SetText("All Classes") + end + UpdateClassLabel() + + -- Class popup (opens to the right of the class button) + local classPopup = CreateFrame("Frame", "EasyFindAsClassPopup", UIParent, "BackdropTemplate") + classPopup:SetFrameStrata("TOOLTIP") + classPopup:SetFrameLevel(optionsPopup:GetFrameLevel() + 20) + StylePopup(classPopup) + classPopup:EnableMouse(true) + classPopup:Hide() + classPopup:SetScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end) + classPopup:SetScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + end) + classPopup:SetScript("OnEvent", function(self, event) + if event == "GLOBAL_MOUSE_DOWN" then + if not self:IsMouseOver() and not classBtn:IsMouseOver() + and not optionsPopup:IsMouseOver() then + self:Hide() + end + end + end) + + local classRows = {} + local allRow = CreateFrame("Button", nil, classPopup) + allRow:SetSize(CLASSPOPUP_WIDTH - 16, FLYOUT_ROW_H) + allRow:SetFrameLevel(classPopup:GetFrameLevel() + 10) + local allRadio, allSetRadio = CreateRadioTexture(allRow) + allRadio:SetPoint("LEFT", 4, 0) + local allLbl = allRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + allLbl:SetPoint("LEFT", allRadio, "RIGHT", 4, 0) + allLbl:SetText("All Classes") + local allHL = allRow:CreateTexture(nil, "HIGHLIGHT") + allHL:SetAllPoints() + allHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + allRow._setRadioChecked = allSetRadio + allRow._classID = nil + allRow:SetScript("OnClick", function() + EasyFind.db.appearanceSetClass = "all" + UpdateClassLabel() + classPopup:Hide() + ApplyFilterSelection() + end) + classRows[#classRows + 1] = allRow + + for _, cls in ipairs(classes) do + local clsRow = CreateFrame("Button", nil, classPopup) + clsRow:SetSize(CLASSPOPUP_WIDTH - 16, FLYOUT_ROW_H) + clsRow:SetFrameLevel(classPopup:GetFrameLevel() + 10) + local cRadio, cSetRadio = CreateRadioTexture(clsRow) + cRadio:SetPoint("LEFT", 4, 0) + local cLbl = clsRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + cLbl:SetPoint("LEFT", cRadio, "RIGHT", 4, 0) + cLbl:SetText(ClassColorString(cls.classFile) .. cls.className .. "|r") + local cHL = clsRow:CreateTexture(nil, "HIGHLIGHT") + cHL:SetAllPoints() + cHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + clsRow._setRadioChecked = cSetRadio + clsRow._classID = cls.classID + clsRow:SetScript("OnClick", function() + EasyFind.db.appearanceSetClass = { classID = cls.classID } + UpdateClassLabel() + classPopup:Hide() + ApplyFilterSelection() + end) + classRows[#classRows + 1] = clsRow + end + + local function LayoutClassPopup() + local py = -6 + local cf = EasyFind.db.appearanceSetClass + for _, r in ipairs(classRows) do + r:ClearAllPoints() + r:SetPoint("TOPLEFT", classPopup, "TOPLEFT", 8, py) + r:Show() + if r._setRadioChecked then + local match = false + if not r._classID then + match = cf == "all" + else + if type(cf) == "table" and cf.classID == r._classID then + match = true + elseif not cf then + local _, _, cid = UnitClass("player") + match = r._classID == cid + end + end + r._setRadioChecked(match) + end + py = py - FLYOUT_ROW_H + end + classPopup:SetSize(CLASSPOPUP_WIDTH, -py + 6) + end + + classBtn:SetScript("OnClick", function(self) + if classPopup:IsShown() then + classPopup:Hide() + return + end + LayoutClassPopup() + classPopup:SetScale(optionsPopup:GetScale()) + classPopup:ClearAllPoints() + classPopup:SetPoint("TOPLEFT", self, "TOPRIGHT", 4, 0) + classPopup:Show() + end) + + -- Filter checkboxes + local filterDefs = { + { dbKey = "appearanceSetCollected", label = "Collected" }, + { dbKey = "appearanceSetNotCollected", label = "Not Collected" }, + { dbKey = "appearanceSetPvE", label = "PvE" }, + { dbKey = "appearanceSetPvP", label = "PvP" }, + } + + local cbRows = {} + local cy = -(PAD + CLASS_BTN_H + 6) + for si, def in ipairs(filterDefs) do + local cbRow = CreateFrame("CheckButton", nil, optionsPopup) + cbRow:SetSize(OPTIONS_WIDTH - PAD * 2, CB_ROW_H) + cbRow:SetPoint("TOPLEFT", optionsPopup, "TOPLEFT", PAD, cy) + + cbRow:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") + cbRow:GetNormalTexture():SetSize(CHECK_SIZE, CHECK_SIZE) + cbRow:GetNormalTexture():ClearAllPoints() + cbRow:GetNormalTexture():SetPoint("LEFT", 4, 0) + + cbRow:SetCheckedTexture("Interface\\Buttons\\UI-CheckBox-Check") + cbRow:GetCheckedTexture():SetSize(CHECK_SIZE, CHECK_SIZE) + cbRow:GetCheckedTexture():ClearAllPoints() + cbRow:GetCheckedTexture():SetPoint("LEFT", 4, 0) + + local cbText = cbRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + cbText:SetPoint("LEFT", cbRow:GetNormalTexture(), "RIGHT", 4, 0) + cbText:SetText(def.label) + + local cbHL = cbRow:CreateTexture(nil, "HIGHLIGHT") + cbHL:SetAllPoints() + cbHL:SetColorTexture(1, 1, 1, 0.1) + + local val = EasyFind.db[def.dbKey] + if val == nil then val = true end + cbRow:SetChecked(val) + cbRow.dbKey = def.dbKey + + cbRow:SetScript("OnClick", function(self) + EasyFind.db[def.dbKey] = self:GetChecked() + ApplyFilterSelection() + end) + + cbRows[si] = cbRow + cy = cy - CB_ROW_H + if si == 2 then + local sep = optionsPopup:CreateTexture(nil, "ARTWORK") + sep:SetHeight(1) + sep:SetPoint("TOPLEFT", optionsPopup, "TOPLEFT", PAD + 4, cy + 2) + sep:SetPoint("TOPRIGHT", optionsPopup, "TOPRIGHT", -(PAD + 4), cy + 2) + sep:SetColorTexture(0.5, 0.5, 0.5, 0.4) + cy = cy - 6 + end + end + optionsPopup:SetSize(OPTIONS_WIDTH, -cy + PAD) + + -- Hide nested class popup whenever the options popup itself hides. + optionsPopup:HookScript("OnHide", function() classPopup:Hide() end) + + -- Outside-click: close immediately when the user clicks anywhere + -- that isn't this popup or its nested class popup. The owning + -- sub-row hover handler is responsible for re-showing on rehover. + optionsPopup:HookScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end) + optionsPopup:HookScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + end) + optionsPopup:HookScript("OnEvent", function(self, event) + if event ~= "GLOBAL_MOUSE_DOWN" then return end + if self:IsMouseOver() then return end + if self._owningRow and self._owningRow:IsMouseOver() then return end + if classPopup:IsShown() and classPopup:IsMouseOver() then return end + self:Hide() + end) + + local function SyncFromDB() + for _, sr in ipairs(cbRows) do + if sr.dbKey then + sr:SetChecked(EasyFind.db[sr.dbKey] ~= false) + end + end + UpdateClassLabel() + end + + return optionsPopup, SyncFromDB +end + function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local ROW_HEIGHT = 20 local DROPDOWN_WIDTH = 207 @@ -1801,11 +2393,18 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local dropdown = CreateFrame("Frame", "EasyFindUIFilterDropdown", UIParent, "BackdropTemplate") dropdown:SetFrameStrata("FULLSCREEN_DIALOG") dropdown:SetFrameLevel(9999) + -- Bump everything in the filter menu uniformly: 1.5x larger fonts, + -- icons, paddings, and row heights without rewriting the hardcoded + -- pixel sizes scattered through the row builders. + dropdown:SetScale(1.5) dropdown:Hide() dropdown:EnableMouse(true) -- Popups that should prevent the dropdown from closing on outside-click. -- Each sub-filter registers its popups here instead of hardcoding frame names. + -- Stashed on the dropdown so the search bar's autoHide handler can also + -- consult it (otherwise clicks inside a flyout dismiss the bar). local dropdownGuardFrames = {} + dropdown.guardFrames = dropdownGuardFrames dropdown:SetClampedToScreen(true) dropdown:SetBackdrop({ @@ -1967,20 +2566,31 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) row:GetCheckedTexture():ClearAllPoints() row:GetCheckedTexture():SetPoint("LEFT", 4, 0) - local label = row:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - label:SetPoint("LEFT", row:GetNormalTexture(), "RIGHT", 4, 0) - label:SetText(opt.label) - row.label = label - - -- Always draw the configured category icon, even on rows with a - -- flyout. The auto-popup on hover is enough hint that the row has - -- sub-options; no extra arrow needed. Supports atlas, raw fileID, - -- or fileID+coords for sprite-sheet sub-icons. + -- Category icon sits between the checkbox and label so the row + -- reads left-to-right as [check][icon][name]. Supports atlas, + -- raw fileID, or fileID + texCoords for sprite-sheet sub-icons. + local icon if opt.iconAtlas or opt.iconTex then - local icon = row:CreateTexture(nil, "ARTWORK") + icon = row:CreateTexture(nil, "ARTWORK") icon:SetSize(ICON_SIZE, ICON_SIZE) - icon:SetPoint("RIGHT", -4, 0) - if opt.iconAtlas then + icon:SetPoint("LEFT", row:GetNormalTexture(), "RIGHT", 4, 0) + -- Gear sets pulls its icon from PaperDollSidebarTab3 so the + -- filter row matches whatever sprite Blizzard ships, instead + -- of the spec-border placeholder. Resolve once, then prefer + -- the cached tex/coords over the static iconAtlas fallback. + if opt.key == "gearSets" then + ResolveGearSetIcon() + local resolved = FLAT_CATEGORY_ICONS.gearSet + if resolved and resolved._resolved and resolved.tex then + icon:SetTexture(resolved.tex) + if resolved.coords then + icon:SetTexCoord(resolved.coords[1], resolved.coords[2], + resolved.coords[3], resolved.coords[4]) + end + else + icon:SetAtlas(opt.iconAtlas) + end + elseif opt.iconAtlas then icon:SetAtlas(opt.iconAtlas) else icon:SetTexture(opt.iconTex) @@ -1992,6 +2602,34 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) row.iconTex = icon end + local label = row:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + if icon then + label:SetPoint("LEFT", icon, "RIGHT", 4, 0) + else + label:SetPoint("LEFT", row:GetNormalTexture(), "RIGHT", 4, 0) + end + label:SetText(opt.label) + row.label = label + + -- Right-pointing chevron on rows that have a flyout, signalling + -- the row expands to the right. Mirrors the standard submenu + -- indicator used elsewhere in the WoW UI. + if opt.flyoutSubFilters then + local chev = row:CreateTexture(nil, "OVERLAY") + chev:SetAtlas("common-icon-forwardarrow") + chev:SetSize(ICON_SIZE - 2, ICON_SIZE - 2) + chev:SetPoint("RIGHT", -4, 0) + chev:SetVertexColor(0.85, 0.85, 0.85, 1) + row.flyoutChevron = chev + -- Anchor the label's right edge to the chevron so long names + -- truncate cleanly instead of running under it. + label:SetPoint("RIGHT", chev, "LEFT", -4, 0) + label:SetWordWrap(false) + label:SetJustifyH("LEFT") + row:HookScript("OnEnter", function() chev:SetVertexColor(1, 1, 1, 1) end) + row:HookScript("OnLeave", function() chev:SetVertexColor(0.85, 0.85, 0.85, 1) end) + end + -- Flyout sub-filters (e.g. Collections > Mounts/Toys/Pets/...). -- Hovering the row opens a popup containing one CheckButton per -- sub-filter. Each sub-filter writes through to filters[subKey] @@ -2014,6 +2652,28 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) popup:Hide() row.flyoutPopup = popup dropdownGuardFrames[#dropdownGuardFrames + 1] = popup + -- Sibling registry so each flyout's ShowPopup can hide + -- every other flyout on entry (kills overlap on quick + -- row-to-row hover transitions). + dropdown.flyoutPopups = dropdown.flyoutPopups or {} + dropdown.flyoutPopups[#dropdown.flyoutPopups + 1] = popup + + -- Outside-click: close on click outside the popup. Nested + -- options popups (e.g. appearance set options) act as + -- guards so clicking inside them keeps this popup open. + popup:HookScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end) + popup:HookScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + end) + popup:HookScript("OnEvent", function(self, event) + if event ~= "GLOBAL_MOUSE_DOWN" then return end + if self:IsMouseOver() or row:IsMouseOver() then return end + local opts = self._appearanceSetOptionsPopup + if opts and opts:IsShown() and opts:IsMouseOver() then return end + self:Hide() + end) local subRows = {} for si, sub in ipairs(opt.flyoutSubFilters) do @@ -2032,19 +2692,40 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) subRow:GetCheckedTexture():ClearAllPoints() subRow:GetCheckedTexture():SetPoint("LEFT", 4, 0) - local subLabel = subRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - subLabel:SetPoint("LEFT", subRow:GetNormalTexture(), "RIGHT", 4, 0) - subLabel:SetText(sub.label) - + local subIcon if sub.iconAtlas or sub.iconTex then - local subIcon = subRow:CreateTexture(nil, "ARTWORK") + subIcon = subRow:CreateTexture(nil, "ARTWORK") subIcon:SetSize(SUB_ICON, SUB_ICON) - subIcon:SetPoint("RIGHT", -4, 0) + subIcon:SetPoint("LEFT", subRow:GetNormalTexture(), "RIGHT", 4, 0) if sub.iconAtlas then subIcon:SetAtlas(sub.iconAtlas) else subIcon:SetTexture(sub.iconTex) end + if sub.iconColor then + subIcon:SetVertexColor(unpack(sub.iconColor)) + end + end + + local subLabel = subRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + if subIcon then + subLabel:SetPoint("LEFT", subIcon, "RIGHT", 4, 0) + else + subLabel:SetPoint("LEFT", subRow:GetNormalTexture(), "RIGHT", 4, 0) + end + subLabel:SetText(sub.label) + + if sub.hasOptions then + local subChev = subRow:CreateTexture(nil, "OVERLAY") + subChev:SetAtlas("common-icon-forwardarrow") + subChev:SetSize(SUB_ICON - 2, SUB_ICON - 2) + subChev:SetPoint("RIGHT", -4, 0) + subChev:SetVertexColor(0.85, 0.85, 0.85, 1) + subLabel:SetPoint("RIGHT", subChev, "LEFT", -4, 0) + subLabel:SetWordWrap(false) + subLabel:SetJustifyH("LEFT") + subRow:HookScript("OnEnter", function() subChev:SetVertexColor(1, 1, 1, 1) end) + subRow:HookScript("OnLeave", function() subChev:SetVertexColor(0.85, 0.85, 0.85, 1) end) end local subHL = subRow:CreateTexture(nil, "HIGHLIGHT") @@ -2060,6 +2741,62 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) subRows[si] = subRow subRows[sub.key] = subRow + + -- Appearance Sets has its own nested options popup (class + -- selector + Collected/Not Collected/PvE/PvP filters) + -- that opens to the right of this sub-row on hover. + if sub.hasOptions and sub.key == "appearanceSets" then + local optionsPopup, syncOptions = UI:BuildAppearanceSetOptionsPopup( + StylePopup, CreateRadioTexture, ROW_HIGHLIGHT_COLOR, CHECK_SIZE, + searchEditBox) + UI._SyncAppearanceSetOptions = syncOptions + optionsPopup:SetFrameLevel(popup:GetFrameLevel() + 10) + optionsPopup._owningRow = subRow + popup._appearanceSetOptionsPopup = optionsPopup + dropdownGuardFrames[#dropdownGuardFrames + 1] = optionsPopup + + local optHideTimer + local function HideOptionsNow() + if optionsPopup:IsMouseOver() or subRow:IsMouseOver() then return end + optionsPopup:Hide() + end + local function ScheduleHideOptions() + if optHideTimer then optHideTimer:Cancel() end + optHideTimer = C_Timer.NewTimer(0.15, function() + optHideTimer = nil + HideOptionsNow() + end) + end + + subRow:HookScript("OnEnter", function() + if optHideTimer then optHideTimer:Cancel(); optHideTimer = nil end + syncOptions() + optionsPopup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + optionsPopup:ClearAllPoints() + optionsPopup:SetPoint("TOPLEFT", subRow, "TOPRIGHT", 4, 0) + optionsPopup:Show() + end) + subRow:HookScript("OnLeave", ScheduleHideOptions) + optionsPopup:HookScript("OnEnter", function() + if optHideTimer then optHideTimer:Cancel(); optHideTimer = nil end + end) + optionsPopup:HookScript("OnLeave", ScheduleHideOptions) + + popup:HookScript("OnHide", function() optionsPopup:Hide() end) + dropdown:HookScript("OnHide", function() optionsPopup:Hide() end) + end + end + -- Sibling sub-rows hide the appearance set options popup so it + -- doesn't linger when the cursor moves to a non-options row. + if popup._appearanceSetOptionsPopup then + local optionsPopup = popup._appearanceSetOptionsPopup + for _, srOther in ipairs(subRows) do + if srOther ~= subRows.appearanceSets then + srOther:HookScript("OnEnter", function() + optionsPopup:Hide() + end) + end + end end row.flyoutSubRows = subRows popup:SetSize(SUB_POPUP_WIDTH, SUB_PAD * 2 + #opt.flyoutSubFilters * SUB_ROW_H) @@ -2085,6 +2822,15 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local hideTimer local function ShowPopup() if hideTimer then hideTimer:Cancel(); hideTimer = nil end + -- Slam any sibling flyout shut on entry so quickly + -- moving between adjacent flyout rows can't paint two + -- popups on top of each other (the 0.15s grace timer + -- would otherwise leave the previous one hanging). + for _, sibling in ipairs(dropdown.flyoutPopups or {}) do + if sibling ~= popup and sibling:IsShown() then + sibling:Hide() + end + end SyncSubChecks() popup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) PositionPopup() @@ -2092,6 +2838,11 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end local function MaybeHide() if popup:IsMouseOver() or row:IsMouseOver() then return end + if popup._appearanceSetOptionsPopup + and popup._appearanceSetOptionsPopup:IsShown() + and popup._appearanceSetOptionsPopup:IsMouseOver() then + return + end popup:Hide() end local function ScheduleHide() @@ -2122,16 +2873,28 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- model shows both scopes together and that's what UI search -- now does too — the toggle above is just on/off. - -- Loot: indented sub-options for search mode and spec toggle + -- Loot/Gear: side popup with difficulty + spec selector + iLvl + -- upgrades checkbox. Opens to the right of the Gear filter row + -- on hover, like the Collections sub-flyout. if opt.key == "loot" then - local SUB_INDENT = 24 + local GEAR_POPUP_WIDTH = 200 + local GEAR_POPUP_PAD = 8 + + local gearOptionsPopup = CreateFrame("Frame", "EasyFindGearOptionsPopup", UIParent, "BackdropTemplate") + gearOptionsPopup:SetFrameStrata("TOOLTIP") + StylePopup(gearOptionsPopup) + gearOptionsPopup:EnableMouse(true) + gearOptionsPopup:Hide() + row.gearOptionsPopup = gearOptionsPopup + dropdownGuardFrames[#dropdownGuardFrames + 1] = gearOptionsPopup + local lootSubDefs = { { dbKey = "lootUpgradesOnly", label = "iLvl Upgrades Only" }, } local lootSubRows = {} for si, sub in ipairs(lootSubDefs) do - local subRow = CreateFrame("CheckButton", nil, dropdown) - subRow:SetSize(DROPDOWN_WIDTH - 16 - SUB_INDENT, ROW_HEIGHT) + local subRow = CreateFrame("CheckButton", nil, gearOptionsPopup) + subRow:SetSize(GEAR_POPUP_WIDTH - GEAR_POPUP_PAD * 2, ROW_HEIGHT) subRow:SetHitRectInsets(0, 0, 0, 0) subRow:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") @@ -2163,11 +2926,11 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end) end - -- Separator line under Loot checkbox - local lootSep = dropdown:CreateTexture(nil, "ARTWORK") + -- Separator line between iLvl Upgrades checkbox and the + -- difficulty/spec selectors. + local lootSep = gearOptionsPopup: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) @@ -2179,8 +2942,8 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) } local DIFF_LABELS = { lfr = "Raid Finder", normal = "Normal", heroic = "Heroic", mythic = "Mythic" } - local diffBtn = CreateFrame("Button", nil, dropdown) - diffBtn:SetSize(120, 27) + local diffBtn = CreateFrame("Button", nil, gearOptionsPopup) + diffBtn:SetSize(GEAR_POPUP_WIDTH - GEAR_POPUP_PAD * 2, 27) local diffBg = diffBtn:CreateTexture(nil, "BACKGROUND") diffBg:SetAtlas("common-dropdown-textholder") diffBg:SetAllPoints() @@ -2200,7 +2963,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) 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" @@ -2210,6 +2972,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- Difficulty popup menu local diffPopup = CreateFrame("Frame", "EasyFindDiffPopup", UIParent, "BackdropTemplate") diffPopup:SetFrameStrata("TOOLTIP") + diffPopup:SetFrameLevel(gearOptionsPopup:GetFrameLevel() + 20) StylePopup(diffPopup) diffPopup:EnableMouse(true) diffPopup:Hide() @@ -2415,6 +3178,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) ------------------------------------------------------------------- local specPopup = CreateFrame("Frame", "EasyFindSpecPopup", UIParent, "BackdropTemplate") specPopup:SetFrameStrata("TOOLTIP") + specPopup:SetFrameLevel(gearOptionsPopup:GetFrameLevel() + 20) StylePopup(specPopup) specPopup:EnableMouse(true) specPopup:Hide() @@ -2424,6 +3188,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) ------------------------------------------------------------------- local classFlyout = CreateFrame("Frame", "EasyFindSpecFlyout", UIParent, "BackdropTemplate") classFlyout:SetFrameStrata("TOOLTIP") + classFlyout:SetFrameLevel(gearOptionsPopup:GetFrameLevel() + 30) StylePopup(classFlyout) classFlyout:EnableMouse(true) classFlyout:Hide() @@ -2665,8 +3430,8 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) ------------------------------------------------------------------- -- Spec selector dropdown bar ------------------------------------------------------------------- - local specSelectRow = CreateFrame("Button", nil, dropdown) - specSelectRow:SetSize(120, 27) + local specSelectRow = CreateFrame("Button", nil, gearOptionsPopup) + specSelectRow:SetSize(GEAR_POPUP_WIDTH - GEAR_POPUP_PAD * 2, 27) local specBg = specSelectRow:CreateTexture(nil, "BACKGROUND") specBg:SetAtlas("common-dropdown-textholder") specBg:SetAllPoints() @@ -2767,35 +3532,97 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) row.specFlyout = specFlyout row.allClassSpecs = allClassSpecs row.lootSubRows = lootSubRows - row.updateLootToggle = function() - local lootChecked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.loot ~= false + + -- Layout the controls inside the gear options popup, top-down. + local gy = -GEAR_POPUP_PAD + diffBtn:ClearAllPoints() + diffBtn:SetPoint("TOPLEFT", gearOptionsPopup, "TOPLEFT", GEAR_POPUP_PAD, gy) + gy = gy - 27 - 4 + specSelectRow:ClearAllPoints() + specSelectRow:SetPoint("TOPLEFT", gearOptionsPopup, "TOPLEFT", GEAR_POPUP_PAD, gy) + gy = gy - 27 - 6 + lootSep:ClearAllPoints() + lootSep:SetPoint("LEFT", gearOptionsPopup, "LEFT", GEAR_POPUP_PAD, 0) + lootSep:SetPoint("RIGHT", gearOptionsPopup, "RIGHT", -GEAR_POPUP_PAD, 0) + lootSep:SetPoint("TOP", 0, gy) + gy = gy - 6 + for _, sr in ipairs(lootSubRows) do + sr:ClearAllPoints() + sr:SetPoint("TOPLEFT", gearOptionsPopup, "TOPLEFT", GEAR_POPUP_PAD, gy) + gy = gy - ROW_HEIGHT + end + gearOptionsPopup:SetSize(GEAR_POPUP_WIDTH, -gy + GEAR_POPUP_PAD) + + -- Hover-to-show wiring on the Gear filter row, mirroring the + -- Collections sub-flyout pattern (with grace timer). + local gearHideTimer + local function MaybeHideGear() + if gearOptionsPopup:IsMouseOver() or row:IsMouseOver() then return end + local sp = _G["EasyFindSpecPopup"] + if sp and sp:IsShown() and sp:IsMouseOver() then return end + if classFlyout:IsShown() and classFlyout:IsMouseOver() then return end + if row.diffPopup and row.diffPopup:IsShown() and row.diffPopup:IsMouseOver() then return end + gearOptionsPopup:Hide() + end + local function ScheduleHideGear() + if gearHideTimer then gearHideTimer:Cancel() end + gearHideTimer = C_Timer.NewTimer(0.15, function() + gearHideTimer = nil + MaybeHideGear() + end) + end + local function ShowGear() + if gearHideTimer then gearHideTimer:Cancel(); gearHideTimer = nil end + if row.UpdateDiffButtons then row.UpdateDiffButtons() end + UpdateSpecLabel() for _, sr in ipairs(lootSubRows) do if sr.dbKey and sr.SetChecked then sr:SetChecked(EasyFind.db[sr.dbKey] ~= false) 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() + gearOptionsPopup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + gearOptionsPopup:ClearAllPoints() + gearOptionsPopup:SetPoint("TOPLEFT", row, "TOPRIGHT", 4, 0) + gearOptionsPopup:Show() + end + row.ShowGearOptionsPopup = ShowGear + row:HookScript("OnEnter", ShowGear) + row:HookScript("OnLeave", ScheduleHideGear) + gearOptionsPopup:HookScript("OnEnter", function() + if gearHideTimer then gearHideTimer:Cancel(); gearHideTimer = nil end + end) + gearOptionsPopup:HookScript("OnLeave", ScheduleHideGear) + gearOptionsPopup:HookScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end) + gearOptionsPopup:HookScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + if row.diffPopup then row.diffPopup:Hide() end + local sp = _G["EasyFindSpecPopup"] + if sp then sp:Hide() end + classFlyout:Hide() + end) + -- Outside-click: nested diff/spec/class popups act as guards + -- so clicks inside them don't dismiss the gear options. + gearOptionsPopup:HookScript("OnEvent", function(self, event) + if event ~= "GLOBAL_MOUSE_DOWN" then return end + if self:IsMouseOver() or row:IsMouseOver() then return end + if row.diffPopup and row.diffPopup:IsShown() and row.diffPopup:IsMouseOver() then return end + local sp = _G["EasyFindSpecPopup"] + if sp and sp:IsShown() and sp:IsMouseOver() then return end + if classFlyout:IsShown() and classFlyout:IsMouseOver() then return end + self:Hide() + end) + dropdown:HookScript("OnHide", function() gearOptionsPopup:Hide() end) + + row.updateLootToggle = function() + for _, sr in ipairs(lootSubRows) do + if sr.dbKey and sr.SetChecked then + sr:SetChecked(EasyFind.db[sr.dbKey] ~= false) end end UpdateSpecLabel() - if not lootChecked then - local sp = _G["EasyFindSpecPopup"] - if sp then sp:Hide() end - specFlyout:Hide() - end + if row.UpdateDiffButtons then row.UpdateDiffButtons() end end end @@ -2809,345 +3636,12 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) kbHighlight:Hide() row.kbHighlight = kbHighlight - -- Appearance Sets sub-options: class selector + Filter button (collected/PvE/PvP) - if opt.key == "appearanceSets" then - local FLYOUT_ROW_H_AS = 20 - local CLASSPOPUP_WIDTH_AS = 160 - local FILTERFLYOUT_WIDTH_AS = 150 - local FILTERFLYOUT_ROW_H = 22 - - -- Separator line - local asSep = dropdown:CreateTexture(nil, "ARTWORK") - asSep:SetHeight(1) - asSep:SetColorTexture(0.5, 0.5, 0.5, 0.4) - asSep:Hide() - row.asSep = asSep - - -- Build class data (same pattern as loot) - local CLASS_COLORS_AS = RAID_CLASS_COLORS - local asClasses = {} - for classIdx = 1, GetNumClasses() do - local className, classFile, classID = GetClassInfo(classIdx) - if className and classFile then - asClasses[#asClasses + 1] = { - classID = classID, className = className, classFile = classFile, - } - end - end - - local function ApplyAsFilterSelection() - if ns.Database and ns.Database.PopulateDynamicTransmogSets then - ns.Database:PopulateDynamicTransmogSets() - end - if searchEditBox:GetText() ~= "" then - UI:OnSearchTextChanged(searchEditBox:GetText()) - end - end - - ----------------------------------------------------------- - -- Class selector bar (matches loot spec selector exactly) - ----------------------------------------------------------- - local classSelectRow = CreateFrame("Button", nil, dropdown) - classSelectRow:SetSize(120, 27) - local csBg = classSelectRow:CreateTexture(nil, "BACKGROUND") - csBg:SetAtlas("common-dropdown-textholder") - csBg:SetAllPoints() - local csArrow = classSelectRow:CreateTexture(nil, "OVERLAY") - csArrow:SetAtlas("common-dropdown-a-button-hover") - csArrow:SetSize(20, 20) - csArrow:SetPoint("RIGHT", -2, -1) - csArrow:SetVertexColor(0.7, 0.7, 0.7) - local csLabel = classSelectRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - csLabel:SetPoint("LEFT", 8, 0) - csLabel:SetPoint("RIGHT", csArrow, "LEFT", -2, 0) - csLabel:SetJustifyH("LEFT") - csLabel:SetWordWrap(false) - classSelectRow:SetScript("OnEnter", function() csArrow:SetVertexColor(1, 1, 1) end) - classSelectRow:SetScript("OnLeave", function() csArrow:SetVertexColor(0.7, 0.7, 0.7) end) - classSelectRow:Hide() - row.asClassSelectRow = classSelectRow - row.asClassLabel = csLabel - - local function UpdateAsClassLabel() - local cf = EasyFind.db.appearanceSetClass - if not cf then - local _, _, cid = UnitClass("player") - for _, cls in ipairs(asClasses) do - if cls.classID == cid then - local ccl = CLASS_COLORS_AS[cls.classFile] - local cs = ccl and string.format("|cff%02x%02x%02x", ccl.r * 255, ccl.g * 255, ccl.b * 255) or "" - csLabel:SetText(cs .. cls.className .. "|r") - return - end - end - elseif cf == "all" then - csLabel:SetText("All Classes") - elseif type(cf) == "table" and cf.classID then - for _, cls in ipairs(asClasses) do - if cls.classID == cf.classID then - local ccl = CLASS_COLORS_AS[cls.classFile] - local cs = ccl and string.format("|cff%02x%02x%02x", ccl.r * 255, ccl.g * 255, ccl.b * 255) or "" - csLabel:SetText(cs .. cls.className .. "|r") - return - end - end - end - csLabel:SetText("All Classes") - end - UpdateAsClassLabel() - - -- Class popup (opens below the selector bar) - local classPopup = CreateFrame("Frame", "EasyFindAsClassPopup", UIParent, "BackdropTemplate") - classPopup:SetFrameStrata("TOOLTIP") - StylePopup(classPopup) - classPopup:EnableMouse(true) - classPopup:Hide() - classPopup:SetScript("OnShow", function(self) - self:RegisterEvent("GLOBAL_MOUSE_DOWN") - end) - classPopup:SetScript("OnHide", function(self) - self:UnregisterEvent("GLOBAL_MOUSE_DOWN") - end) - classPopup:SetScript("OnEvent", function(self, event) - if event == "GLOBAL_MOUSE_DOWN" then - if not self:IsMouseOver() and not classSelectRow:IsMouseOver() - and not dropdown:IsMouseOver() then - self:Hide() - end - end - end) - - local classPopupRows = {} - -- "All Classes" row - local allRow = CreateFrame("Button", nil, classPopup) - allRow:SetSize(CLASSPOPUP_WIDTH_AS - 16, FLYOUT_ROW_H_AS) - allRow:SetFrameLevel(classPopup:GetFrameLevel() + 10) - local allRadio, allSetRadio = CreateRadioTexture(allRow) - allRadio:SetPoint("LEFT", 4, 0) - local allLbl = allRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - allLbl:SetPoint("LEFT", allRadio, "RIGHT", 4, 0) - allLbl:SetText("All Classes") - local allHL = allRow:CreateTexture(nil, "HIGHLIGHT") - allHL:SetAllPoints() - allHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) - allRow._setRadioChecked = allSetRadio - allRow._classID = nil - allRow:SetScript("OnClick", function() - EasyFind.db.appearanceSetClass = "all" - UpdateAsClassLabel() - classPopup:Hide() - ApplyAsFilterSelection() - end) - classPopupRows[#classPopupRows + 1] = allRow - - for _, cls in ipairs(asClasses) do - local clsRow = CreateFrame("Button", nil, classPopup) - clsRow:SetSize(CLASSPOPUP_WIDTH_AS - 16, FLYOUT_ROW_H_AS) - clsRow:SetFrameLevel(classPopup:GetFrameLevel() + 10) - local cRadio, cSetRadio = CreateRadioTexture(clsRow) - cRadio:SetPoint("LEFT", 4, 0) - local ccl = CLASS_COLORS_AS[cls.classFile] - local csStr = ccl and string.format("|cff%02x%02x%02x", ccl.r * 255, ccl.g * 255, ccl.b * 255) or "" - local cLbl = clsRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - cLbl:SetPoint("LEFT", cRadio, "RIGHT", 4, 0) - cLbl:SetText(csStr .. cls.className .. "|r") - local cHL = clsRow:CreateTexture(nil, "HIGHLIGHT") - cHL:SetAllPoints() - cHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) - clsRow._setRadioChecked = cSetRadio - clsRow._classID = cls.classID - clsRow:SetScript("OnClick", function() - EasyFind.db.appearanceSetClass = { classID = cls.classID } - UpdateAsClassLabel() - classPopup:Hide() - ApplyAsFilterSelection() - end) - classPopupRows[#classPopupRows + 1] = clsRow - end - - local function LayoutClassPopup() - local py = -6 - local cf = EasyFind.db.appearanceSetClass - for _, r in ipairs(classPopupRows) do - r:ClearAllPoints() - r:SetPoint("TOPLEFT", classPopup, "TOPLEFT", 8, py) - r:Show() - if r._setRadioChecked then - local match = false - if not r._classID then - match = cf == "all" - else - if type(cf) == "table" and cf.classID == r._classID then - match = true - elseif not cf then - local _, _, cid = UnitClass("player") - match = r._classID == cid - end - end - r._setRadioChecked(match) - end - py = py - FLYOUT_ROW_H_AS - end - classPopup:SetSize(CLASSPOPUP_WIDTH_AS, -py + 6) - end - - classSelectRow:SetScript("OnClick", function(self) - if classPopup:IsShown() then - classPopup:Hide() - return - end - LayoutClassPopup() - classPopup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) - classPopup:ClearAllPoints() - classPopup:SetPoint("TOPLEFT", self, "BOTTOMLEFT", 0, 2) - classPopup:Show() - end) - - ----------------------------------------------------------- - -- Filter button (Options.lua multi-select dropdown style) - ----------------------------------------------------------- - local asFilterDefs = { - { dbKey = "appearanceSetCollected", label = "Collected", shortLabel = "Collected" }, - { dbKey = "appearanceSetNotCollected", label = "Not Collected", shortLabel = "Not Collected" }, - { dbKey = "appearanceSetPvE", label = "PvE", shortLabel = "PvE" }, - { dbKey = "appearanceSetPvP", label = "PvP", shortLabel = "PvP" }, - } - - local filterBtn = CreateFrame("Button", nil, dropdown) - filterBtn:SetSize(120, 22) - local filterBg = filterBtn:CreateTexture(nil, "BACKGROUND") - filterBg:SetAllPoints() - filterBg:SetAtlas("common-dropdown-b-button-hover") - local filterArrow = filterBtn:CreateTexture(nil, "OVERLAY") - filterArrow:SetSize(16, 16) - filterArrow:SetPoint("RIGHT", -2, 0) - filterArrow:SetAtlas("common-dropdown-b-arrow-closed") - local filterText = filterBtn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") - filterText:SetPoint("LEFT", 4, 0) - filterText:SetPoint("RIGHT", -16, 0) - filterText:SetJustifyH("CENTER") - filterText:SetText("Filter") - filterBtn:Hide() - row.asFilterBtn = filterBtn - - -- Filter flyout panel - local filterFlyout = CreateFrame("Frame", nil, UIParent, "BackdropTemplate") - filterFlyout:SetFrameStrata("TOOLTIP") - StylePopup(filterFlyout) - filterFlyout:EnableMouse(true) - filterFlyout:Hide() - - local asSubRows = {} - local ffy = -4 - for si, def in ipairs(asFilterDefs) do - local cbRow = CreateFrame("CheckButton", nil, filterFlyout) - cbRow:SetSize(FILTERFLYOUT_WIDTH_AS - 8, FILTERFLYOUT_ROW_H) - cbRow:SetPoint("TOPLEFT", filterFlyout, "TOPLEFT", 4, ffy) - - cbRow:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") - cbRow:GetNormalTexture():SetSize(CHECK_SIZE, CHECK_SIZE) - cbRow:GetNormalTexture():ClearAllPoints() - cbRow:GetNormalTexture():SetPoint("LEFT", 4, 0) - - cbRow:SetCheckedTexture("Interface\\Buttons\\UI-CheckBox-Check") - cbRow:GetCheckedTexture():SetSize(CHECK_SIZE, CHECK_SIZE) - cbRow:GetCheckedTexture():ClearAllPoints() - cbRow:GetCheckedTexture():SetPoint("LEFT", 4, 0) - - local cbLabel = cbRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") - cbLabel:SetPoint("LEFT", cbRow:GetNormalTexture(), "RIGHT", 4, 0) - cbLabel:SetText(def.label) - - local cbHL = cbRow:CreateTexture(nil, "HIGHLIGHT") - cbHL:SetAllPoints() - cbHL:SetColorTexture(1, 1, 1, 0.1) - - local val = EasyFind.db[def.dbKey] - if val == nil then val = true end - cbRow:SetChecked(val) - cbRow.dbKey = def.dbKey - - cbRow:SetScript("OnClick", function(self) - EasyFind.db[def.dbKey] = self:GetChecked() - ApplyAsFilterSelection() - end) - - asSubRows[si] = cbRow - ffy = ffy - FILTERFLYOUT_ROW_H - -- Separator between "Not Collected" and "PvE" - if si == 2 then - local sep = filterFlyout:CreateTexture(nil, "ARTWORK") - sep:SetHeight(1) - sep:SetPoint("TOPLEFT", filterFlyout, "TOPLEFT", 8, ffy + 2) - sep:SetPoint("TOPRIGHT", filterFlyout, "TOPRIGHT", -8, ffy + 2) - sep:SetColorTexture(0.5, 0.5, 0.5, 0.4) - ffy = ffy - 6 - end - end - filterFlyout:SetSize(FILTERFLYOUT_WIDTH_AS, -ffy + 4) - row.asSubRows = asSubRows - - filterBtn:SetScript("OnClick", function() - local opening = not filterFlyout:IsShown() - filterFlyout:SetShown(opening) - filterArrow:SetAtlas(opening and "common-dropdown-b-arrow-open" or "common-dropdown-b-arrow-closed") - if opening then - filterFlyout:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) - filterFlyout:ClearAllPoints() - filterFlyout:SetPoint("TOPLEFT", filterBtn, "BOTTOMLEFT", 0, -2) - end - end) - filterFlyout:SetScript("OnShow", function(self) - self:RegisterEvent("GLOBAL_MOUSE_DOWN") - end) - filterFlyout:SetScript("OnHide", function(self) - self:UnregisterEvent("GLOBAL_MOUSE_DOWN") - filterArrow:SetAtlas("common-dropdown-b-arrow-closed") - end) - filterFlyout:SetScript("OnEvent", function(self, event) - if event == "GLOBAL_MOUSE_DOWN" then - if not self:IsMouseOver() and not filterBtn:IsMouseOver() - and not dropdown:IsMouseOver() then - self:Hide() - end - end - end) - - -- Register appearance set popups as dropdown guard frames - dropdownGuardFrames[#dropdownGuardFrames + 1] = classPopup - dropdownGuardFrames[#dropdownGuardFrames + 1] = filterFlyout - - -- Close popups when the main dropdown hides - dropdown:HookScript("OnHide", function() - classPopup:Hide() - filterFlyout:Hide() - end) - - row.updateAppearanceSetToggle = function() - local checked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.appearanceSets ~= false - if asSep then asSep:SetShown(checked) end - classSelectRow:SetShown(checked) - filterBtn:SetShown(checked) - for _, sr in ipairs(asSubRows) do - if sr.dbKey and sr.SetChecked then - sr:SetChecked(EasyFind.db[sr.dbKey] ~= false) - end - end - UpdateAsClassLabel() - if not checked then - classPopup:Hide() - filterFlyout:Hide() - end - end - end - row:SetChecked(true) row:SetScript("OnClick", function(self) local filters = EasyFind.db.uiSearchFilters filters[opt.key] = self:GetChecked() if self.updateLootToggle then self.updateLootToggle() end - if self.updateAppearanceSetToggle then self.updateAppearanceSetToggle() end LayoutDropdown() if searchEditBox:GetText() ~= "" then UI:OnSearchTextChanged(searchEditBox:GetText()) @@ -3202,16 +3696,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local parentVisible = (not opt.parentKey) or (filters[opt.parentKey] ~= false) if not parentVisible then row:Hide() - -- Also hide nested sub-options so they don't float on screen - if row.asClassSelectRow then row.asClassSelectRow:Hide() end - if row.asFilterBtn then row.asFilterBtn:Hide() end - if row.asSep then row.asSep:Hide() end - if row.lootSep then row.lootSep:Hide() end - if row.specSelectRow then row.specSelectRow:Hide() end - if row.diffBtn then row.diffBtn:Hide() end - if row.lootSubRows then - for _, sr in ipairs(row.lootSubRows) do sr:Hide() end - end else local rowIndent = opt.parentKey and SUB_INDENT or 0 row:ClearAllPoints() @@ -3219,93 +3703,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) row:Show() dropdownNavRows[#dropdownNavRows + 1] = row y = y - ROW_HEIGHT - -- Sub-options nest one indent level deeper than the row - local subIndent = rowIndent + SUB_INDENT - -- Appearance Sets sub-rows - if row.asClassSelectRow then - local asChecked = filters and filters.appearanceSets ~= false - if row.asSep then - if asChecked then - row.asSep:ClearAllPoints() - row.asSep:SetPoint("LEFT", 8 + subIndent, 0) - row.asSep:SetPoint("RIGHT", dropdown, "RIGHT", -8, 0) - row.asSep:SetPoint("TOP", 0, y - 2) - row.asSep:Show() - y = y - 6 - else - row.asSep:Hide() - end - end - if asChecked then - row.asClassSelectRow:ClearAllPoints() - row.asClassSelectRow:SetPoint("TOPLEFT", 8 + subIndent, y) - row.asClassSelectRow:Show() - dropdownNavRows[#dropdownNavRows + 1] = row.asClassSelectRow - y = y - 28 - else - row.asClassSelectRow:Hide() - end - if row.asFilterBtn then - if asChecked then - row.asFilterBtn:ClearAllPoints() - row.asFilterBtn:SetPoint("TOPLEFT", 8 + subIndent, y) - row.asFilterBtn:Show() - dropdownNavRows[#dropdownNavRows + 1] = row.asFilterBtn - y = y - 24 - else - row.asFilterBtn:Hide() - end - end - end - -- Loot sub-rows - if row.lootSubRows then - local lootChecked = filters and filters.loot ~= false - if row.lootSep then - if lootChecked then - row.lootSep:ClearAllPoints() - row.lootSep:SetPoint("LEFT", 8 + subIndent, 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 - if row.specSelectRow then - if lootChecked then - row.specSelectRow:ClearAllPoints() - row.specSelectRow:SetPoint("TOPLEFT", 8 + subIndent, y) - row.specSelectRow:Show() - dropdownNavRows[#dropdownNavRows + 1] = row.specSelectRow - y = y - 24 - else - row.specSelectRow:Hide() - end - end - if row.diffBtn then - if lootChecked then - row.diffBtn:ClearAllPoints() - row.diffBtn:SetPoint("TOPLEFT", 8 + subIndent, y) - row.diffBtn:Show() - dropdownNavRows[#dropdownNavRows + 1] = row.diffBtn - y = y - 28 - else - row.diffBtn:Hide() - end - end - for _, sr in ipairs(row.lootSubRows) do - if lootChecked then - sr:ClearAllPoints() - sr:SetPoint("TOPLEFT", 8 + subIndent, y) - sr:Show() - dropdownNavRows[#dropdownNavRows + 1] = sr - y = y - ROW_HEIGHT - else - sr:Hide() - end - end - end end end dropdown:SetSize(DROPDOWN_WIDTH, -y + PADDING_BOTTOM) @@ -3426,9 +3823,8 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end - local asRow = checkRows and checkRows.appearanceSets - if asRow and asRow.updateAppearanceSetToggle then - asRow.updateAppearanceSetToggle() + if UI._SyncAppearanceSetOptions then + UI._SyncAppearanceSetOptions() end end if searchFrame.filterBtn and searchFrame.filterBtn.keyboardFocused then @@ -3472,10 +3868,15 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end) - -- Close when clicking outside (but not when interacting with sub-filter popups) + -- Close when clicking outside (but not when interacting with sub-filter popups). + -- Both LeftButton AND RightButton trigger close — without the right-button + -- check, right-clicking outside dismisses the search bar (whose handler + -- listens for GLOBAL_MOUSE_DOWN regardless of button) but leaves the + -- filter dropdown stuck open. dropdown:SetScript("OnUpdate", function(self) if self._demoSuspend then return end - if self:IsShown() and IsMouseButtonDown("LeftButton") then + if self:IsShown() + and (IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton")) then if not self:IsMouseOver() and not toggleBtn:IsMouseOver() then for _, guard in ipairs(dropdownGuardFrames) do if guard:IsShown() and guard:IsMouseOver() then return end @@ -3524,6 +3925,36 @@ function UI:CreateResultsFrame() resultsFrame:Hide() + -- Click-outside-to-close: hides the results frame on any click that + -- isn't on the search bar, results frame, or one of its associated + -- popups (filter dropdown, pin/right-click menu, gear/collections + -- option popups). Hover-out doesn't close — that's too sensitive. + resultsFrame:SetScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end) + resultsFrame:SetScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + end) + resultsFrame:SetScript("OnEvent", function(self, event) + if event ~= "GLOBAL_MOUSE_DOWN" then return end + if self:IsMouseOver() then return end + if searchFrame and searchFrame:IsMouseOver() then return end + local guards = { + _G["EasyFindUIFilterDropdown"], + _G["EasyFindPinPopup"], + _G["EasyFindAsOptionsPopup"], + _G["EasyFindAsClassPopup"], + _G["EasyFindGearOptionsPopup"], + _G["EasyFindDiffPopup"], + _G["EasyFindSpecPopup"], + _G["EasyFindSpecFlyout"], + } + for _, g in ipairs(guards) do + if g and g:IsShown() and g:IsMouseOver() then return end + end + UI:HideResults() + end) + local resizeTimer resultsFrame:SetScript("OnSizeChanged", function() if not resultsFrame:IsShown() or not cachedHierarchical then return end -- luacheck: ignore 113 @@ -3575,8 +4006,16 @@ function UI:CreateResultsFrame() resultsFrame.categorySeps = categorySeps end --- Vibrant indent line colors for each depth level (used by Classic theme) -local INDENT_COLORS = THEMES["Classic"].indentColors +-- Per-depth indent line colors. Used as a fallback when a theme's +-- indentColors array doesn't define a color for the requested depth. +local INDENT_COLORS = { + {0.40, 0.85, 1.00, 0.80}, + {1.00, 0.55, 0.10, 0.80}, + {0.55, 1.00, 0.35, 0.80}, + {1.00, 0.40, 0.70, 0.80}, + {0.70, 0.55, 1.00, 0.80}, + {1.00, 0.90, 0.20, 0.80}, +} local INDENT_PX = 20 -- pixels per depth level (icon 16 + 4 gap) local LINE_X_OFF = 10 -- horizontal offset within each depth column (clears tab rounded corner) @@ -3592,6 +4031,7 @@ local expandedContainers = {} -- tracks which containers have had children inje local groupUI, groupMounts, groupToys, groupPets, groupOutfits, groupLoot, groupAppearanceSets, groupMap = {}, {}, {}, {}, {}, {}, {}, {} local groupAchievements, groupCurrencies, groupReputations = {}, {}, {} local groupBags, groupOptions = {}, {} +local groupHeirlooms = {} local uiSectionHeader = { name = "UI Elements", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, @@ -3624,6 +4064,10 @@ local outfitSectionHeader = { name = "Outfits", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, } +local heirloomSectionHeader = { + name = "Heirlooms", depth = 0, isPathNode = true, + isMatch = false, isSectionHeader = true, +} local lootSectionHeader = { name = "Gear", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, @@ -3647,41 +4091,52 @@ local optionsSectionHeader = { -- Flat-list mode scratch: reused entry pool keeps per-keystroke allocations -- low when uiHideHeaders is on. flatEntries holds recyclable entry tables; --- flatCatGroups is the catGroup buffer for sort ordering. +-- flatCombined is the merged-results buffer that gets sorted by score. local flatEntries = {} -local flatCatGroups = {} +local flatCombined = {} + +local PB = { + ui = {}, ach = {}, cur = {}, rep = {}, + mounts = {}, toys = {}, pets = {}, + outfits = {}, loot = {}, appsets = {}, + bags = {}, options = {}, heirlooms = {}, +} + +local SCRATCH = { + visible = {}, + isLastChild = {}, + catSepYPositions = {}, + bestCatScore = {}, + catGroups = {}, +} --- Module-level so tsort doesn't re-allocate the closure each keystroke. -local function CatGroupScoreDescending(a, b) +local function CatGroupCompare(a, b) if a.score ~= b.score then return a.score > b.score end return a.key < b.key end --- Within-group ordering for flat-list mode. Path-aware for UI entries so --- siblings under the same parent cluster together (Group Finder > PvP items --- stay adjacent regardless of relevance score). Collection items just sort --- alphabetically since they have no shared parent path. -local function FlatPathLess(ra, rb) - local pa, pb = ra.data.path, rb.data.path - local na = pa and #pa or 0 - local nb = pb and #pb or 0 - local m = na < nb and na or nb - for i = 1, m do - local x, y = pa[i], pb[i] - if x ~= y then return x < y end - end - if na ~= nb then return na < nb end - return (ra.data.name or "") < (rb.data.name or "") +local function BuildBucketInto(group, bucketResults) + if #bucketResults == 0 then return end + local hier = ns.Database:BuildHierarchicalResults(bucketResults) + for hi = 1, #hier do + local entry = hier[hi] + if entry.isContainer then + collapsedNodes[entry.name .. "_" .. (entry.depth or 0)] = true + end + group[#group + 1] = entry + end end -local function FlatNameLess(ra, rb) - return (ra.data.name or "") < (rb.data.name or "") -end -local function FlatMapLess(ra, rb) - local za = ra.data.zoneName or "" - local zb = rb.data.zoneName or "" - if za ~= zb then return za < zb end +-- Within-group ordering for flat-list mode. Score-first so the best +-- matches stay at the top — alphabetical was burying high-scoring +-- prefix matches (e.g. "Skull Bash" for query "skull") below low- +-- scoring fuzzy matches (e.g. "Armor Skills" via "skill"). Path/name +-- only break ties between equally-scored results so siblings under +-- the same parent still cluster predictably. +local function FlatNameLess(ra, rb) + local sa, sb = ra.score or 0, rb.score or 0 + if sa ~= sb then return sa > sb end return (ra.data.name or "") < (rb.data.name or "") end @@ -4000,7 +4455,7 @@ function UI:CreateResultButton(index) -- Pin indicator (small map pin badge on the icon) local pinIcon = resultRow:CreateTexture(nil, "OVERLAY") - pinIcon:SetSize(10, 10) + pinIcon:SetSize(13, 13) pinIcon:SetPoint("BOTTOMLEFT", icon, "BOTTOMRIGHT", -4, -1) pinIcon:SetAtlas("Waypoint-MapPin-ChatIcon") pinIcon:Hide() @@ -4054,6 +4509,313 @@ function UI:CreateResultButton(index) amountText:Hide() resultRow.amountText = amountText + -- Right-aligned setting state widget (checkbox + optional checkmark + -- overlay for boolean settings). For dropdowns we reuse amountText + -- to show the current value; this widget is the boolean visual. + -- The box stays visible whether checked or not; the checkmark is + -- a separate overlay that toggles on/off, so the box doesn't + -- vanish behind the checkmark when the setting is enabled. + local settingState = resultRow:CreateTexture(nil, "OVERLAY") + settingState:SetSize(16, 16) + settingState:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) + settingState:SetAtlas("checkbox-minimal") + settingState:Hide() + resultRow.settingState = settingState + + local settingCheck = resultRow:CreateTexture(nil, "OVERLAY", nil, 1) + settingCheck:SetSize(16, 16) + settingCheck:SetPoint("CENTER", settingState, "CENTER", 0, 0) + settingCheck:SetAtlas("checkmark-minimal") + settingCheck:Hide() + resultRow.settingCheck = settingCheck + + -- SliderWithSteppers-style widget for slider settings. The minus + -- and plus buttons step the value by data.settingStep. The slider + -- itself supports drag and click-on-track. Frame levels are bumped + -- above the parent row so clicks land on the widget, not the row. + local sliderGroup = CreateFrame("Frame", nil, resultRow) + sliderGroup:SetSize(140, 18) + sliderGroup:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) + sliderGroup:SetFrameLevel(resultRow:GetFrameLevel() + 5) + sliderGroup:Hide() + resultRow.settingSliderGroup = sliderGroup + + local function applySettingValue(variable, newVal) + if not variable then return end + local applied = false + if Settings and Settings.GetSetting then + local sok, settObj = pcall(Settings.GetSetting, variable) + if sok and settObj and settObj.SetValue then + pcall(settObj.SetValue, settObj, newVal) + applied = true + end + end + if not applied and SetCVar then + SetCVar(variable, newVal) + end + end + + local function clampToRange(value, slider) + local minV, maxV = slider:GetMinMaxValues() + if value < minV then return minV end + if value > maxV then return maxV end + return value + end + + local stepBack = CreateFrame("Button", nil, sliderGroup) + stepBack:SetSize(11, 18) + stepBack:SetPoint("LEFT", sliderGroup, "LEFT", 0, 0) + stepBack:EnableMouse(true) + local stepBackTex = stepBack:CreateTexture(nil, "ARTWORK") + stepBackTex:SetAllPoints() + stepBackTex:SetAtlas("Minimal_SliderBar_Button_Left") + stepBack:SetHighlightAtlas("Minimal_SliderBar_Button_Left", "ADD") + resultRow.settingStepBack = stepBack + + local stepFwd = CreateFrame("Button", nil, sliderGroup) + stepFwd:SetSize(11, 18) + stepFwd:SetPoint("RIGHT", sliderGroup, "RIGHT", 0, 0) + stepFwd:EnableMouse(true) + local stepFwdTex = stepFwd:CreateTexture(nil, "ARTWORK") + stepFwdTex:SetAllPoints() + stepFwdTex:SetAtlas("Minimal_SliderBar_Button_Right") + stepFwd:SetHighlightAtlas("Minimal_SliderBar_Button_Right", "ADD") + resultRow.settingStepFwd = stepFwd + + local settingSlider = CreateFrame("Slider", nil, sliderGroup) + settingSlider:SetPoint("LEFT", stepBack, "RIGHT", 2, 0) + settingSlider:SetPoint("RIGHT", stepFwd, "LEFT", -2, 0) + settingSlider:SetHeight(16) + settingSlider:EnableMouse(true) + settingSlider:SetOrientation("HORIZONTAL") + -- Match Blizzard's SliderWithSteppers atlases (Minimal_SliderBar_*). + -- Track is composed of Left/Right endcaps + a stretchable Middle. + -- Thumb is the diamond Minimal_SliderBar_Button atlas. + local trackLeft = settingSlider:CreateTexture(nil, "ARTWORK") + trackLeft:SetAtlas("Minimal_SliderBar_Left", true) + trackLeft:SetPoint("LEFT", 0, 0) + local trackRight = settingSlider:CreateTexture(nil, "ARTWORK") + trackRight:SetAtlas("Minimal_SliderBar_Right", true) + trackRight:SetPoint("RIGHT", 0, 0) + local trackMid = settingSlider:CreateTexture(nil, "ARTWORK") + trackMid:SetAtlas("_Minimal_SliderBar_Middle", false) + trackMid:SetPoint("LEFT", trackLeft, "RIGHT", 0, 0) + trackMid:SetPoint("RIGHT", trackRight, "LEFT", 0, 0) + trackMid:SetHeight(16) + -- Need a real texture file before GetThumbTexture returns a + -- valid Texture object; UI-SliderBar-Button-Horizontal is a + -- guaranteed core texture. We immediately swap to the Minimal + -- diamond atlas via SetAtlas on the same texture. + settingSlider:SetThumbTexture("Interface\\Buttons\\UI-SliderBar-Button-Horizontal") + local thumb = settingSlider:GetThumbTexture() + if thumb then + thumb:SetAtlas("Minimal_SliderBar_Button", true) + thumb:SetSize(20, 19) + end + if settingSlider.SetObeyStepOnDrag then settingSlider:SetObeyStepOnDrag(true) end + settingSlider:EnableMouseWheel(false) + settingSlider:SetScript("OnMouseWheel", nil) + settingSlider:SetScript("OnValueChanged", function(self, newVal) + if self._updating then return end + applySettingValue(self._settingVar, newVal) + local valText = resultRow.settingSliderValue + if valText then + if newVal == mfloor(newVal) then + valText:SetText(tostring(mfloor(newVal))) + else + valText:SetText(sformat("%.2f", newVal)) + end + end + end) + resultRow.settingSlider = settingSlider + + stepBack:SetScript("OnClick", function() + local slider = resultRow.settingSlider + if not slider:IsShown() then return end + local cur = slider:GetValue() + local step = slider:GetValueStep() + if step == 0 then step = 1 end + slider:SetValue(clampToRange(cur - step, slider)) + end) + stepFwd:SetScript("OnClick", function() + local slider = resultRow.settingSlider + if not slider:IsShown() then return end + local cur = slider:GetValue() + local step = slider:GetValueStep() + if step == 0 then step = 1 end + slider:SetValue(clampToRange(cur + step, slider)) + end) + + -- Slider/stepper clicks bypass resultRow's PostClick (clicks on a + -- child frame don't bubble to the parent button) so the row's own + -- "refocus editbox" path never runs. Restore focus on mouse-up so + -- the user can resume typing or arrow-navigating without having + -- to click the search bar again. + local function refocusEditbox() + if not (searchFrame and searchFrame.editBox) then return end + if navFrame and navFrame:IsKeyboardEnabled() then return end + searchFrame.editBox.blockFocus = nil + searchFrame.editBox:SetFocus() + end + settingSlider:HookScript("OnMouseUp", refocusEditbox) + stepBack:HookScript("OnMouseUp", refocusEditbox) + stepFwd:HookScript("OnMouseUp", refocusEditbox) + + -- Inline keybind editor: two buttons (primary / alternate) showing + -- the current binding text. Click captures the next keypress and + -- assigns it to the action. Right-click clears the binding. + local keybindGroup = CreateFrame("Frame", nil, resultRow) + keybindGroup:SetSize(140, 20) + keybindGroup:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) + keybindGroup:SetFrameLevel(resultRow:GetFrameLevel() + 5) + keybindGroup:Hide() + resultRow.settingKeybindGroup = keybindGroup + + local function MakeKeybindButton(parent) + local btn = CreateFrame("Button", nil, parent) + btn:SetSize(66, 20) + btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + local bg = btn:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints() + bg:SetColorTexture(0.08, 0.08, 0.08, 0.85) + local hl = btn:CreateTexture(nil, "HIGHLIGHT") + hl:SetAllPoints() + hl:SetColorTexture(0.4, 0.4, 0.5, 0.4) + btn:SetNormalFontObject("GameFontHighlightSmall") + btn:SetText("Not Bound") + local txt = btn:GetFontString() + if txt then txt:SetPoint("CENTER") end + local border = CreateFrame("Frame", nil, btn, "BackdropTemplate") + border:SetAllPoints() + if border.SetBackdrop then + border:SetBackdrop({ + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + edgeSize = 10, + insets = { left = 2, right = 2, top = 2, bottom = 2 }, + }) + border:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.7) + end + -- Forward hover to the parent result row so its tooltip stays + -- visible when the cursor moves from the row onto these buttons + -- (Buttons consume hover events, so the row's OnEnter/OnLeave + -- doesn't see them otherwise). + btn:HookScript("OnEnter", function(self) + local rowEnter = resultRow:GetScript("OnEnter") + if rowEnter then rowEnter(resultRow) end + end) + btn:HookScript("OnLeave", function(self) + local rowLeave = resultRow:GetScript("OnLeave") + if rowLeave then rowLeave(resultRow) end + end) + return btn + end + + local kb1 = MakeKeybindButton(keybindGroup) + kb1:SetPoint("LEFT", keybindGroup, "LEFT", 0, 0) + resultRow.settingKeybind1 = kb1 + + local kb2 = MakeKeybindButton(keybindGroup) + kb2:SetPoint("RIGHT", keybindGroup, "RIGHT", 0, 0) + resultRow.settingKeybind2 = kb2 + + local function StopKeybindCapture(btn) + if not btn._waitingForKey then return end + btn._waitingForKey = false + Utils.SafeCallMethod(btn, "EnableKeyboard", false) + btn:UnlockHighlight() + btn:SetScript("OnKeyDown", nil) + if activeKeybindBtn == btn then activeKeybindBtn = nil end + if btn._refresh then btn._refresh() end + -- Defer the editbox re-enable + refocus to next frame: the + -- captured key's OnChar event still has to fire after this + -- OnKeyDown handler returns, and refocusing now would let the + -- editbox receive the character ("A" → bound to A AND typed + -- into the search bar). Letting the disabled editbox swallow + -- the OnChar first prevents the leak. + Utils.SafeAfter(0, function() + if searchFrame and searchFrame.editBox then + searchFrame.editBox:SetEnabled(true) + end + refocusEditbox() + end) + end + kb1._stopCapture = StopKeybindCapture + kb2._stopCapture = StopKeybindCapture + + local function StartKeybindCapture(btn, action, slot) + if btn._waitingForKey then + StopKeybindCapture(btn) + return + end + if activeKeybindBtn and activeKeybindBtn ~= btn then + StopKeybindCapture(activeKeybindBtn) + end + activeKeybindBtn = btn + btn._waitingForKey = true + btn:SetText("Press a key...") + btn:LockHighlight() + if searchFrame and searchFrame.editBox then + searchFrame.editBox.blockFocus = true + searchFrame.editBox:ClearFocus() + searchFrame.editBox:SetEnabled(false) + end + Utils.SafeCallMethod(btn, "EnableKeyboard", true) + btn:SetScript("OnKeyDown", function(self, key) + if key == "LSHIFT" or key == "RSHIFT" or key == "LCTRL" + or key == "RCTRL" or key == "LALT" or key == "RALT" then + return + end + if key == "ESCAPE" then + StopKeybindCapture(self) + return + end + local hasMod = IsAltKeyDown() or IsControlKeyDown() or IsShiftKeyDown() + if not hasMod and (key == "SPACE" or key == "ENTER" + or key == "W" or key == "A" or key == "S" or key == "D") then + return + end + local combo = "" + if IsAltKeyDown() then combo = combo .. "ALT-" end + if IsControlKeyDown() then combo = combo .. "CTRL-" end + if IsShiftKeyDown() then combo = combo .. "SHIFT-" end + combo = combo .. key + -- Only clear the slot we're editing so the other slot + -- (primary vs alt) stays intact. + local k1, k2 = GetBindingKey(action) + local oldKey = (slot == 1) and k1 or k2 + if oldKey then SetBinding(oldKey) end + SetBinding(combo, action) + SaveBindings(GetCurrentBindingSet()) + StopKeybindCapture(self) + end) + end + + local function MakeBindingClickHandler(slot) + return function(self, mouseButton) + local action = self._bindingAction + if not action then return end + if mouseButton == "RightButton" then + if self._waitingForKey then StopKeybindCapture(self); return end + local k1, k2 = GetBindingKey(action) + local oldKey = (slot == 1) and k1 or k2 + if oldKey then SetBinding(oldKey) end + SaveBindings(GetCurrentBindingSet()) + if self._refresh then self._refresh() end + refocusEditbox() + return + end + StartKeybindCapture(self, action, slot) + end + end + kb1:SetScript("OnClick", MakeBindingClickHandler(1)) + kb2:SetScript("OnClick", MakeBindingClickHandler(2)) + + local settingSliderValue = sliderGroup:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + settingSliderValue:SetPoint("BOTTOM", sliderGroup, "TOP", 0, -2) + settingSliderValue:SetTextColor(0.7, 0.7, 0.7, 1.0) + settingSliderValue:SetShadowOffset(1, -1) + resultRow.settingSliderValue = settingSliderValue + -- Right-aligned reputation standing bar -- Structure: repBar (dark bg + border) → repClip (clips fill) → repFillFrame (colored, same shape) -- repBar → repTextOverlay (text on top of everything) @@ -4135,21 +4897,25 @@ function UI:CreateResultButton(index) flatCatIcon:Hide() resultRow.flatCatIcon = flatCatIcon - -- LeftButtonDown for the secure-action click: SecureActionButton's - -- handler in retail fires reliably on Down events for spell/toy/ - -- macro/action types, but silently no-ops for some types when - -- registered on Up only. RegisterForDrag still works because drag - -- detection runs from press-and-move regardless of click registry. - -- RightButtonUp keeps the pin/alias context menu on release. + -- LeftButtonDown for the secure cast: type=spell silently no-ops + -- on LeftButtonUp for many spells (this was confirmed in the TBC + -- version where Down works perfectly). RegisterForDrag would + -- defer the Down click and break that, so we route drag-to-bar + -- through Shift+click instead (handled in PreClick below). resultRow:RegisterForClicks("LeftButtonDown", "RightButtonUp") - -- Drag-to-pickup: hold left mouse and drag a result row to drop - -- the action it represents on the cursor (so it can be placed on - -- action bars, etc.) — matching native UI behavior. Mounts route - -- through their cast spell because C_MountJournal.Pickup takes a - -- display index that's only meaningful inside the journal's - -- filtered view. C_Spell.PickupSpell is preferred over the legacy - -- global because Midnight phased the global out for some spells. + -- Shift+drag on a row picks the action up onto the cursor (for + -- placing on action bars, banks, etc.) instead of casting. We + -- can't use RegisterForDrag here because it defers the Down + -- click and silently breaks type=spell casts. So we do it + -- manually: PreClick detects Shift and clears the secure type + -- so the cast doesn't fire, OnMouseDown records the press + -- position, OnUpdate watches for movement, and the actual + -- Pickup* call happens once the cursor has moved past the + -- 5px drag threshold. Plain shift+click without movement + -- does nothing — matches Blizzard's action-bar drag feel. + -- C_Spell.PickupSpell is preferred over the legacy global since + -- Midnight phased PickupSpell out for some spells. local function PickupSpellCompat(spellID) if C_Spell and C_Spell.PickupSpell then C_Spell.PickupSpell(spellID) @@ -4157,10 +4923,7 @@ function UI:CreateResultButton(index) PickupSpell(spellID) end end - resultRow:RegisterForDrag("LeftButton") - resultRow:SetScript("OnDragStart", function(self) - local d = self.data - if not d then return end + local function PickupRowAction(d) if InCombatLockdown() then return end ClearCursor() if d.mountID and C_MountJournal and C_MountJournal.GetMountInfoByID then @@ -4177,10 +4940,6 @@ function UI:CreateResultButton(index) elseif d.spellID then PickupSpellCompat(d.spellID) elseif d.bagID and d.bagSlot then - -- Bag items: pick up by container slot, which mirrors the - -- native bag-item drag (carries stack count, can drop on - -- action bars, banks, etc.). Fall back to PickupItem when - -- the container slot lookup isn't available. local pickup = (C_Container and C_Container.PickupContainerItem) or PickupContainerItem if pickup then pickup(d.bagID, d.bagSlot) @@ -4190,70 +4949,126 @@ function UI:CreateResultButton(index) elseif d.itemID and PickupItem then PickupItem(d.itemID) end + end + local DRAG_PX = 5 + -- HookScript not SetScript: SecureActionButtonTemplate uses the + -- native OnMouseDown / OnMouseUp handlers internally to dispatch + -- the secure click. SetScript would replace them and break casts. + resultRow:HookScript("OnMouseDown", function(self, button) + if button ~= "LeftButton" then return end + if not IsShiftKeyDown() then return end + if not self.data then return end + local x, y = GetCursorPosition() + self._dragOriginX, self._dragOriginY = x, y + end) + resultRow:HookScript("OnUpdate", function(self) + if not self._dragOriginX then return end + local x, y = GetCursorPosition() + local dx, dy = x - self._dragOriginX, y - self._dragOriginY + if dx * dx + dy * dy < DRAG_PX * DRAG_PX then return end + self._dragOriginX, self._dragOriginY = nil, nil + self._pickedUp = true + if self.data then PickupRowAction(self.data) end + end) + resultRow:HookScript("OnMouseUp", function(self) + self._dragOriginX, self._dragOriginY = nil, nil + -- If we picked up this cycle and the user released over an + -- action bar slot, place it (emulates native drag-drop, which + -- we can't get via RegisterForDrag because it'd defer the Down + -- click and break casts). + if not self._pickedUp then return end + if InCombatLockdown() then return end + local cursorType = GetCursorInfo and GetCursorInfo() + if not cursorType then return end + local foci + if GetMouseFoci then + foci = GetMouseFoci() + elseif GetMouseFocus then + foci = { GetMouseFocus() } + end + if not foci then return end + for i = 1, #foci do + local f = foci[i] + local slot = f and (f.action or (f.GetAttribute and f:GetAttribute("action"))) + if slot then + if PlaceAction then PlaceAction(slot) end + ClearCursor() + break + end + end end) - - -- 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 InCombatLockdown() then return end + + local d = self.data + + -- Shift held: kill the cast for this click. The pickup (if any) + -- happens via the OnMouseDown / OnUpdate drag detection above — + -- this branch only ensures the secure handler is a no-op so + -- nothing fires when the user hasn't moved yet. Setting the + -- skip-navigation flag here (not waiting for OnUpdate) is what + -- prevents PostClick from closing the window before OnUpdate + -- has had a chance to detect movement and pick up the action. + if d and IsShiftKeyDown() then + self:SetAttribute("type", nil) + self._lastAttrType = nil + self._lastAttrKey = nil + self._lastAttrVal = nil + self._pickedUp = true + return + end + + -- Outfit equip: place onto a temp action slot, then the secure + -- UseAction dispatch fires on the action attribute. + local outfitID = d and d.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 - -- 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 + self:SetAttribute("type", nil) + self:SetAttribute("action", nil) return end self._outfitSlot = tempSlot self._outfitID = outfitID - if not InCombatLockdown() then - self:SetAttribute("action", tempSlot) - end + self:SetAttribute("action", tempSlot) 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 self._outfitID = nil - if not InCombatLockdown() then - self:SetAttribute("type", nil) - self:SetAttribute("action", nil) - end + self:SetAttribute("type", nil) + self:SetAttribute("action", nil) end end end) resultRow:SetScript("PostClick", function(self, mouseButton, down) - -- 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 - -- 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.blockFocus = nil - searchFrame.editBox:SetFocus() - end - return + -- Shift+click pickup: cursor is holding the action for the + -- user to drop on a bar. Don't navigate away or close. + if self._pickedUp then + self._pickedUp = nil + return + end + -- Block result selection if outfit equip is on cooldown (keep results open). + -- Toys are deliberately NOT checked here: GetItemCooldown returns the + -- cast-time of a freshly-started channel as a "cooldown", which would + -- keep the window open every time you click a cast-toy (Hearthstone, + -- garrison hearthstone, etc.). Outfit cooldown is a real swap-lockout + -- we manage ourselves, so it's safe to gate on. + if self.data and mouseButton == "LeftButton" and self.data.outfitID + and outfitCdStart > 0 + and outfitCdDuration - (GetTime() - outfitCdStart) > 0 then + if searchFrame and searchFrame.editBox and not navFrame:IsKeyboardEnabled() then + searchFrame.editBox.blockFocus = nil + searchFrame.editBox:SetFocus() end + return end -- Clean up temp action slot after outfit equip. @@ -4285,6 +5100,7 @@ function UI:CreateResultButton(index) local isPinned = IsUIItemPinned(pinData) local hasGuide = pinData.steps or pinData.transmogSetID or (pinData.category == "Loot" and pinData.itemID) + or pinData.mapSearchResult local onGuide = hasGuide and function() UI:SelectResult(pinData, true) end or nil @@ -4300,10 +5116,10 @@ function UI:CreateResultButton(index) end local editBox = searchFrame and searchFrame.editBox local text = editBox and editBox:GetText() or "" - if text == "" and editBox and editBox:HasFocus() then + if text == "" then UI:ShowPinnedItems() else - UI:OnSearchTextChanged(text) + UI:OnSearchTextChanged(text, true) end end, onGuide, onAddAlias) return @@ -4314,6 +5130,50 @@ function UI:CreateResultButton(index) return end + -- Setting click: keep the search panel open so the user can + -- adjust multiple settings, retest, retoggle, etc. without + -- having to reopen the search. + -- Checkbox: toggle inline (no need to open the panel at all). + -- Slider: the inline slider widget IS the editor; row click + -- is a no-op so dragging the slider doesn't also fire a + -- row click that opens the panel. + -- Dropdown / other: open the Settings panel for editing but + -- leave the search results visible underneath. + if self.data and self.data.settingType == "keybind" and self.data.bindingAction then + -- Keybind row: the inline kb1/kb2 buttons own the click and + -- manage their own keyboard capture (via blockFocus on the + -- editbox). A bare row click does nothing here; refocusing + -- the editbox would clear blockFocus mid-capture and hand + -- the next keystroke to the search bar instead of the bind. + return + end + if self.data and self.data.settingVariable then + local stype = self.data.settingType + if stype == "checkbox" then + UI:ToggleSettingCheckbox(self.data) + elseif stype == "slider" then + -- Slider widget handles its own drag/click. The row + -- still receives a "click" because the slider is a + -- child, so refocus the editbox to keep results open + -- (OnEditFocusLost would otherwise hide them). + if searchFrame and searchFrame.editBox + and not (navFrame and navFrame:IsKeyboardEnabled()) then + searchFrame.editBox.blockFocus = nil + searchFrame.editBox:SetFocus() + end + elseif stype == "dropdown" then + -- Cycle to the next dropdown value inline. Falls back to + -- opening the panel only if we can't enumerate options + -- (variable not found in any live category layout). + if not UI:CycleSettingDropdown(self.data) then + UI:OpenSettingNoClose(self.data) + end + else + UI:OpenSettingNoClose(self.data) + end + return + end + -- Pin header: toggle collapse if self.isPinHeader then EasyFind.db.pinsCollapsed = not EasyFind.db.pinsCollapsed @@ -4365,6 +5225,62 @@ function UI:CreateResultButton(index) -- Tooltip for unearned currencies, mounts, and toys resultRow:SetScript("OnEnter", function(self) + -- Hover-based action hint (mirrors keyboard selection hint). + ApplyActionHint(self) + -- Keybinding row: show the action name plus current bindings. + if self.data and self.data.settingType == "keybind" and self.data.bindingAction then + local action = self.data.bindingAction + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(self.data.name or action, 1, 1, 1) + local k1, k2 = GetBindingKey(action) + GameTooltip:AddLine(" ") + GameTooltip:AddLine(sformat("Primary: %s", k1 or "Not Bound"), 0.7, 0.7, 0.7) + GameTooltip:AddLine(sformat("Alternate: %s", k2 or "Not Bound"), 0.7, 0.7, 0.7) + GameTooltip:AddLine(" ") + GameTooltip:AddLine("Click a button to capture, right-click to clear.", + 0.5, 0.5, 0.5, true) + GameTooltip:Show() + return + end + -- Game Settings: show the setting's tooltip text plus current + -- value. Resolved via BlizzOptionsSearch's tooltip cache (live + -- SettingsPanel + OPTION_TOOLTIP_* globals). + if self.data and self.data.settingVariable then + local var = self.data.settingVariable + local tipText + if ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetTooltipForVariable then + tipText = ns.BlizzOptionsSearch.GetTooltipForVariable(var, self.data.name) + end + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(self.data.name or var, 1, 1, 1) + if tipText then + GameTooltip:AddLine(tipText, 1, 0.82, 0, true) + end + -- Slider: append current value + range + if self.data.settingType == "slider" and self.data.settingMin and self.data.settingMax then + local cur + if Settings and Settings.GetSetting then + local sok, settObj = pcall(Settings.GetSetting, var) + if sok and settObj and settObj.GetValue then + local vok, v = pcall(settObj.GetValue, settObj) + if vok then cur = v end + end + end + if cur == nil and GetCVar then cur = GetCVar(var) end + local n = tonumber(cur) + if n then + GameTooltip:AddLine(" ") + local valStr = (n == mfloor(n)) and tostring(mfloor(n)) or sformat("%.2f", n) + GameTooltip:AddLine(sformat("Current: %s (%s - %s)", + valStr, + tostring(self.data.settingMin), + tostring(self.data.settingMax)), 0.7, 0.7, 0.7) + end + end + GameTooltip:Show() + return + end + if self.isUnearnedCurrency then if unearnedTooltip then local tooltipText = self.isPathNode and "This tab does not exist on this character yet" or "Currency not yet earned" @@ -4400,18 +5316,17 @@ function UI:CreateResultButton(index) GameTooltip:SetToyByItemID(toyItemID) end end) - -- Pet tooltip + -- Pet tooltip (use BattlePetToolTip via the link, since GameTooltip + -- only renders battle pet links as raw escape codes) elseif self.icon.petID then - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - if C_PetJournal and C_PetJournal.GetPetInfoByPetID then - local _, speciesID = C_PetJournal.GetPetInfoByPetID(self.icon.petID) - if speciesID and BattlePetToolTip_ShowLink then - local link = C_PetJournal.GetBattlePetLink and C_PetJournal.GetBattlePetLink(self.icon.petID) - if link then - GameTooltip:SetText(link) - GameTooltip:Show() - end - end + local link = C_PetJournal and C_PetJournal.GetBattlePetLink + and C_PetJournal.GetBattlePetLink(self.icon.petID) + if link and BattlePetToolTip_ShowLink then + BattlePetToolTip_ShowLink(link) + elseif link then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetHyperlink(link) + GameTooltip:Show() end -- Outfit tooltip elseif self.icon.outfitID then @@ -4451,6 +5366,31 @@ function UI:CreateResultButton(index) GameTooltip:SetItemByID(self.icon.lootItemID) end GameTooltip:Show() + -- Heirloom tooltip + elseif self.icon.heirloomItemID then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetItemByID(self.icon.heirloomItemID) + GameTooltip:Show() + -- Ability tooltip (must come after mount, since mount entries + -- carry both mountID and spellID and use the mount tooltip). + elseif self.icon.spellID then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + if GameTooltip.SetSpellByID then + GameTooltip:SetSpellByID(self.icon.spellID) + else + GameTooltip:SetHyperlink("spell:" .. self.icon.spellID) + end + GameTooltip:Show() + -- Bag item tooltip + elseif self.icon.bagItemID then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + local link = self.data and self.data.bagItemLink + if link then + GameTooltip:SetHyperlink(link) + else + GameTooltip:SetItemByID(self.icon.bagItemID) + end + GameTooltip:Show() end end end) @@ -4463,14 +5403,26 @@ 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 - or (self.data.itemID and self.data.category == "Loot")) then + if GameTooltip:IsOwned(self) then GameTooltip:Hide() end + -- BattlePetTooltip is a separate frame; hide it on row leave so + -- the pet card doesn't linger after the cursor moves away. + if self.data and self.data.petID and BattlePetTooltip then + BattlePetTooltip:Hide() + end -- Clear map preview if we were showing one if self.data and self.data.mapSearchResult and ns.MapSearch and ns.MapSearch.ClearUIPreview then ns.MapSearch:ClearUIPreview() end + -- Restore hint to whatever the keyboard has selected (or clear). + if actionHintRow == self then + ClearActionHint() + local selRow = selectedIndex > 0 and resultButtons[selectedIndex] or nil + if selRow and selRow ~= self and not toggleFocused then + ApplyActionHint(selRow) + end + end end) resultRow:Hide() @@ -4513,15 +5465,19 @@ StaticPopupDialogs["EASYFIND_ADD_ALIAS"] = { hideOnEscape = true, enterClicksFirstButton = true, OnShow = function(self, data) - self.editBox:SetText("") - self.editBox:SetFocus() + local eb = self.editBox or self.EditBox + if eb then + eb:SetText("") + eb:SetFocus() + end end, OnAccept = function(self, data) - local txt = self.editBox:GetText() - if not txt or strtrim(txt) == "" then return end + local eb = self.editBox or self.EditBox + local txt = eb and eb:GetText() or "" + if strtrim(txt) == "" then return end if ns.Aliases and ns.Aliases:Add(txt, data) then - local editBox = searchFrame and searchFrame.editBox - local current = editBox and editBox:GetText() or "" + local searchEditBox = searchFrame and searchFrame.editBox + local current = searchEditBox and searchEditBox:GetText() or "" if current ~= "" then UI:OnSearchTextChanged(current) end end end, @@ -4578,9 +5534,24 @@ function UI:NavigateSearchHistory(direction) return true end -function UI:OnSearchTextChanged(text) +function UI:OnSearchTextChanged(text, force) -- Suppress re-renders while SelectResult is clearing text/focus if selectingResult then return end + -- A pending OnTextChanged timer can fire after focus has shifted + -- away from the editbox (user clicked outside, OR clicked an + -- inline child widget like a slider that StripAutocomplete then + -- triggers a SetText on via the focus-loss hook). Just bail — + -- don't re-render, but also don't hide. The outside-click paths + -- (GLOBAL_MOUSE_DOWN, OnEditFocusLost) decide whether to actually + -- hide based on cursor position. Calling HideResults here also + -- tore down the panel during slider drags, which is exactly what + -- we want to avoid. + -- `force` lets internal callers (pin/unpin from the right-click + -- menu) re-render after the pin popup briefly stole focus. + if not force and searchFrame and searchFrame.editBox + and not searchFrame.editBox:HasFocus() then + return + end -- Treat whitespace-only as empty (pins show on focus, not on blank spaces) if text then text = strtrim(text) end if not text or text == "" then @@ -4598,36 +5569,58 @@ function UI:OnSearchTextChanged(text) collapsedNodes = {} expandedContainers = {} + if ns.Database.ResetHierEntryPool then + ns.Database:ResetHierEntryPool() + end + -- Build skip set from filters so SearchUI avoids scoring/copying filtered categories. -- Collection items (mounts/toys/pets/outfits/appearance sets) are -- skipped when their own filter is off OR the parent Collections -- toggle is off. Loot is independent. local filters = EasyFind.db.uiSearchFilters local collectionsOff = filters and filters.collections == false + local optionsOff = filters and filters.options == false local skipCategories if filters then local mountsOff = collectionsOff or filters.mounts == false local toysOff = collectionsOff or filters.toys == false local petsOff = collectionsOff or filters.pets == false local outfitsOff = collectionsOff or filters.outfits == false + local heirloomsOff = collectionsOff or filters.heirlooms == false local appsetsOff = collectionsOff or filters.appearanceSets == false local lootOff = filters.loot == false local bagsOff = filters.bags == false - local optionsOff = filters.options == false + local macrosOff = filters.macros == false + local gameOptOff = optionsOff or filters.gameOptions == false + local addonOptOff = optionsOff or filters.addonOptions == false + local abilitiesOff = filters.abilities == false + local bossesOff = filters.bosses == false + local titlesOff = filters.titles == false + local gearSetsOff = collectionsOff or filters.gearSets == false if mountsOff or toysOff or petsOff or outfitsOff or lootOff - or appsetsOff or bagsOff or optionsOff then + or appsetsOff or bagsOff or macrosOff or gameOptOff or addonOptOff + or abilitiesOff or bossesOff or heirloomsOff or titlesOff or gearSetsOff then skipCategories = {} - if mountsOff then skipCategories["Mount"] = true end - if toysOff then skipCategories["Toy"] = true end - if petsOff then skipCategories["Pet"] = true end - if outfitsOff then skipCategories["Outfit"] = true end - if lootOff then skipCategories["Loot"] = true end - if appsetsOff then skipCategories["Appearance Set"] = true end - if bagsOff then skipCategories["Bag"] = true end - if optionsOff then skipCategories["Game Settings"] = true end - end - end + if mountsOff then skipCategories["Mount"] = true end + if toysOff then skipCategories["Toy"] = true end + if petsOff then skipCategories["Pet"] = true end + if outfitsOff then skipCategories["Outfit"] = true end + if heirloomsOff then skipCategories["Heirloom"] = true end + if lootOff then skipCategories["Loot"] = true end + if appsetsOff then skipCategories["Appearance Set"] = true end + if bagsOff then skipCategories["Bag"] = true end + if macrosOff then skipCategories["Macro"] = true end + if gameOptOff then skipCategories["Game Settings"] = true end + if addonOptOff then skipCategories["AddOn Settings"] = true end + if abilitiesOff then skipCategories["Ability"] = true end + if bossesOff then skipCategories["Boss"] = true end + if titlesOff then skipCategories["Title"] = true end + if gearSetsOff then skipCategories["Gear Set"] = true end + end + end + local _perfT0 = ns.PERF and debugprofilestop() or 0 local results = ns.Database:SearchUI(text, skipCategories) + local _perfTSearch = ns.PERF and debugprofilestop() or 0 -- Inject user-defined alias hits at the front. Aliases bypass -- bucket filters so a saved shortcut is always reachable, even if @@ -4649,22 +5642,31 @@ function UI:OnSearchTextChanged(text) end -- Bucket-aware UI filter: drop UI entries whose bucket - -- (ui / achievements / currencies / reputations / bags / options) is unchecked. - -- Skip the loop entirely if every bucket is enabled. - if filters and (filters.ui == false or filters.achievements == false + -- (ui / abilities / achievements / currencies / reputations / bags + -- / options) is unchecked. Options is a parent toggle: when off, + -- both gameOptions and addonOptions buckets are treated as off. + if filters and (filters.ui == false or filters.abilities == false + or filters.bosses == false + or filters.achievements == false or filters.currencies == false or filters.reputations == false - or filters.bags == false or filters.options == false) then + or filters.bags == false or filters.macros == false + or filters.options == false + or filters.gameOptions == false or filters.addonOptions == false) then local filtered = {} - for _, r in ipairs(results) do - -- Aliases override bucket filters: the user explicitly - -- saved this shortcut, so respecting the filter would - -- silently hide it. + local fi = 0 + for ri = 1, #results do + local r = results[ri] if r.isAlias then - filtered[#filtered + 1] = r + fi = fi + 1 + filtered[fi] = r else local bucket = GetUIBucket(r.data) - if not bucket or filters[bucket] ~= false then - filtered[#filtered + 1] = r + local bucketOff = bucket and filters[bucket] == false + local parentOff = optionsOff + and (bucket == "gameOptions" or bucket == "addonOptions") + if not bucket or (not bucketOff and not parentOff) then + fi = fi + 1 + filtered[fi] = r end end end @@ -4677,9 +5679,10 @@ 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 + wipe(SCRATCH.bestCatScore) + local bestCatScore = SCRATCH.bestCatScore + for ri = 1, #results do + local r = results[ri] local d = r.data local s = r.score or 0 local cat @@ -4687,6 +5690,7 @@ function UI:OnSearchTextChanged(text) elseif d.toyItemID then cat = "toys" elseif d.petID then cat = "pets" elseif d.outfitID then cat = "outfits" + elseif d.heirloomItemID then cat = "heirlooms" elseif d.itemID and d.category == "Loot" then cat = "loot" elseif d.transmogSetID then cat = "appearanceSets" else cat = GetUIBucket(d) or "ui" @@ -4694,8 +5698,8 @@ function UI:OnSearchTextChanged(text) 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 + for ri = 1, #mapResults do + local s = mapResults[ri].score or 0 if s > (bestCatScore.map or 0) then bestCatScore.map = s end end end @@ -4710,132 +5714,38 @@ function UI:OnSearchTextChanged(text) local hideHeaders = EasyFind.db.uiHideHeaders local hierarchical if hideHeaders then - -- Flat-list mode: skip path-node hierarchy and section headers, but - -- keep category clustering so mounts/toys/UI/etc. stay grouped. - -- Reuses module-level scratch tables and a recyclable entry pool to - -- keep per-keystroke allocations down. - wipe(groupUI); wipe(groupMounts); wipe(groupToys); wipe(groupPets) - wipe(groupOutfits); wipe(groupLoot); wipe(groupAppearanceSets); wipe(groupMap) - wipe(groupAchievements); wipe(groupCurrencies); wipe(groupReputations) - wipe(groupBags); wipe(groupOptions) - - for _, r in ipairs(results) do - local d = r.data - if d.mountID then - groupMounts[#groupMounts + 1] = r - elseif d.toyItemID then - groupToys[#groupToys + 1] = r - elseif d.petID then - groupPets[#groupPets + 1] = r - elseif d.outfitID then - groupOutfits[#groupOutfits + 1] = r - elseif d.itemID and d.category == "Loot" then - groupLoot[#groupLoot + 1] = r - elseif d.transmogSetID then - groupAppearanceSets[#groupAppearanceSets + 1] = r - else - local bucket = UI_BUCKET_BY_CATEGORY[d.category] - if bucket == "achievements" then - groupAchievements[#groupAchievements + 1] = r - elseif bucket == "currencies" then - groupCurrencies[#groupCurrencies + 1] = r - elseif bucket == "reputations" then - groupReputations[#groupReputations + 1] = r - elseif bucket == "bags" then - groupBags[#groupBags + 1] = r - elseif bucket == "options" then - groupOptions[#groupOptions + 1] = r - else - groupUI[#groupUI + 1] = r - end - end - end + -- Flat-list mode: single score-sorted list. UI results, map + -- results, collections, settings — everything gets ranked + -- together purely on score. Category clustering (used to + -- live here) hid genuinely better matches behind weaker ones + -- in higher-priority buckets, e.g. a name-prefix UI hit + -- lost to a same-bucket map result that only matched via + -- initials, because the map bucket sorted alphabetically + -- by zone before yielding to the next bucket. + wipe(flatCombined) + local combined = flatCombined + for ri = 1, #results do combined[#combined + 1] = results[ri] end if mapResults then - for _, r in ipairs(mapResults) do - groupMap[#groupMap + 1] = r - end - end - - -- Within-group ordering: UI sorts by path-then-name so nested - -- siblings stay adjacent; collection groups sort alphabetically - -- since their items have no shared hierarchy; map sorts by zone. - if #groupUI > 1 then tsort(groupUI, FlatPathLess) end - if #groupAchievements > 1 then tsort(groupAchievements, FlatPathLess) end - if #groupCurrencies > 1 then tsort(groupCurrencies, FlatPathLess) end - if #groupReputations > 1 then tsort(groupReputations, FlatPathLess) end - if #groupBags > 1 then tsort(groupBags, FlatNameLess) end - if #groupOptions > 1 then tsort(groupOptions, FlatPathLess) end - if #groupMounts > 1 then tsort(groupMounts, FlatNameLess) end - if #groupToys > 1 then tsort(groupToys, FlatNameLess) end - if #groupPets > 1 then tsort(groupPets, FlatNameLess) end - if #groupOutfits > 1 then tsort(groupOutfits, FlatNameLess) end - if #groupLoot > 1 then tsort(groupLoot, FlatNameLess) end - if #groupAppearanceSets > 1 then tsort(groupAppearanceSets, FlatNameLess) end - if #groupMap > 1 then tsort(groupMap, FlatMapLess) end - - wipe(flatCatGroups) - if #groupUI > 0 then - flatCatGroups[#flatCatGroups + 1] = { key = "ui", score = bestCatScore.ui or 0, group = groupUI } - end - if #groupAchievements > 0 then - flatCatGroups[#flatCatGroups + 1] = { key = "achievements", score = bestCatScore.achievements or 0, group = groupAchievements } + for ri = 1, #mapResults do combined[#combined + 1] = mapResults[ri] end end - if #groupCurrencies > 0 then - flatCatGroups[#flatCatGroups + 1] = { key = "currencies", score = bestCatScore.currencies or 0, group = groupCurrencies } - end - if #groupReputations > 0 then - flatCatGroups[#flatCatGroups + 1] = { key = "reputations", score = bestCatScore.reputations or 0, group = groupReputations } - end - if #groupBags > 0 then - flatCatGroups[#flatCatGroups + 1] = { key = "bags", score = bestCatScore.bags or 0, group = groupBags } - end - if #groupOptions > 0 then - flatCatGroups[#flatCatGroups + 1] = { key = "options", score = bestCatScore.options or 0, group = groupOptions } - end - if #groupMounts > 0 then - flatCatGroups[#flatCatGroups + 1] = { key = "mounts", score = bestCatScore.mounts or 0, group = groupMounts } - end - if #groupToys > 0 then - flatCatGroups[#flatCatGroups + 1] = { key = "toys", score = bestCatScore.toys or 0, group = groupToys } - end - if #groupPets > 0 then - flatCatGroups[#flatCatGroups + 1] = { key = "pets", score = bestCatScore.pets or 0, group = groupPets } - end - if #groupOutfits > 0 then - flatCatGroups[#flatCatGroups + 1] = { key = "outfits", score = bestCatScore.outfits or 0, group = groupOutfits } - end - if #groupLoot > 0 then - flatCatGroups[#flatCatGroups + 1] = { key = "loot", score = bestCatScore.loot or 0, group = groupLoot } - end - if #groupAppearanceSets > 0 then - flatCatGroups[#flatCatGroups + 1] = { key = "appearanceSets", score = bestCatScore.appearanceSets or 0, group = groupAppearanceSets } - end - if #groupMap > 0 then - flatCatGroups[#flatCatGroups + 1] = { key = "map", score = bestCatScore.map or 0, group = groupMap } - end - tsort(flatCatGroups, CatGroupScoreDescending) + if #combined > 1 then tsort(combined, FlatNameLess) end local n = 0 - for ci = 1, #flatCatGroups do - local cat = flatCatGroups[ci] - local key = cat.key - local g = cat.group - for ri = 1, #g do - n = n + 1 - local e = flatEntries[n] - if not e then - e = {} - flatEntries[n] = e - end - local d = g[ri].data - e.name = d.name - e.depth = 0 - e.isPathNode = false - e.isMatch = true - e.isFlat = true - e.flatCatKey = key - e.data = d - end + for ri = 1, #combined do + n = n + 1 + local e = flatEntries[n] + if not e then + e = {} + flatEntries[n] = e + end + local d = combined[ri].data + e.name = d.name + e.depth = 0 + e.isPathNode = false + e.isMatch = true + e.isFlat = true + e.flatCatKey = nil + e.data = d end for i = n + 1, #flatEntries do flatEntries[i] = nil @@ -4843,45 +5753,42 @@ function UI:OnSearchTextChanged(text) hierarchical = flatEntries else - -- Pre-bucket results by category so each top-level group (UI elements, - -- achievements, currencies, reputations, plus collection sub-groups) - -- gets its own hierarchy. Splitting BEFORE BuildHierarchicalResults - -- keeps the path-node tree of, say, achievements separate from UI - -- elements, so the section headers actually correspond to disjoint - -- subtrees. Path nodes inherit their bucket from leaf entries - -- because each bucket is built from a leaf-only result list. - local prebucket_ui, prebucket_ach, prebucket_cur, prebucket_rep = {}, {}, {}, {} - local prebucket_mounts, prebucket_toys, prebucket_pets = {}, {}, {} - local prebucket_outfits, prebucket_loot, prebucket_appsets = {}, {}, {} - local prebucket_bags, prebucket_options = {}, {} - for _, r in ipairs(results) do + local pb = PB + wipe(pb.ui); wipe(pb.ach); wipe(pb.cur); wipe(pb.rep) + wipe(pb.mounts); wipe(pb.toys); wipe(pb.pets) + wipe(pb.outfits); wipe(pb.loot); wipe(pb.appsets) + wipe(pb.bags); wipe(pb.options); wipe(pb.heirlooms) + for ri = 1, #results do + local r = results[ri] local d = r.data if d.mountID then - prebucket_mounts[#prebucket_mounts + 1] = r + pb.mounts[#pb.mounts + 1] = r elseif d.toyItemID then - prebucket_toys[#prebucket_toys + 1] = r + pb.toys[#pb.toys + 1] = r elseif d.petID then - prebucket_pets[#prebucket_pets + 1] = r + pb.pets[#pb.pets + 1] = r elseif d.outfitID then - prebucket_outfits[#prebucket_outfits + 1] = r + pb.outfits[#pb.outfits + 1] = r + elseif d.heirloomItemID then + pb.heirlooms[#pb.heirlooms + 1] = r elseif d.itemID and d.category == "Loot" then - prebucket_loot[#prebucket_loot + 1] = r + pb.loot[#pb.loot + 1] = r elseif d.transmogSetID then - prebucket_appsets[#prebucket_appsets + 1] = r + pb.appsets[#pb.appsets + 1] = r else local bucket = UI_BUCKET_BY_CATEGORY[d.category] if bucket == "achievements" then - prebucket_ach[#prebucket_ach + 1] = r + pb.ach[#pb.ach + 1] = r elseif bucket == "currencies" then - prebucket_cur[#prebucket_cur + 1] = r + pb.cur[#pb.cur + 1] = r elseif bucket == "reputations" then - prebucket_rep[#prebucket_rep + 1] = r + pb.rep[#pb.rep + 1] = r elseif bucket == "bags" then - prebucket_bags[#prebucket_bags + 1] = r + pb.bags[#pb.bags + 1] = r elseif bucket == "options" then - prebucket_options[#prebucket_options + 1] = r + pb.options[#pb.options + 1] = r else - prebucket_ui[#prebucket_ui + 1] = r + pb.ui[#pb.ui + 1] = r end end end @@ -4890,34 +5797,21 @@ function UI:OnSearchTextChanged(text) wipe(groupUI); wipe(groupAchievements); wipe(groupCurrencies); wipe(groupReputations) wipe(groupMounts); wipe(groupToys); wipe(groupPets) wipe(groupOutfits); wipe(groupLoot); wipe(groupAppearanceSets); wipe(groupMap) - wipe(groupBags); wipe(groupOptions) - - local function buildInto(group, bucketResults) - if #bucketResults == 0 then return end - local hier = ns.Database:BuildHierarchicalResults(bucketResults) - for _, entry in ipairs(hier) do - -- Container nodes (path-node ancestors with extra non-matching - -- children) start collapsed; the user opens them to browse. - if entry.isContainer then - local key = entry.name .. "_" .. (entry.depth or 0) - collapsedNodes[key] = true - end - group[#group + 1] = entry - end - end - - buildInto(groupUI, prebucket_ui) - buildInto(groupAchievements, prebucket_ach) - buildInto(groupCurrencies, prebucket_cur) - buildInto(groupReputations, prebucket_rep) - buildInto(groupBags, prebucket_bags) - buildInto(groupOptions, prebucket_options) - buildInto(groupMounts, prebucket_mounts) - buildInto(groupToys, prebucket_toys) - buildInto(groupPets, prebucket_pets) - buildInto(groupOutfits, prebucket_outfits) - buildInto(groupLoot, prebucket_loot) - buildInto(groupAppearanceSets, prebucket_appsets) + wipe(groupBags); wipe(groupOptions); wipe(groupHeirlooms) + + BuildBucketInto(groupUI, pb.ui) + BuildBucketInto(groupAchievements, pb.ach) + BuildBucketInto(groupCurrencies, pb.cur) + BuildBucketInto(groupReputations, pb.rep) + BuildBucketInto(groupBags, pb.bags) + BuildBucketInto(groupOptions, pb.options) + BuildBucketInto(groupMounts, pb.mounts) + BuildBucketInto(groupToys, pb.toys) + BuildBucketInto(groupPets, pb.pets) + BuildBucketInto(groupOutfits, pb.outfits) + BuildBucketInto(groupHeirlooms, pb.heirlooms) + BuildBucketInto(groupLoot, pb.loot) + BuildBucketInto(groupAppearanceSets, pb.appsets) -- Keep `hierarchical` defined for the rest of the function; -- it gets rebuilt below by appending each section in catGroups order. hierarchical = nil @@ -4997,24 +5891,24 @@ 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 #groupAchievements > 0 then catGroups[#catGroups + 1] = { key = "achievements", score = bestCatScore.achievements or 0 } end - if #groupCurrencies > 0 then catGroups[#catGroups + 1] = { key = "currencies", score = bestCatScore.currencies or 0 } end - if #groupReputations > 0 then catGroups[#catGroups + 1] = { key = "reputations", score = bestCatScore.reputations or 0 } end - if #groupBags > 0 then catGroups[#catGroups + 1] = { key = "bags", score = bestCatScore.bags or 0 } end - if #groupOptions > 0 then catGroups[#catGroups + 1] = { key = "options", score = bestCatScore.options 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 #groupAppearanceSets > 0 then catGroups[#catGroups + 1] = { key = "appearanceSets", score = bestCatScore.appearanceSets 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) + wipe(SCRATCH.catGroups) + local catGroups = SCRATCH.catGroups + local n = 0 + if #groupUI > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "ui"; g.score = bestCatScore.ui or 0 end + if #groupAchievements > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "achievements"; g.score = bestCatScore.achievements or 0 end + if #groupCurrencies > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "currencies"; g.score = bestCatScore.currencies or 0 end + if #groupReputations > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "reputations"; g.score = bestCatScore.reputations or 0 end + if #groupBags > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "bags"; g.score = bestCatScore.bags or 0 end + if #groupOptions > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "options"; g.score = bestCatScore.options or 0 end + if #groupMounts > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "mounts"; g.score = bestCatScore.mounts or 0 end + if #groupToys > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "toys"; g.score = bestCatScore.toys or 0 end + if #groupPets > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "pets"; g.score = bestCatScore.pets or 0 end + if #groupOutfits > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "outfits"; g.score = bestCatScore.outfits or 0 end + if #groupHeirlooms > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "heirlooms"; g.score = bestCatScore.heirlooms or 0 end + if #groupLoot > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "loot"; g.score = bestCatScore.loot or 0 end + if #groupAppearanceSets > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "appearanceSets"; g.score = bestCatScore.appearanceSets or 0 end + if #groupMap > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "map"; g.score = bestCatScore.map or 0 end + tsort(catGroups, CatGroupCompare) hierarchical = {} for _, cat in ipairs(catGroups) do @@ -5050,6 +5944,9 @@ function UI:OnSearchTextChanged(text) 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 == "heirlooms" then + hierarchical[#hierarchical + 1] = heirloomSectionHeader + for _, e in ipairs(groupHeirlooms) do e.depth = 1; hierarchical[#hierarchical + 1] = e end elseif cat.key == "loot" then hierarchical[#hierarchical + 1] = lootSectionHeader local slotGroups = {} @@ -5134,7 +6031,19 @@ function UI:OnSearchTextChanged(text) hierarchical = pinnedEntries end + local _perfTBuild = ns.PERF and debugprofilestop() or 0 self:ShowHierarchicalResults(hierarchical) + if ns.PERF then + local now = debugprofilestop() + EasyFind:Print(string.format( + "perf: q=%q search=%.2fms build=%.2fms render=%.2fms total=%.2fms rows=%d", + text or "", + _perfTSearch - _perfT0, + _perfTBuild - _perfTSearch, + now - _perfTBuild, + now - _perfT0, + #hierarchical)) + end end -- Helper function to get icon from a button frame @@ -5191,6 +6100,59 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- Cache the FULL (unfiltered) list so collapse toggles can re-render cachedHierarchical = hierarchical + -- Render-skip: if the input list is identical (same length, same + -- data refs and same depth at every index) AND the relevant view + -- state (theme, collapse state, hide-headers, results-above) hasn't + -- changed since the last render, the visible output would be byte- + -- for-byte identical. Skip the entire per-row layout pass — this is + -- the typical case during typing once the top results stabilize. + do + -- collapsedNodes is wiped to a fresh empty table on every + -- search, so identity comparison would always miss during + -- typing. Snapshot a single key (or nil if empty) — a click on + -- a collapse toggle adds or removes a key, which we'll see. + local theme = EasyFind.db.resultsTheme + local hideHeaders = EasyFind.db.uiHideHeaders + local above = EasyFind.db.uiResultsAbove + local collapsedKey = next(collapsedNodes) + local n = #hierarchical + local last = self._lastRenderSig + local same = last and last.n == n + and last.theme == theme + and last.hideHeaders == hideHeaders + and last.above == above + and last.collapsedKey == collapsedKey + and last.pinsCollapsed == EasyFind.db.pinsCollapsed + and resultsFrame:IsShown() + if same then + for hi = 1, n do + local e = hierarchical[hi] + if last[hi * 2 - 1] ~= e.data or last[hi * 2] ~= (e.depth or 0) then + same = false + break + end + end + end + if same then + self._renderSkips = (self._renderSkips or 0) + 1 + return + end + self._renderRuns = (self._renderRuns or 0) + 1 + if not last then last = {}; self._lastRenderSig = last end + last.n = n + last.theme = theme + last.hideHeaders = hideHeaders + last.above = above + last.collapsedKey = collapsedKey + last.pinsCollapsed = EasyFind.db.pinsCollapsed + for hi = 1, n do + local e = hierarchical[hi] + last[hi * 2 - 1] = e.data + last[hi * 2] = e.depth or 0 + end + for i = n * 2 + 1, #last do last[i] = nil end + end + local theme = GetActiveTheme() local rowH = theme.rowHeight local indPx = theme.indentPx @@ -5246,15 +6208,16 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultsFrame:SetClipsChildren(false) end - -- Build the visible list by filtering out children of collapsed nodes - local visible = {} - local skipBelowDepth = nil -- when set, skip entries deeper than this - local skipPins = false -- when pin header is collapsed, skip pinned entries + wipe(SCRATCH.visible) + local visible = SCRATCH.visible + local visibleN = 0 + local skipBelowDepth = nil + local skipPins = false - for _, entry in ipairs(hierarchical) do + for hi = 1, #hierarchical do + local entry = hierarchical[hi] local d = entry.depth or 0 - -- If we're skipping children of a collapsed node, check depth if skipBelowDepth then if d <= skipBelowDepth then skipBelowDepth = nil @@ -5263,16 +6226,15 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if not (skipPins and entry.isPinned) and not skipBelowDepth then if skipPins and not entry.isPinned then - skipPins = false -- past the pin section + skipPins = false end - tinsert(visible, entry) + visibleN = visibleN + 1 + visible[visibleN] = entry - -- Pin header: check pinsCollapsed instead of collapsedNodes if entry.isPinHeader then if EasyFind.db.pinsCollapsed then skipPins = true end - -- Regular collapsed path node elseif entry.isPathNode then local key = entry.name .. "_" .. d if collapsedNodes[key] then @@ -5284,26 +6246,24 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- Count pin-related visible entries (header + pinned items) local pinSlots = 0 - for _, entry in ipairs(visible) do + for vi = 1, visibleN do + local entry = visible[vi] if entry.isPinHeader or entry.isPinned then pinSlots = pinSlots + 1 end end - -- Show all results (scroll handles overflow) - local count = mmin(#visible, MAX_BUTTON_POOL) + local count = mmin(visibleN, MAX_BUTTON_POOL) - -- Pre-compute whether scrolling will be needed so buttons can be narrower. - -- Rough estimate: if all rows at base height exceed the limit, a scrollbar likely appears. local maxVisibleHeight = EasyFind.db.uiResultsHeight or 280 - local willScroll = #visible * rowH > maxVisibleHeight + local willScroll = visibleN * rowH > maxVisibleHeight local scrollInset = 0 if willScroll and resultsFrame.scrollBar then scrollInset = resultsFrame.scrollBar:GetWidth() end - -- Pre-compute last-child flags on the VISIBLE list - local isLastChild = {} + wipe(SCRATCH.isLastChild) + local isLastChild = SCRATCH.isLastChild for i = 1, count do local d = visible[i].depth or 0 if d > 0 then @@ -5337,10 +6297,10 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- populate it. local catSepBeforeIndex = {} - -- Render visible rows local yOffset = 0 local pinEndYOffset = 0 - local catSepYPositions = {} + wipe(SCRATCH.catSepYPositions) + local catSepYPositions = SCRATCH.catSepYPositions local hasSideBySideRepBar = false for i = 1, MAX_BUTTON_POOL do local resultRow = resultButtons[i] @@ -5391,47 +6351,42 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end resultRow.data = data - -- Set secure action attributes for toys, mounts, outfits, and macros + -- Secure action attributes. Cache the (type, value) we last + -- applied to this row so we only re-issue SetAttribute when + -- the row's data actually changed. SetAttribute on a secure + -- button is the single most expensive thing we do per row, + -- and incremental narrowing keeps the same row.data on most + -- rows from one keystroke to the next, so most renders end + -- up no-ops here. if not InCombatLockdown() then + local newType, newKey, newVal if data and data.toyItemID then - resultRow:SetAttribute("type", "toy") - resultRow:SetAttribute("toy", data.toyItemID) - resultRow:SetAttribute("macrotext", nil) - resultRow:SetAttribute("macro", nil) - resultRow:SetAttribute("action", nil) - resultRow:SetAttribute("spell", nil) + newType, newKey, newVal = "toy", "toy", data.toyItemID elseif data and data.mountID then - resultRow:SetAttribute("type", "macro") - resultRow:SetAttribute("macrotext", "/cancelform [form]") - resultRow:SetAttribute("toy", nil) - resultRow:SetAttribute("macro", nil) - resultRow:SetAttribute("action", nil) - resultRow:SetAttribute("spell", nil) + newType, newKey, newVal = "macro", "macrotext", "/cancelform [form]" elseif data and data.outfitID then - -- 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("macro", nil) - resultRow:SetAttribute("macrotext", nil) - resultRow:SetAttribute("spell", nil) + newType, newKey, newVal = "action", "action", 0 elseif data and data.spellID then - -- Ability: cast via SecureActionButton. Spell name is - -- preferred (more stable across talent swaps) but ID - -- works as a fallback. - resultRow:SetAttribute("type", "spell") - resultRow:SetAttribute("spell", data.spellName or data.spellID) - resultRow:SetAttribute("toy", nil) - resultRow:SetAttribute("macro", nil) - resultRow:SetAttribute("macrotext", nil) - resultRow:SetAttribute("action", nil) - else - resultRow:SetAttribute("type", nil) - resultRow:SetAttribute("toy", nil) - resultRow:SetAttribute("macro", nil) - resultRow:SetAttribute("macrotext", nil) - resultRow:SetAttribute("action", nil) - resultRow:SetAttribute("spell", nil) + newType, newKey, newVal = "spell", "spell", data.spellName or data.spellID + elseif data and data.itemID and data.category == "Bag" then + newType, newKey, newVal = "item", "item", data.name + end + if resultRow._lastAttrType ~= newType + or resultRow._lastAttrKey ~= newKey + or resultRow._lastAttrVal ~= newVal then + -- Strip the previously-set value (if any) before + -- applying the new one so stale attributes from the + -- prior data don't leak through. + if resultRow._lastAttrKey then + resultRow:SetAttribute(resultRow._lastAttrKey, nil) + end + resultRow:SetAttribute("type", newType) + if newKey then + resultRow:SetAttribute(newKey, newVal) + end + resultRow._lastAttrType = newType + resultRow._lastAttrKey = newKey + resultRow._lastAttrVal = newVal end end resultRow.isPathNode = entry.isPathNode @@ -5674,8 +6629,19 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local sz = entryRowH - 14 if catIconDef.atlas then resultRow.flatCatIcon:SetAtlas(catIconDef.atlas) + resultRow.flatCatIcon:SetTexCoord(0, 1, 0, 1) else resultRow.flatCatIcon:SetTexture(catIconDef.tex) + if catIconDef.coords then + resultRow.flatCatIcon:SetTexCoord(unpack(catIconDef.coords)) + else + resultRow.flatCatIcon:SetTexCoord(0, 1, 0, 1) + end + end + if catIconDef.color then + resultRow.flatCatIcon:SetVertexColor(unpack(catIconDef.color)) + else + resultRow.flatCatIcon:SetVertexColor(1, 1, 1, 1) end resultRow.flatCatIcon:SetSize(sz, sz) resultRow.flatCatIcon:ClearAllPoints() @@ -5746,6 +6712,11 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- Set icon local iconSet = false + -- Clear leftover cooldown sweep from prior render. Only the + -- toy/ability/outfit branch re-enables it; every other branch + -- (map results, currencies, settings, etc.) leaves it alone, so + -- without this clear a recycled row keeps the previous sweep. + if resultRow.iconCooldown then resultRow.iconCooldown:Hide() end local isCurrencyItem = data and data.category == "Currency" local isCurrencyLeaf = isCurrencyItem and not entry.isPathNode local isReputationLeaf = data and data.category == "Reputation" and not entry.isPathNode @@ -5850,6 +6821,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) else resultRow.flatCatIcon:SetTexture(catIconDef.tex) end + resultRow.flatCatIcon:SetVertexColor(1, 1, 1, 1) resultRow.flatCatIcon:SetSize(sz, sz) resultRow.flatCatIcon:ClearAllPoints() resultRow.flatCatIcon:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) @@ -5867,7 +6839,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 or data.outfitID or data.transmogSetID) then + elseif not iconSet and data and (data.mountID or data.toyItemID or data.petID or data.outfitID or data.heirloomItemID or data.transmogSetID or (data.spellID and data.category == "Ability") or (data.encounterID and data.category == "Boss") or (data.macroIndex and data.category == "Macro") or (data.bagID and data.category == "Bag")) then local iconFileID = data.icon local rightOffset = -5 @@ -5884,6 +6856,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.icon.petID = data.petID resultRow.icon.spellID = data.spellID resultRow.icon.outfitID = data.outfitID + resultRow.icon.heirloomItemID = data.heirloomItemID + resultRow.icon.bagItemID = (data.category == "Bag") and data.itemID or nil resultRow.icon.lootItemID = nil -- Red tint on mount icons when in combat (can't mount) if data.mountID and InCombatLockdown() then @@ -5912,7 +6886,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow._lockOverlay:Hide() end - -- Cooldown sweep overlay (toys and outfits) + -- Cooldown sweep overlay (toys, abilities, outfits) resultRow.amountText:Hide() if data.toyItemID and iconFileID and GetItemCooldown then local startTime, duration = GetItemCooldown(data.toyItemID) @@ -5923,6 +6897,15 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) else resultRow.iconCooldown:Hide() end + elseif data.spellID and data.category == "Ability" and iconFileID and C_Spell and C_Spell.GetSpellCooldown then + local cd = C_Spell.GetSpellCooldown(data.spellID) + if cd and cd.startTime and cd.duration and cd.duration > 0 then + resultRow.iconCooldown:SetAllPoints(resultRow.icon) + resultRow.iconCooldown:SetCooldown(cd.startTime, cd.duration) + resultRow.iconCooldown:Show() + else + resultRow.iconCooldown:Hide() + end elseif data.outfitID and outfitCdStart > 0 then local remaining = outfitCdDuration - (GetTime() - outfitCdStart) if remaining > 0 then @@ -5975,7 +6958,10 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.text:SetPoint("RIGHT", resultRow.icon, "LEFT", -4, 0) iconSet = true - -- Map search results: left-side category icon + nav pin on right + -- Map search results: per-POI icon (flightmaster, bank, dungeon + -- entrance, etc.) on the RIGHT. The LEFT generic-map glyph is + -- already handled by the flat-mode block above via GetFlatCategoryIcon + -- (which returns FLAT_CATEGORY_ICONS.map for mapSearchResult rows). elseif not iconSet and data and data.mapSearchResult then resultRow.amountText:Hide() local mapIcon = data.icon @@ -5994,17 +6980,18 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end resultRow.icon:SetSize(rowIconSize, rowIconSize) resultRow.icon:ClearAllPoints() - local indentPixels = depth * indPx + 4 - resultRow.icon:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) + resultRow.icon:SetPoint("RIGHT", resultRow, "RIGHT", -5, 0) resultRow.icon:Show() - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("LEFT", resultRow.icon, "RIGHT", 4, 0) else - local indentPixels = depth * indPx + 4 - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) + SetRowIcon(resultRow, "hidden", nil, rowIconSize) + end + resultRow.text:ClearAllPoints() + resultRow.text:SetPoint("LEFT", resultRow, "LEFT", depth * indPx + 4, 0) + if mapIcon then + resultRow.text:SetPoint("RIGHT", resultRow.icon, "LEFT", -4, 0) + else + resultRow.text:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) end - resultRow.text:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) iconSet = true -- Reputation leaves: faction-side crest on the LEFT, rep bar on @@ -6178,6 +7165,18 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.repBar:Hide() end + -- Game Settings: cogwheel atlas in non-flat mode (flat mode + -- uses flatCatIcon via GetFlatCategoryIcon). + if not iconSet and data and data.category == "Game Settings" then + SetRowIcon(resultRow, "atlas", "QuestLog-icon-setting", rowIconSize) + iconSet = true + end + + if not iconSet and data and data.iconAtlas then + SetRowIcon(resultRow, "atlas", data.iconAtlas, rowIconSize) + iconSet = true + end + if not iconSet and data and data.icon then SetRowIcon(resultRow, "file", data.icon, rowIconSize) iconSet = true @@ -6255,6 +7254,148 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) SetRowIcon(resultRow, "file", 134400, rowIconSize) end + -- Setting state visualization. Checkbox: check/uncheck atlas + -- (toggle inline via PostClick). Slider: actual draggable + -- slider widget with value label. Dropdown/other: current + -- value as muted text (click opens panel to edit). + local isKeybindEntry = data and data.settingType == "keybind" and data.bindingAction + if isKeybindEntry and not entry.isPathNode then + -- Keybinding row: two inline buttons showing current + -- primary/alternate keys. Refresh function lets the + -- buttons re-read GetBindingKey after a capture/clear. + local action = data.bindingAction + local kb1 = resultRow.settingKeybind1 + local kb2 = resultRow.settingKeybind2 + local function refresh() + local k1, k2 = GetBindingKey(action) + kb1:SetText(k1 or "Not Bound") + kb2:SetText(k2 or "Not Bound") + end + kb1._bindingAction = action + kb1._refresh = refresh + kb2._bindingAction = action + kb2._refresh = refresh + refresh() + resultRow.settingKeybindGroup:Show() + if resultRow.settingSlider then resultRow.settingSliderGroup:Hide() end + resultRow.settingState:Hide() + resultRow.settingCheck:Hide() + resultRow.amountText:Hide() + resultRow.text:SetPoint("RIGHT", resultRow.settingKeybindGroup, "LEFT", -4, 0) + elseif data and data.settingVariable and not entry.isPathNode then + if resultRow.settingKeybindGroup then resultRow.settingKeybindGroup:Hide() end + local settingType = data.settingType + if settingType == "checkbox" then + local isOn = false + if Settings and Settings.GetSetting then + local sok, settObj = pcall(Settings.GetSetting, data.settingVariable) + if sok and settObj and settObj.GetValue then + local vok, v = pcall(settObj.GetValue, settObj) + if vok then + isOn = (v == true or v == "1" or v == 1) + end + end + end + if not isOn and GetCVar then + local val = GetCVar(data.settingVariable) + isOn = (val == "1") + end + resultRow.settingState:Show() + resultRow.settingCheck:SetShown(isOn) + resultRow.amountText:Hide() + if resultRow.settingSlider then resultRow.settingSliderGroup:Hide() end + -- Re-anchor text RIGHT to settingState so the row name + -- truncates at the checkbox instead of overlapping it. + resultRow.text:SetPoint("RIGHT", resultRow.settingState, "LEFT", -4, 0) + elseif settingType == "slider" and data.settingMin and data.settingMax then + -- Read current value + local rawVal + if Settings and Settings.GetSetting then + local sok, settObj = pcall(Settings.GetSetting, data.settingVariable) + if sok and settObj and settObj.GetValue then + local vok, v = pcall(settObj.GetValue, settObj) + if vok then rawVal = v end + end + end + if rawVal == nil and GetCVar then + rawVal = GetCVar(data.settingVariable) + end + local numVal = tonumber(rawVal) or data.settingMin + + local sMin, sMax = data.settingMin, data.settingMax + local stepVal = data.settingStep or 1 + if sMax <= sMin then sMax = sMin + 1 end + local slider = resultRow.settingSlider + slider._settingVar = data.settingVariable + slider._updating = true + slider:SetMinMaxValues(sMin, sMax) + slider:SetValueStep(stepVal) + slider:SetValue(numVal) + slider._updating = false + resultRow.settingSliderGroup:Show() + + local displayVal + if numVal == mfloor(numVal) then + displayVal = tostring(mfloor(numVal)) + else + displayVal = sformat("%.2f", numVal) + end + resultRow.settingSliderValue:SetText(displayVal) + + resultRow.settingState:Hide() + resultRow.settingCheck:Hide() + resultRow.amountText:Hide() + resultRow.text:SetPoint("RIGHT", resultRow.settingSliderGroup, "LEFT", -4, 0) + else + -- Dropdown / other: show current value as text + local val, rawVal + if Settings and Settings.GetSetting then + local sok, settObj = pcall(Settings.GetSetting, data.settingVariable) + if sok and settObj and settObj.GetValue then + local vok, raw = pcall(settObj.GetValue, settObj) + if vok and raw ~= nil then + rawVal = raw + val = tostring(raw) + end + end + end + if (not val or val == "") and GetCVar then + rawVal = GetCVar(data.settingVariable) + val = rawVal + end + -- Lazily cache the option list so subsequent renders + -- can translate raw values (often opaque ints) into + -- the localized label the dropdown actually shows. + if data.settingType == "dropdown" and data.settingOptions == nil + and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetOptionsForVariable then + data.settingOptions = ns.BlizzOptionsSearch.GetOptionsForVariable(data.settingVariable) or false + end + if data.settingOptions and rawVal ~= nil then + for oi = 1, #data.settingOptions do + local o = data.settingOptions[oi] + if o.value == rawVal or tostring(o.value) == tostring(rawVal) then + val = o.label or val + break + end + end + end + if val and val ~= "" then + resultRow.amountText:SetText("|cFFAAAAaa" .. val .. "|r") + resultRow.amountText:ClearAllPoints() + resultRow.amountText:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) + resultRow.amountText:Show() + end + resultRow.settingState:Hide() + resultRow.settingCheck:Hide() + if resultRow.settingSlider then resultRow.settingSliderGroup:Hide() end + end + else + resultRow.settingState:Hide() + resultRow.settingCheck:Hide() + if resultRow.settingSlider then resultRow.settingSliderGroup:Hide() end + if resultRow.settingKeybindGroup then resultRow.settingKeybindGroup:Hide() end + end + -- Flat-list icon sizing. The LEFT icon (UI/map/pin or flatCatIcon) -- is large since it's the row's visual anchor. The RIGHT icon -- (currency, mount, toy, pet, outfit, appearance set, loot) @@ -6304,6 +7445,15 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) elseif mainIconOnRight and resultRow.icon:IsShown() then rightAnchor = resultRow.icon rightOffset = -4 + elseif resultRow.settingSliderGroup and resultRow.settingSliderGroup:IsShown() then + rightAnchor = resultRow.settingSliderGroup + rightOffset = -4 + elseif resultRow.settingKeybindGroup and resultRow.settingKeybindGroup:IsShown() then + rightAnchor = resultRow.settingKeybindGroup + rightOffset = -4 + elseif resultRow.settingState and resultRow.settingState:IsShown() then + rightAnchor = resultRow.settingState + rightOffset = -4 elseif resultRow.amountText and resultRow.amountText:IsShown() then rightAnchor = resultRow.amountText rightOffset = -4 @@ -6381,6 +7531,21 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end end + -- Off-spec abilities: desaturate the icon and dim the text + -- to match the spellbook's greyed-out treatment for spells + -- that belong to a non-active spec line (offSpecID > 0). + if data and data.isOffSpec then + if resultRow.icon then + resultRow.icon:SetVertexColor(0.4, 0.4, 0.4, 1.0) + end + if resultRow.text then + resultRow.text:SetTextColor(0.5, 0.5, 0.5, 1.0) + end + if resultRow.pathSubtext then + resultRow.pathSubtext:SetTextColor(0.4, 0.4, 0.4, 1.0) + end + end + yOffset = yOffset + actualH resultRow:Show() else @@ -6552,8 +7717,143 @@ function UI:RefreshResults() end end +-- Toggle a boolean setting in place (clicked from a result row). +-- Tries the Settings API first (handles non-CVar settings like action +-- bar visibility), falls back to GetCVar/SetCVar. +function UI:ToggleSettingCheckbox(data) + if not data or not data.settingVariable then return end + local var = data.settingVariable + local toggled = false + if Settings and Settings.GetSetting then + local sok, settObj = pcall(Settings.GetSetting, var) + if sok and settObj and settObj.GetValue and settObj.SetValue then + local vok, curVal = pcall(settObj.GetValue, settObj) + if vok then + if type(curVal) == "boolean" then + pcall(settObj.SetValue, settObj, not curVal) + toggled = true + elseif curVal == "1" or curVal == "0" then + pcall(settObj.SetValue, settObj, curVal == "1" and "0" or "1") + toggled = true + end + end + end + end + if not toggled and GetCVar then + local cur = GetCVar(var) + if cur and SetCVar then + SetCVar(var, cur == "1" and "0" or "1") + end + end + -- Refresh the row so the checkbox state updates without closing + -- the search panel. Keeps focus on the editbox so the user can + -- toggle multiple settings without retyping. + self:RefreshResults() + if searchFrame and searchFrame.editBox + and not (navFrame and navFrame:IsKeyboardEnabled()) then + searchFrame.editBox.blockFocus = nil + searchFrame.editBox:SetFocus() + end +end + +-- Advance a dropdown setting to its next value inline. Returns true if +-- we found options and applied a new value; false if the variable +-- wasn't enumerable (caller should fall back to opening the panel). +function UI:CycleSettingDropdown(data) + if not data or not data.settingVariable then return false end + local var = data.settingVariable + local opts = data.settingOptions + if not opts and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetOptionsForVariable then + opts = ns.BlizzOptionsSearch.GetOptionsForVariable(var) + if opts then data.settingOptions = opts end + end + if not opts or #opts == 0 then return false end + + local settObj + if Settings and Settings.GetSetting then + local sok, s = pcall(Settings.GetSetting, var) + if sok then settObj = s end + end + local curVal + if settObj and settObj.GetValue then + local vok, v = pcall(settObj.GetValue, settObj) + if vok then curVal = v end + end + if curVal == nil and GetCVar then curVal = GetCVar(var) end + + local curIdx + for i = 1, #opts do + local v = opts[i].value + if v == curVal or tostring(v) == tostring(curVal) then + curIdx = i + break + end + end + local nextIdx = (curIdx or 0) % #opts + 1 + local nextVal = opts[nextIdx].value + + local applied = false + if settObj and settObj.SetValue then + applied = pcall(settObj.SetValue, settObj, nextVal) + end + if not applied and SetCVar then + pcall(SetCVar, var, tostring(nextVal)) + end + + self:RefreshResults() + if searchFrame and searchFrame.editBox + and not (navFrame and navFrame:IsKeyboardEnabled()) then + searchFrame.editBox.blockFocus = nil + searchFrame.editBox:SetFocus() + end + return true +end + +-- Open the Settings panel to a setting (slider/dropdown/etc.) without +-- closing the EasyFind search results. Mirrors ToggleSettingCheckbox's +-- "stay open" behavior so users can edit one setting in the panel and +-- still see / re-toggle others in the result list. +function UI:OpenSettingNoClose(data) + if not data or not data.steps or not data.steps[1] then return end + if ns.BlizzOptionsSearch then + ns.BlizzOptionsSearch:HandleStep(data.steps[1]) + end + -- Refresh in case the panel itself altered values that affect the + -- displayed amountText (e.g. dropdown selection updated). + self:RefreshResults() +end + +-- Close the filter dropdown and any nested sub-popups in one call. +-- Returns true if anything was actually visible (so callers can decide +-- whether to consume the ESC keystroke vs fall through to text-clear / +-- window-close behavior). Walks dropdown.guardFrames so ESC works the +-- same regardless of how deep the user has navigated into sub-filters. +function UI:CloseFilterDropdownIfOpen() + if not searchFrame then return false end + local dropdown = searchFrame.filterDropdown + if not dropdown then return false end + local closedAny = false + if dropdown.guardFrames then + for i = 1, #dropdown.guardFrames do + local guard = dropdown.guardFrames[i] + if guard and guard:IsShown() then + guard:Hide() + closedAny = true + end + end + end + if dropdown:IsShown() then + dropdown:Hide() + closedAny = true + end + return closedAny +end + function UI:HideResults() if not searchFrame then return end + if activeKeybindBtn and activeKeybindBtn._stopCapture then + activeKeybindBtn._stopCapture(activeKeybindBtn) + end if searchFrame.StopKeyRepeat then searchFrame.StopKeyRepeat() end if searchFrame.ClearToolbarFocus then searchFrame.ClearToolbarFocus() end if not resultsFrame then return end @@ -6644,6 +7944,11 @@ function UI:CountVisibleResults() end function UI:MoveSelection(delta) + -- CountVisibleResults walks the button pool and trusts each row's + -- :IsShown(), but child rows of a hidden resultsFrame still report + -- shown — so a leftover row from a prior search would let Ctrl+J + -- yank focus into nothing on an empty bar. Gate on the frame. + if not resultsFrame or not resultsFrame:IsShown() then return end local visibleCount = self:CountVisibleResults() if visibleCount == 0 then return end @@ -6715,6 +8020,18 @@ function UI:JumpToNextSection(direction) end function UI:UpdateSelectionHighlight(skipRefocus) + -- Action-hint overlay: replaces the selected row's pathSubtext with + -- a "Select to ..." hint so the user knows what Enter / left-click + -- will do, without cluttering every row. Restored to the canonical + -- subtext (recomputed via GetFlatSubtext) when selection moves. + local newSelRow = selectedIndex > 0 and resultButtons[selectedIndex] or nil + if newSelRow and not toggleFocused then + if not GetActionHint(newSelRow.data) then ClearActionHint() end + ApplyActionHint(newSelRow) + else + ClearActionHint() + end + for i = 1, MAX_BUTTON_POOL do local resultRow = resultButtons[i] if not resultRow then break end @@ -6797,12 +8114,18 @@ function UI:UpdateSelectionHighlight(skipRefocus) 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). + -- Bind Enter to the selected result button for any secure-action row + -- (spell/toy/macro/action/item) so the secure dispatch fires on keypress + -- the same as a mouse click. Without this, Enter on an ability would + -- fall through ActivateSelected → SelectResult, which intentionally + -- no-ops for spell/toy data because casting is protected. 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 secureRow = rd and (rd.outfitID or rd.toyItemID or rd.spellID + or rd.mountID or rd.macroIndex + or (rd.itemID and rd.category == "Bag")) + if secureRow then local btnName = selRow:GetName() if btnName then SetOverrideBindingClick(navFrame, true, "ENTER", btnName, "LeftButton") @@ -7027,7 +8350,11 @@ function UI:SelectResult(data, forceGuide) searchFrame.editBox:ClearFocus() searchFrame.editBox.placeholder:Show() selectingResult = false - self:HideResults() + if EasyFind.db.autoHide then + self:Hide() + else + self:HideResults() + end -- Transmogrification panel: load and show TransmogFrame @@ -7074,7 +8401,7 @@ function UI:SelectResult(data, forceGuide) steps = { { buttonFrame = "EJMicroButton" }, { waitForFrame = "EncounterJournal", tabIndex = tabIndex }, - { waitForFrame = "EncounterJournal", ejInstance = data.lootInstanceName }, + { waitForFrame = "EncounterJournal", ejInstance = data.lootInstanceName, ejInstanceID = data.instanceID }, { waitForFrame = "EncounterJournal", ejBoss = data.lootSourceName, ejEncounterID = data.encounterID }, { waitForFrame = "EncounterJournal", ejLootTab = true, ejDifficultyID = ejDiffID }, { waitForFrame = "EncounterJournal", ejLootItem = data.itemID, ejLootItemName = data.name }, @@ -7132,6 +8459,32 @@ function UI:SelectResult(data, forceGuide) return end + -- Heirloom: create the item in the player's bags. Mirrors clicking + -- a tile in the HeirloomsJournal: API hands you a fresh copy. + if data.heirloomItemID then + if C_Heirloom and C_Heirloom.CreateHeirloom then + C_Heirloom.CreateHeirloom(data.heirloomItemID) + end + return + end + + -- Title: set as current. SetCurrentTitle is unprotected and updates + -- the player's nameplate immediately, no journal navigation needed. + if data.titleID then + if SetCurrentTitle then SetCurrentTitle(data.titleID) end + return + end + + -- Gear set: equip via Equipment Manager. Skipped in combat — the + -- API silently fails there, so no point trying. + if data.gearSetID then + if InCombatLockdown() then return end + if C_EquipmentSet and C_EquipmentSet.UseEquipmentSet then + C_EquipmentSet.UseEquipmentSet(data.gearSetID) + end + return + end + -- Toy: handled by SecureActionButton on mousedown (UseToyByItemID is protected) if data.toyItemID then return end @@ -7170,11 +8523,42 @@ function UI:SelectResult(data, forceGuide) -- Map search result: open world map and search if data.mapSearchResult then if ns.MapSearch and ns.MapSearch.HandleUISearchClick then - ns.MapSearch:HandleUISearchClick(data) + ns.MapSearch:HandleUISearchClick(data, forceGuide) end return end + -- Bag item: usable items (consumables, equippables) fire /use via the + -- SecureActionButton on click — no bag UI needed. Non-usable items + -- open the bag(s) containing them and highlight the slot. + if data.itemID and data.category == "Bag" then + if useFast then + local hasUseEffect = (C_Item and C_Item.GetItemSpell and C_Item.GetItemSpell(data.itemID)) + or (GetItemSpell and GetItemSpell(data.itemID)) + local isEquippable = IsEquippableItem and IsEquippableItem(data.itemID) + if hasUseEffect or isEquippable then + return + end + local openBag = (C_Container and C_Container.OpenBag) or OpenBag + if openBag and data.bagLocations then + local seen = {} + for _, loc in ipairs(data.bagLocations) do + if not seen[loc.bag] then + seen[loc.bag] = true + pcall(openBag, loc.bag) + end + end + elseif openBag then + pcall(openBag, data.bagID) + end + if data.steps and #data.steps >= 2 and ns.Highlight and ns.Highlight.StartGuideAtStep then + ns.Highlight:StartGuideAtStep(data, 2) + end + return + end + if not data.steps or #data.steps == 0 then return end + end + -- Macro: open MacroFrame and select the macro slot. if data.macroIndex then if useFast then @@ -7276,6 +8660,7 @@ function UI:DirectOpen(data) -- A step is navigable if it has any clickable action property. local function isStepNavigable(step) if step.buttonFrame then return true end + if step.gameMenuText then return true end if step.tabIndex then return true end if step.sideTabIndex then return true end if step.pvpSideTabIndex then return true end @@ -7344,6 +8729,17 @@ function UI:DirectOpen(data) return end + -- Game Menu button by text: open the menu (if not already) + -- and click the labeled child. Handles unnamed dynamic-ID + -- buttons in modern GameMenuFrame. + if step.gameMenuText then + if not GameMenuFrame:IsShown() then + pcall(GameMenuFrame.Show, GameMenuFrame) + end + local btn = Highlight:FindGameMenuButton(step.gameMenuText) + if btn then ClickButton(btn) end + 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) @@ -7359,6 +8755,24 @@ function UI:DirectOpen(data) C_Timer.After(0, function() executeFrom(resume) end) return end + -- Boss navigation: set tier + dungeon/raid tab before showing + -- so the InstanceSelect ScrollBox populates with the right + -- tier's instances on first paint. ShowUIPanel is a no-op + -- when EJ is already shown (OnShow / SetTab won't refire), + -- so we still need an explicit tab click to apply the new + -- tier when the user re-opens onto a different expansion. + if nextStep and nextStep.waitForFrame == "EncounterJournal" and nextStep.ejTier then + EncounterJournal_LoadUI() + if EJ_SelectTier then EJ_SelectTier(nextStep.ejTier) end + local tabIdx = nextStep.ejTabIsRaid and 5 or 4 + EncounterJournal.selectedTab = tabIdx + ShowUIPanel(EncounterJournal) + local tabBtn = Highlight:GetTabButton("EncounterJournal", tabIdx) + if tabBtn then ClickButton(tabBtn) end + local resume = i + 2 + C_Timer.After(0, function() executeFrom(resume) end) + return + end end local stepFrame = Utils.GetFrameByPath(step.buttonFrame) or _G[step.buttonFrame] if stepFrame then ClickButton(stepFrame) end @@ -7410,30 +8824,79 @@ function UI:DirectOpen(data) self:ClickAchievementCategory(categoryToClick) end - -- EJ instance: find by name in ScrollBox and click + -- EJ tier + dungeon/raid tab (boss navigation when EJ is already + -- open). The EJMicroButton fast path handles the cold-open case; + -- this branch handles re-opens on a different tier. + if step.waitForFrame == "EncounterJournal" and step.ejTier then + if EJ_SelectTier then EJ_SelectTier(step.ejTier) end + local tabIdx = step.ejTabIsRaid and 5 or 4 + EncounterJournal.selectedTab = tabIdx + local tabBtn = Highlight:GetTabButton("EncounterJournal", tabIdx) + if tabBtn then ClickButton(tabBtn) end + -- Defer remaining steps so the InstanceSelect ScrollBox can + -- rebuild with the new tier's instances before we look up + -- the target instance by name. + local resume = i + 1 + C_Timer.After(0, function() executeFrom(resume) end) + return + end + + -- EJ instance: prefer the EncounterJournal_DisplayInstance API + -- (synchronous, no ScrollBox race) when we have the instanceID, + -- otherwise fall back to scanning visible buttons by name. 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 + local displayInstance = _G["EncounterJournal_DisplayInstance"] + if step.ejInstanceID and displayInstance then + pcall(displayInstance, step.ejInstanceID) + else + 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 + -- Display rebuilds the BossesScrollBox dataprovider, but + -- buttons / overview content aren't laid out until next + -- frame. Defer so the ejBoss/ejLootTab step that follows + -- scans a populated list. + local nextStep = steps[i + 1] + if nextStep and (nextStep.ejBoss or nextStep.ejLootTab) then + local resume = i + 1 + C_Timer.After(0.05, function() executeFrom(resume) end) + return end end - -- EJ boss: find by name in BossesScrollBox and click + -- EJ boss: prefer EncounterJournal_DisplayEncounter API when we + -- have the encounterID. Falls back to ScrollBox button scan. 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 + local displayEncounter = _G["EncounterJournal_DisplayEncounter"] + if step.ejEncounterID and displayEncounter then + pcall(displayEncounter, step.ejEncounterID) + else + 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 + -- ejLootTab needs a frame for the boss content to settle + -- before we click the loot tab (otherwise Overview eats + -- the click and the loot list never appears). + local nextStep = steps[i + 1] + if nextStep and nextStep.ejLootTab then + local resume = i + 1 + C_Timer.After(0.05, function() executeFrom(resume) end) + return end end @@ -7597,8 +9060,13 @@ function UI:DirectOpen(data) end end - -- Hand off remaining steps to the guided highlight + -- Hand off remaining steps to the guided highlight. DirectOpen + -- is left-click "open + show me where" — not the right-click + -- guided walkthrough, so flag the data so the highlight ticker + -- cancels (instead of rewinding to step 1 and reopening) if the + -- user closes the parent window with the highlight still active. if not finalStepNavigable and Highlight then + data.noCourseCorrect = true Highlight:StartGuideAtStep(data, executeCount + 1) end end @@ -7747,9 +9215,8 @@ function UI:Show(andFocus) if inCombat then return end searchFrame:Show() EasyFind.db.visible = true - if EasyFind.db.smartShow then + if EasyFind.db.smartShow and not EasyFind.db.autoHide then searchFrame.hoverZone:Show() - -- Briefly reveal the bar then let smart-show fade it back out searchFrame.smartShowFadeIn() C_Timer.After(1.5, function() if EasyFind.db.smartShow then @@ -7757,9 +9224,7 @@ function UI:Show(andFocus) end end) end - if andFocus then - -- Delay focus by one frame so the keybind key-press that triggered - -- this Show() doesn't get typed into the editbox. + if andFocus or EasyFind.db.autoHide then C_Timer.After(0, function() if searchFrame:IsShown() then searchFrame.editBox.blockFocus = nil @@ -7775,7 +9240,7 @@ function UI:Hide() searchFrame.setSmartShowVisible(false) self:HideResults() searchFrame.editBox:ClearFocus() - searchFrame.editBox.placeholder:Show() + searchFrame.editBox.placeholder:SetShown(searchFrame.editBox:GetText() == "") EasyFind.db.visible = false searchFrame.hoverZone:SetShown(EasyFind.db.smartShow) @@ -8003,42 +9468,22 @@ end function UI:UpdateOpacity() if not searchFrame then return end local alpha = EasyFind.db.searchBarOpacity or DEFAULT_OPACITY - local theme = GetActiveTheme() - if theme.searchBarRounded then - if containerFrame then - ns.SetRoundedRectBorderBgAlpha(containerFrame, alpha) - end - else - searchFrame:SetBackdropColor(0, 0, 0, alpha) + if containerFrame then + ns.SetRoundedRectBorderBgAlpha(containerFrame, alpha) end end function UI:UpdateSearchBarTheme() if not searchFrame then return end - local theme = GetActiveTheme() local scale = EasyFind.db.fontSize or 1.0 - local WHITE8x8 = "Interface\\BUTTONS\\WHITE8x8" local alpha = EasyFind.db.searchBarOpacity or DEFAULT_OPACITY - if theme.searchBarRounded then - searchFrame:SetBackdrop(nil) - -- Pill stays hidden; container provides the rounded silhouette. - ns.SetSearchBorderShown(searchFrame, false) - if containerFrame then - ns.SetRoundedRectBorderShown(containerFrame, true) - ns.SetRoundedRectBarHeight(containerFrame, ns.SEARCHBAR_HEIGHT * scale) - ns.SetRoundedRectBorderBgAlpha(containerFrame, alpha) - end - else - searchFrame:SetBackdrop({ - bgFile = WHITE8x8, - edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", - edgeSize = 20 * scale, - insets = { left = 5 * scale, right = 5 * scale, top = 5 * scale, bottom = 5 * scale } - }) - searchFrame:SetBackdropBorderColor(1, 1, 1, 1) - searchFrame:SetBackdropColor(0, 0, 0, alpha) - ns.SetSearchBorderShown(searchFrame, false) - if containerFrame then ns.SetRoundedRectBorderShown(containerFrame, false) end + searchFrame:SetBackdrop(nil) + -- Pill stays hidden; container provides the rounded silhouette. + ns.SetSearchBorderShown(searchFrame, false) + if containerFrame then + ns.SetRoundedRectBorderShown(containerFrame, true) + ns.SetRoundedRectBarHeight(containerFrame, ns.SEARCHBAR_HEIGHT * scale) + ns.SetRoundedRectBorderBgAlpha(containerFrame, alpha) end end @@ -8761,7 +10206,6 @@ function UI:UpdateFontSize() local barH = ns.SEARCHBAR_HEIGHT * scale local contentSz = barH * ns.SEARCHBAR_FILL local iconSz = contentSz * ns.SEARCHBAR_ICON_SCALE - local clearSz = ns.CLEAR_BTN_SIZE * scale searchFrame:SetHeight(barH) searchFrame.editBox:SetHeight(contentSz) searchFrame.searchIcon:SetSize(iconSz, iconSz) @@ -8771,9 +10215,6 @@ function UI:UpdateFontSize() if searchFrame.filterBtn then searchFrame.filterBtn:SetWidth(barH) end - if searchFrame.clearTextBtn then - searchFrame.clearTextBtn:SetSize(clearSz, clearSz) - end local theme = GetActiveTheme() local WHITE8x8 = "Interface\\BUTTONS\\WHITE8x8" diff --git a/Utils.lua b/Utils.lua index 42b10a3..0b5efbe 100644 --- a/Utils.lua +++ b/Utils.lua @@ -856,6 +856,14 @@ function Utils.CreateMinimalScrollBar(scrollFrame, parent) bar:SetScript("OnUpdate", function(self) if not self.isDragging then return end + -- Mouse-up can fire outside the thumb (drag off the edge, release + -- on another frame). The thumb's OnMouseUp doesn't fire then, so + -- detect button release here and end the drag. + if not IsMouseButtonDown("LeftButton") then + self.isDragging = false + if not thumb:IsMouseOver() then SetThumbNormal() end + return + end local range = scrollFrame:GetVerticalScrollRange() if range <= 0 then return end @@ -1039,3 +1047,71 @@ function Utils.CreateClearButton(parent, globalName) return btn end + +-- --------------------------------------------------------------------------- +-- Addon-wide font selection. +-- +-- ns.FONT_CHOICES drives the Options dropdown. Each FontString that opts +-- into user-selectable fonts goes through ns.RegisterAddonFont(fs, weight, +-- sizeOverride, flags) which: +-- 1. snapshots the FontString's existing font (path/size/flags) as the +-- "Default" baseline so we can revert without remembering Friz Quadrata +-- paths, +-- 2. records the requested addon weight ("regular" / "semibold" / "bold") +-- so non-Default choices know which weight file to load, +-- 3. tracks the FontString in a registry, +-- 4. immediately applies the current user's choice. +-- +-- ns.RefreshAddonFont() re-applies the current EasyFind.db.font to every +-- registered FontString -- call it from the Options selector callback. Modules +-- that want to participate just call RegisterAddonFont after CreateFontString. +-- --------------------------------------------------------------------------- +local INTER_REGULAR = "Interface\\AddOns\\EasyFind\\Fonts\\Inter-Regular.ttf" +local INTER_SEMIBOLD = "Interface\\AddOns\\EasyFind\\Fonts\\Inter-SemiBold.ttf" +local INTER_BOLD = "Interface\\AddOns\\EasyFind\\Fonts\\Inter-Bold.ttf" + +ns.FONT_CHOICES = { "Default", "Inter" } + +local fontRegistry = {} +ns._fontRegistry = fontRegistry + +local function GetFontChoice() + return (EasyFind and EasyFind.db and EasyFind.db.font) or "Default" +end + +local function ApplyFontTo(fs) + if not fs or not fs.SetFont then return end + local baseline = fs._addonFontBaseline + if not baseline then return end + local size = fs._addonFontSizeOverride or baseline.size or 12 + local flags = fs._addonFontFlags or baseline.flags or "" + local choice = GetFontChoice() + if choice == "Inter" then + local w = fs._addonFontWeight + local path = INTER_REGULAR + if w == "bold" then path = INTER_BOLD + elseif w == "semibold" then path = INTER_SEMIBOLD end + fs:SetFont(path, size, flags) + else + fs:SetFont(baseline.path, baseline.size, baseline.flags or "") + end +end + +function ns.RegisterAddonFont(fs, weight, sizeOverride, flags) + if not fs or not fs.GetFont or not fs.SetFont then return end + if not fs._addonFontBaseline then + local p, sz, fl = fs:GetFont() + fs._addonFontBaseline = { path = p, size = sz, flags = fl } + fontRegistry[#fontRegistry + 1] = fs + end + fs._addonFontWeight = weight + fs._addonFontSizeOverride = sizeOverride + fs._addonFontFlags = flags + ApplyFontTo(fs) +end + +function ns.RefreshAddonFont() + for i = 1, #fontRegistry do + ApplyFontTo(fontRegistry[i]) + end +end diff --git a/Wizard.lua b/Wizard.lua new file mode 100644 index 0000000..336fbb5 --- /dev/null +++ b/Wizard.lua @@ -0,0 +1,908 @@ +local _, ns = ... + +local Wizard = {} +ns.Wizard = Wizard + +local Utils = ns.Utils +local SafeCallMethod = Utils.SafeCallMethod +local SafeAfter = Utils.SafeAfter or function(d, fn) C_Timer.After(d, fn) end + +local CreateFrame = CreateFrame +local UIParent = UIParent +local GetBindingKey = GetBindingKey +local SetBinding = SetBinding +local SaveBindings = SaveBindings +local GetCurrentBindingSet = GetCurrentBindingSet +local IsAltKeyDown, IsControlKeyDown, IsShiftKeyDown = IsAltKeyDown, IsControlKeyDown, IsShiftKeyDown + +local GOLD = ns.GOLD_COLOR or { 1.0, 0.82, 0.0 } +local WIZ_W, WIZ_H = 544, 408 +local TOGGLE_ACTION = "EASYFIND_TOGGLE_FOCUS" +local MAP_ACTION = "EASYFIND_MAP_FOCUS" + +local PANEL_BG_ALPHA = 0.97 +local TEXT_PRIM = { 1.00, 0.97, 0.86 } +local TEXT_BODY = { 0.78, 0.78, 0.80 } +local TEXT_DIM = { 0.55, 0.55, 0.58 } + +local DOT_FILLED = "Interface\\COMMON\\Indicator-Yellow" + +-- Wizard FontStrings opt into the user-selectable font system. The +-- registry tracks each one so the Options "Font" selector can re-apply +-- on change without re-creating the wizard. By default it's "Default" +-- (Friz Quadrata via the original GameFont template); picking "Inter" +-- swaps each registered string to the matching Inter weight. +local function ApplyInter(fs, weight, sizeOverride, flags) + if ns.RegisterAddonFont then + ns.RegisterAddonFont(fs, weight, sizeOverride, flags) + end +end + +local frame +local pages = {} +local pageIdx = 1 +local dots = {} +local backBtn, nextBtn + +-- Two visual styles, both monochrome: +-- solid : subtle dark fill, dim text that brightens on hover +-- ghost : no fill at all, dim text that brightens on hover +-- rounded : same TC9 rounded-pill silhouette the keybind buttons use, +-- dark gray fill, white text always +local function MakeButton(parent, text, variant, w) + local b = CreateFrame("Button", nil, parent) + local h = (variant == "rounded") and 26 or 24 + b:SetSize(w or 96, h) + + local fs = b:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + fs:SetPoint("CENTER") + fs:SetText(text) + ApplyInter(fs, "semibold", 12) + b._label = fs + + if variant == "rounded" then + ns.CreateRoundedRectBorder(b) + -- Half this gives the corner radius. ~5px corners read as + -- "rounded but not a pill" -- enough softness to feel + -- modern, not so much it loses button identity. + ns.SetRoundedRectBarHeight(b, 10) + ns.SetRoundedRectBorderBgAlpha(b, 1) + -- Hide the perimeter ring entirely; at 5-px corners the + -- 256-px border texture downscales to a stairstep that no + -- amount of pixel-snap tweaking smooths over. Just the fill + -- silhouette reads as a clean rounded rectangle. + if b.combinedBorder and b.combinedBorder.border then + for _, t in pairs(b.combinedBorder.border) do + t:Hide() + end + end + if b.combinedBorder and b.combinedBorder.fill then + for _, t in pairs(b.combinedBorder.fill) do + if t.SetSnapToPixelGrid then t:SetSnapToPixelGrid(false) end + if t.SetTexelSnappingBias then t:SetTexelSnappingBias(0) end + end + end + local function tintFill(rr, gg, bb) + if not (b.combinedBorder and b.combinedBorder.fill) then return end + for _, t in pairs(b.combinedBorder.fill) do + t:SetVertexColor(rr, gg, bb, 1) + end + end + tintFill(0.18, 0.18, 0.20) + fs:SetTextColor(1, 1, 1, 1) + b:SetScript("OnEnter", function() tintFill(0.26, 0.26, 0.28) end) + b:SetScript("OnLeave", function() tintFill(0.18, 0.18, 0.20) end) + b:SetScript("OnMouseDown", function() tintFill(0.12, 0.12, 0.14) end) + b:SetScript("OnMouseUp", function(self) + tintFill(self:IsMouseOver() and 0.26 or 0.18, + self:IsMouseOver() and 0.26 or 0.18, + self:IsMouseOver() and 0.28 or 0.20) + end) + return b + end + + local bg = b:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints() + b._bg = bg + + local function setVisual(state) + if variant == "ghost" then + bg:SetColorTexture(0, 0, 0, 0) + else + if state == "pressed" then + bg:SetColorTexture(0.10, 0.10, 0.11, 1) + elseif state == "hover" then + bg:SetColorTexture(0.18, 0.18, 0.20, 1) + else + bg:SetColorTexture(0.13, 0.13, 0.15, 1) + end + end + if state == "hover" or state == "pressed" then + fs:SetTextColor(TEXT_PRIM[1], TEXT_PRIM[2], TEXT_PRIM[3], 1) + else + fs:SetTextColor(TEXT_DIM[1], TEXT_DIM[2], TEXT_DIM[3], 1) + end + end + setVisual("normal") + b:SetScript("OnEnter", function() setVisual("hover") end) + b:SetScript("OnLeave", function() setVisual("normal") end) + b:SetScript("OnMouseDown", function() setVisual("pressed") end) + b:SetScript("OnMouseUp", function(self) + if self:IsMouseOver() then setVisual("hover") else setVisual("normal") end + end) + return b +end + +local function ShowPage(i) + if i < 1 or i > #pages then return end + pageIdx = i + for idx, p in ipairs(pages) do + p:SetShown(idx == i) + end + for idx, d in ipairs(dots) do + if idx == i then + d:SetVertexColor(GOLD[1], GOLD[2], GOLD[3], 1) + d:SetSize(11, 11) + else + d:SetVertexColor(0.32, 0.32, 0.36, 1) + d:SetSize(9, 9) + end + end + backBtn:SetShown(i > 1) + if i == #pages then + nextBtn._label:SetText("Open Bar") + elseif i == 1 then + nextBtn._label:SetText("Get Started") + else + nextBtn._label:SetText("Continue") + end + if pages[i].OnEnter then pages[i].OnEnter() end +end + +local function FinishWizard(openBar) + if not frame then return end + EasyFind.db.tutorialDone = true + EasyFind.db.setupComplete = true + if EasyFind.db.lastSeenVersion == nil then + EasyFind.db.lastSeenVersion = ns.version + end + SafeCallMethod(frame, "EnableKeyboard", false) + frame:Hide() + if openBar and ns.UI and ns.UI.Show then + SafeAfter(0.05, function() ns.UI:Show(true) end) + end +end + +local function MakePage(parent) + local p = CreateFrame("Frame", nil, parent) + p:SetAllPoints(parent) + p:Hide() + return p +end + +local function HeaderText(parent, text, font) + local fs = parent:CreateFontString(nil, "OVERLAY", font or "GameFontNormalHuge") + -- Inter SemiBold at the size already implied by the GameFont + -- template (the SetFont call below preserves size automatically). + ApplyInter(fs, "semibold") + fs:SetText(text) + fs:SetTextColor(TEXT_PRIM[1], TEXT_PRIM[2], TEXT_PRIM[3], 1) + return fs +end + +local function BodyText(parent, text) + local fs = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlight") + ApplyInter(fs, "regular") + fs:SetText(text) + fs:SetTextColor(TEXT_BODY[1], TEXT_BODY[2], TEXT_BODY[3], 1) + fs:SetJustifyH("CENTER") + fs:SetJustifyV("TOP") + fs:SetSpacing(4) + return fs +end + +local function BuildPage1(parent) + local p = MakePage(parent) + + local logo = p:CreateTexture(nil, "OVERLAY") + logo:SetSize(108, 108) + logo:SetTexture("Interface\\AddOns\\EasyFind\\Textures\\Spyglass") + logo:SetPoint("TOP", p, "TOP", 0, -40) + + local title = HeaderText(p, "Welcome to EasyFind") + title:SetPoint("TOP", logo, "BOTTOM", 0, -22) + -- 50% larger than the GameFontNormalHuge baseline. + do + local path, sz, fl = title:GetFont() + if sz then title:SetFont(path, sz * 1.5, fl or "") end + end + + local sub = BodyText(p, "Your shortcut to anything in WoW.") + sub:SetPoint("TOP", title, "BOTTOM", 0, -16) + sub:SetWidth(WIZ_W - 120) + sub:SetTextColor(TEXT_DIM[1], TEXT_DIM[2], TEXT_DIM[3], 1) + + return p +end + +local function FeatureTile(parent, atlas, file, coords, title, desc, onClick) + -- Holder owns the external anchor (TOPLEFT/TOPRIGHT in BuildPage2). + -- The tile inside is pinned to the holder's CENTER so SetScale + -- grows the tile symmetrically around its midpoint instead of + -- expanding from a corner. + local holder = CreateFrame("Frame", nil, parent) + holder:SetSize(222, 112) + + local tile = CreateFrame("Button", nil, holder) + tile:SetSize(222, 112) + tile:SetPoint("CENTER", holder, "CENTER", 0, 0) + tile:RegisterForClicks("LeftButtonUp") + + -- Soft rounded silhouette via the same TC9 fill the search bar + -- uses; corner radius = ~6px so the box reads as "rounded but not + -- a pill". Hide the bright perimeter ring (it aliases at small + -- corner sizes) and skip a separate edge layer -- the rounded + -- fill alone defines the shape. + ns.CreateRoundedRectBorder(tile) + ns.SetRoundedRectBarHeight(tile, 12) + ns.SetRoundedRectBorderBgAlpha(tile, 0.95) + if tile.combinedBorder and tile.combinedBorder.fill then + for _, t in pairs(tile.combinedBorder.fill) do + t:SetVertexColor(0.05, 0.05, 0.06, 1) + if t.SetSnapToPixelGrid then t:SetSnapToPixelGrid(false) end + if t.SetTexelSnappingBias then t:SetTexelSnappingBias(0) end + end + end + if tile.combinedBorder and tile.combinedBorder.border then + for _, t in pairs(tile.combinedBorder.border) do t:Hide() end + end + + local icon = tile:CreateTexture(nil, "ARTWORK") + icon:SetSize(36, 36) + icon:SetPoint("TOPLEFT", tile, "TOPLEFT", 14, -14) + if atlas then + icon:SetAtlas(atlas) + elseif file then + icon:SetTexture(file) + if coords then icon:SetTexCoord(coords[1], coords[2], coords[3], coords[4]) end + end + + local fs = tile:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + fs:SetText(title) + fs:SetTextColor(GOLD[1], GOLD[2], GOLD[3], 1) + fs:SetPoint("TOPLEFT", icon, "TOPRIGHT", 12, -2) + ApplyInter(fs, "semibold") + + local body = tile:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + body:SetText(desc) + body:SetTextColor(TEXT_BODY[1], TEXT_BODY[2], TEXT_BODY[3], 1) + body:SetJustifyH("LEFT") + body:SetJustifyV("TOP") + body:SetSpacing(2) + body:SetPoint("TOPLEFT", fs, "BOTTOMLEFT", 0, -6) + body:SetPoint("RIGHT", tile, "RIGHT", -12, 0) + ApplyInter(body, "regular") + + -- Hover lift: scale the tile up symmetrically. Because the tile + -- is anchored to the holder's CENTER, SetScale grows it outward + -- around that midpoint rather than expanding from a corner. + tile:SetScript("OnEnter", function(self) + self:SetScale(1.04) + if self.combinedBorder and self.combinedBorder.fill then + for _, t in pairs(self.combinedBorder.fill) do + t:SetVertexColor(0.09, 0.09, 0.11, 1) + end + end + end) + tile:SetScript("OnLeave", function(self) + self:SetScale(1.00) + if self.combinedBorder and self.combinedBorder.fill then + for _, t in pairs(self.combinedBorder.fill) do + t:SetVertexColor(0.05, 0.05, 0.06, 1) + end + end + end) + if onClick then + tile:SetScript("OnClick", onClick) + end + + holder.tile = tile + return holder +end + +local function BuildPage2(parent) + local p = MakePage(parent) + + -- Page 2 has two states: a 4-tile grid and per-tile detail views. + -- Both states are full-page subframes; clicking a tile swaps grid + -- for that tile's detail; the in-detail back arrow returns to grid. + local grid = CreateFrame("Frame", nil, p) + grid:SetAllPoints(p) + + local title = HeaderText(grid, "What you can do", "GameFontNormalLarge") + title:SetPoint("TOP", grid, "TOP", 0, -28) + + local sub = BodyText(grid, "Type once. Find anything.") + sub:SetPoint("TOP", title, "BOTTOM", 0, -6) + + local detailViews = {} + + local function ShowGrid() + grid:Show() + for i = 1, #detailViews do detailViews[i]:Hide() end + end + + local function ShowDetail(d) + grid:Hide() + for i = 1, #detailViews do detailViews[i]:Hide() end + d:Show() + end + + local function CreateDetailView(headerText) + local d = CreateFrame("Frame", nil, p) + d:SetAllPoints(p) + d:Hide() + + local back = MakeButton(d, "< Back", "ghost", 64) + back:SetPoint("TOPLEFT", d, "TOPLEFT", 12, -10) + back:SetScript("OnClick", ShowGrid) + + local h = HeaderText(d, headerText, "GameFontNormalLarge") + h:SetPoint("TOP", d, "TOP", 0, -28) + + local body = BodyText(d, "Details coming soon.") + body:SetPoint("TOP", h, "BOTTOM", 0, -16) + body:SetWidth(WIZ_W - 120) + + detailViews[#detailViews + 1] = d + return d + end + + local d1 = CreateDetailView("Search") + local d2 = CreateDetailView("Map Search") + local d3 = CreateDetailView("Click & Right-click") + local d4 = CreateDetailView("Pin Anything") + + local t1 = FeatureTile(grid, nil, "Interface\\AddOns\\EasyFind\\Textures\\Spyglass", nil, + "Search", + "Search any panel, tab, setting, mount, toy, currency, achievement, or vendor item.", + function() ShowDetail(d1) end) + t1:SetPoint("TOPLEFT", grid, "TOPLEFT", 38, -96) + + local t2 = FeatureTile(grid, "Waypoint-MapPin-Untracked", nil, nil, + "Map Search", + "Dedicated map browsing for banks, flight masters, dungeons, raids, and zones. Also reachable from Standard Search.", + function() ShowDetail(d2) end) + t2:SetPoint("TOPRIGHT", grid, "TOPRIGHT", -38, -96) + + -- Anchor lower-row tiles directly to the grid so a tile's hover + -- scale doesn't ripple position changes onto its neighbor. + local t3 = FeatureTile(grid, "UI-HUD-MicroMenu-SpellbookAbilities-Up", nil, nil, + "Click & Right-click", + "Left-click activates the result (cast, use, equip, navigate). Right-click opens Pin / Alias / Guide menu.", + function() ShowDetail(d3) end) + t3:SetPoint("TOPLEFT", grid, "TOPLEFT", 38, -222) + + local t4 = FeatureTile(grid, "Waypoint-MapPin-ChatIcon", nil, nil, + "Pin Anything", + "Pin frequently-used results for quick access.", + function() ShowDetail(d4) end) + t4:SetPoint("TOPRIGHT", grid, "TOPRIGHT", -38, -222) + + -- Reset to grid each time the page is entered so navigating away + -- and back doesn't leave a stale detail view open. + p.OnEnter = ShowGrid + + return p +end + +-- One capture-button widget per binding. Rounded fill (same TC9 +-- 9-slice the search bar uses, pinned at button height for a true +-- pill silhouette) plus a soft center glow stack -- two stacked +-- white squares with low alpha and ADD blend, the smaller one +-- brighter, fading outward like a worn-down key polished by years +-- of presses. Right-click clears the binding, Esc cancels capture. +local kbWidgets = {} +local kbWaitingFor + +local function RefreshKbWidget(widget) + local cur = GetBindingKey(widget.action) + widget.btn._label:SetText(cur or "Not bound") +end + +local function StopKeybindCapture() + if not kbWaitingFor then return end + local widget = kbWaitingFor + kbWaitingFor = nil + SafeCallMethod(widget.btn, "EnableKeyboard", false) + widget.btn:SetScript("OnKeyDown", nil) + RefreshKbWidget(widget) +end + +local function CreateKbWidget(parent, action, label) + local w = { action = action } + + local lbl = parent:CreateFontString(nil, "OVERLAY", "GameFontDisable") + lbl:SetText(label) + lbl:SetTextColor(TEXT_DIM[1], TEXT_DIM[2], TEXT_DIM[3], 1) + ApplyInter(lbl, "regular") + w.label = lbl + + local btn = CreateFrame("Button", nil, parent) + btn:SetSize(208, 40) + btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + w.btn = btn + + -- Rounded silhouette (corner radius = btnHeight/2 = 20px = full pill). + ns.CreateRoundedRectBorder(btn) + ns.SetRoundedRectBarHeight(btn, 40) + ns.SetRoundedRectBorderBgAlpha(btn, 1) + -- Hide the perimeter ring; the fill silhouette alone reads as a + -- clean rounded rectangle and avoids the aliased outline. + if btn.combinedBorder and btn.combinedBorder.border then + for _, t in pairs(btn.combinedBorder.border) do + t:Hide() + end + end + if btn.combinedBorder and btn.combinedBorder.fill then + for _, t in pairs(btn.combinedBorder.fill) do + t:SetVertexColor(0.18, 0.18, 0.20, 1) + end + end + + -- Worn-key center glow: two horizontal-gradient halves meeting at + -- the button's centerline. Each half fades from transparent at + -- the outer edge to a soft warm tint at the centerline, so the + -- composite peaks in the middle and dies away symmetrically. + -- ADD blend keeps the rounded silhouette unaffected. Glow height + -- is kept inside the rounded interior so its rectangular top + -- and bottom edges fall within the flat middle of the pill. + local function MakeGlowHalf(anchorEdge, fromAlpha, toAlpha) + local g = btn:CreateTexture(nil, "ARTWORK") + g:SetTexture("Interface\\Buttons\\WHITE8x8") + g:SetBlendMode("ADD") + g:SetSize(80, 18) + g:SetPoint(anchorEdge, btn, "CENTER", 0, 0) + g:SetGradient("HORIZONTAL", + CreateColor(1, 0.96, 0.82, fromAlpha), + CreateColor(1, 0.96, 0.82, toAlpha)) + return g + end + local glowL = MakeGlowHalf("RIGHT", 0, 0.07) + local glowR = MakeGlowHalf("LEFT", 0.07, 0) + + local fs = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + fs:SetPoint("CENTER") + ApplyInter(fs, "semibold") + btn._label = fs + + local function setHover(hover) + local peak = hover and 0.14 or 0.07 + glowL:SetGradient("HORIZONTAL", + CreateColor(1, 0.96, 0.82, 0), + CreateColor(1, 0.96, 0.82, peak)) + glowR:SetGradient("HORIZONTAL", + CreateColor(1, 0.96, 0.82, peak), + CreateColor(1, 0.96, 0.82, 0)) + fs:SetTextColor(hover and 1 or TEXT_PRIM[1], + hover and 1 or TEXT_PRIM[2], + hover and 1 or TEXT_PRIM[3], 1) + end + setHover(false) + btn:SetScript("OnEnter", function() setHover(true) end) + btn:SetScript("OnLeave", function() setHover(false) end) + + btn:SetScript("OnClick", function(self, mouseButton) + if mouseButton == "RightButton" then + local o1, o2 = GetBindingKey(action) + if o1 then SetBinding(o1) end + if o2 then SetBinding(o2) end + SaveBindings(GetCurrentBindingSet()) + RefreshKbWidget(w) + return + end + if kbWaitingFor == w then + StopKeybindCapture() + return + end + if kbWaitingFor then StopKeybindCapture() end + kbWaitingFor = w + self._label:SetText("Press a key...") + SafeCallMethod(self, "EnableKeyboard", true) + self:SetScript("OnKeyDown", function(s, key) + if key == "LSHIFT" or key == "RSHIFT" or key == "LCTRL" or key == "RCTRL" + or key == "LALT" or key == "RALT" then return end + if key == "ESCAPE" then + StopKeybindCapture() + return + end + local hasMod = IsAltKeyDown() or IsControlKeyDown() or IsShiftKeyDown() + if not hasMod and (key == "SPACE" or key == "ENTER" + or key == "W" or key == "A" or key == "S" or key == "D") then + return + end + local combo = "" + if IsAltKeyDown() then combo = combo .. "ALT-" end + if IsControlKeyDown() then combo = combo .. "CTRL-" end + if IsShiftKeyDown() then combo = combo .. "SHIFT-" end + combo = combo .. key + local o1, o2 = GetBindingKey(action) + if o1 then SetBinding(o1) end + if o2 then SetBinding(o2) end + SetBinding(combo, action) + SaveBindings(GetCurrentBindingSet()) + StopKeybindCapture() + end) + end) + + return w +end + +local function BuildPage3(parent) + local p = MakePage(parent) + + local title = HeaderText(p, "Pick your hotkeys", "GameFontNormalLarge") + title:SetPoint("TOP", p, "TOP", 0, -36) + + local sub = BodyText(p, + "These are the key combos that open each search bar.\n" .. + "Ctrl+Space and Ctrl+M are clean defaults, but anything works.") + sub:SetPoint("TOP", title, "BOTTOM", 0, -10) + sub:SetWidth(WIZ_W - 100) + + local uiKb = CreateKbWidget(p, TOGGLE_ACTION, "Search Bar") + local mapKb = CreateKbWidget(p, MAP_ACTION, "Map Search") + kbWidgets = { uiKb, mapKb } + + -- Stacked layout: search bar on top, map search below. + uiKb.label:SetPoint("RIGHT", uiKb.btn, "LEFT", -14, 0) + uiKb.btn:SetPoint("TOP", sub, "BOTTOM", 40, -28) + + mapKb.label:SetPoint("RIGHT", mapKb.btn, "LEFT", -14, 0) + mapKb.btn:SetPoint("TOP", uiKb.btn, "BOTTOM", 0, -16) + + local hint = p:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + hint:SetText("Click a button, then press your keys. Right-click to clear. Esc cancels capture.") + hint:SetTextColor(TEXT_DIM[1], TEXT_DIM[2], TEXT_DIM[3], 1) + hint:SetPoint("BOTTOM", p, "BOTTOM", 0, 22) + hint:SetWidth(WIZ_W - 100) + hint:SetJustifyH("CENTER") + ApplyInter(hint, "regular") + + p.OnEnter = function() + if kbWaitingFor then StopKeybindCapture() end + -- First-time default: if the player hasn't bound the map + -- search yet, suggest CTRL-M. Doesn't override existing + -- bindings or other actions already on that combo. + if not GetBindingKey(MAP_ACTION) then + local ownerOfCtrlM = GetBindingAction and GetBindingAction("CTRL-M") + if not ownerOfCtrlM or ownerOfCtrlM == "" then + SetBinding("CTRL-M", MAP_ACTION) + SaveBindings(GetCurrentBindingSet()) + end + end + for i = 1, #kbWidgets do RefreshKbWidget(kbWidgets[i]) end + end + + return p +end + +local function BulletRow(parent, header, body) + local row = CreateFrame("Frame", nil, parent) + row:SetSize(WIZ_W - 110, 1) + + local dot = row:CreateTexture(nil, "OVERLAY") + dot:SetSize(6, 6) + dot:SetTexture(DOT_FILLED) + dot:SetVertexColor(GOLD[1], GOLD[2], GOLD[3], 1) + dot:SetPoint("TOPLEFT", row, "TOPLEFT", 0, -6) + + local h = row:CreateFontString(nil, "OVERLAY", "GameFontNormal") + h:SetText(header) + h:SetTextColor(GOLD[1], GOLD[2], GOLD[3], 1) + h:SetPoint("TOPLEFT", dot, "TOPRIGHT", 10, 6) + ApplyInter(h, "semibold") + + local b = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + b:SetText(body) + b:SetTextColor(TEXT_BODY[1], TEXT_BODY[2], TEXT_BODY[3], 1) + b:SetJustifyH("LEFT") + b:SetJustifyV("TOP") + b:SetSpacing(2) + b:SetPoint("TOPLEFT", h, "BOTTOMLEFT", 0, -2) + b:SetPoint("RIGHT", row, "RIGHT", -4, 0) + ApplyInter(b, "regular") + + row:SetHeight(h:GetStringHeight() + b:GetStringHeight() + 10) + row.header = h + row.body = b + return row +end + +local function BuildPage4(parent) + local p = MakePage(parent) + + local title = HeaderText(p, "You're all set", "GameFontNormalLarge") + title:SetPoint("TOP", p, "TOP", 0, -28) + + local sub = BodyText(p, "A few last things to know.") + sub:SetPoint("TOP", title, "BOTTOM", 0, -6) + + local rows = {} + + local r1 = BulletRow(p, + "Open it", + "Press your hotkey to open the search bar from anywhere. Click outside (or pick a result) and it tucks away.") + r1:SetPoint("TOPLEFT", p, "TOPLEFT", 50, -84) + rows[1] = r1 + + local r2 = BulletRow(p, + "Move it", + "Drag the bar anywhere on the screen. Lock it from Options if you don't want it moving.") + r2:SetPoint("TOPLEFT", r1, "BOTTOMLEFT", 0, -10) + + local r3 = BulletRow(p, + "Reset its spot", + "Type :reset in the search bar to send it back to the top of the screen, or use Reset Positions in Options.") + r3:SetPoint("TOPLEFT", r2, "BOTTOMLEFT", 0, -10) + + local r4 = BulletRow(p, + "Tweak everything", + "Type /ef to open Options: themes, indicator style, hotkeys, filters, map pins, and more.") + r4:SetPoint("TOPLEFT", r3, "BOTTOMLEFT", 0, -10) + + p.OnEnter = function() + local current = GetBindingKey(TOGGLE_ACTION) + local bindLabel = current + and ("|cffFFD100" .. current .. "|r") + or "|cffff6e6e(no key bound; set one in Options)|r" + rows[1].body:SetText( + "Press " .. bindLabel .. " to open the search bar from anywhere. Click outside (or pick a result) and it tucks away.") + rows[1]:SetHeight(rows[1].header:GetStringHeight() + rows[1].body:GetStringHeight() + 10) + end + + return p +end + +local BANNER_H = 38 + +local function CreateFrameOnce() + if frame then return frame end + + local f = CreateFrame("Frame", "EasyFindWizard", UIParent) + f:SetSize(WIZ_W, WIZ_H) + -- Shrink the wizard's overall footprint without resizing internal + -- layout (fonts, buttons, anchors all stay the same pixel sizes). + f:SetScale(0.88) + f:SetPoint("CENTER") + f:SetFrameStrata("FULLSCREEN_DIALOG") + f:SetFrameLevel(220) + f:SetMovable(true) + f:EnableMouse(true) + f:SetClampedToScreen(true) + f:RegisterForDrag("LeftButton") + f:SetScript("OnDragStart", f.StartMoving) + f:SetScript("OnDragStop", f.StopMovingOrSizing) + frame = f + + -- Same rounded rect 9-slice the search bar / results panel use. + -- Pin a small bar height so the corner radius is fixed at ~10px + -- instead of half the frame height. Tint the fill dark gray + -- (not pure black) so it reads as a panel rather than a void. + ns.CreateRoundedRectBorder(f) + ns.SetRoundedRectBarHeight(f, 20) + ns.SetRoundedRectBorderBgAlpha(f, PANEL_BG_ALPHA) + -- Hide the bright border ring; its corner cells render as visible + -- horizontal bands against the gradient fill, breaking the smooth + -- top-to-bottom gloss. The fill silhouette is enough. + if f.combinedBorder and f.combinedBorder.border then + for _, t in pairs(f.combinedBorder.border) do + t:Hide() + end + end + if f.combinedBorder and f.combinedBorder.fill then + for _, t in pairs(f.combinedBorder.fill) do + t:SetVertexColor(0.04, 0.04, 0.05, 1) + end + end + + -- Glossy sheen: one continuous vertical gradient mapped across the + -- 9-slice. Each cell receives gradient stops sampled from its + -- vertical position in the frame (top corners get the top of the + -- ramp, middle row spans the bulk of the gradient, bottom corners + -- get the bottom). The result reads as one smooth top-to-bottom + -- gloss across the whole panel; the texture's alpha handles the + -- rounded silhouette so nothing pokes past the corners. + local function ApplyGloss(self) + local fill = self.combinedBorder and self.combinedBorder.fill + if not fill then return end + local H = self:GetHeight() + if not H or H <= 0 then return end + local corner = (self.cbBarHeight or 32) / 2 + + -- Visible banding from 8-bit color comes from too few color + -- steps spread across too many pixels. To make bands + -- effectively invisible we PACK a wide brightness range into + -- the transition zone -- many 8-bit color values per pixel + -- means each band is only a couple pixels tall. Smoothstep + -- on top so the slope varies and bands can't space evenly. + local DARK_FRAC = 0.90 -- top 90% pure dark base; transition concentrated in bottom 10% + local function smoothstep(t) + if t <= 0 then return 0 end + if t >= 1 then return 1 end + return t * t * (3 - 2 * t) + end + local function lerp(a, b, t) return a + (b - a) * t end + local function colorAtY(y) + local t = y / H + if t < DARK_FRAC then t = 0 + else t = smoothstep((t - DARK_FRAC) / (1 - DARK_FRAC)) end + return lerp(0.022, 0.20, t), -- r + lerp(0.022, 0.20, t), -- g + lerp(0.030, 0.22, t) -- b + end + + -- Relaxed pixel snapping lets the GPU sub-pixel-blend the + -- vertex colors instead of hard-aligning each color stop to + -- the nearest pixel row, which softens the transitions. + for _, cell in pairs(fill) do + if cell.SetSnapToPixelGrid then cell:SetSnapToPixelGrid(false) end + if cell.SetTexelSnappingBias then cell:SetTexelSnappingBias(0) end + end + + local function ramp(cell, yTop, yBot) + if not cell then return end + local r1, g1, b1 = colorAtY(yTop) + local r2, g2, b2 = colorAtY(yBot) + -- VERTICAL gradient: first color = bottom, second = top. + cell:SetGradient("VERTICAL", + CreateColor(r2, g2, b2, 1), + CreateColor(r1, g1, b1, 1)) + end + + local yTopRowTop, yTopRowBot = 0, corner + local yMidRowTop, yMidRowBot = corner, H - corner + local yBotRowTop, yBotRowBot = H - corner, H + + ramp(fill.tl, yTopRowTop, yTopRowBot) + ramp(fill.tm, yTopRowTop, yTopRowBot) + ramp(fill.tr, yTopRowTop, yTopRowBot) + ramp(fill.ml, yMidRowTop, yMidRowBot) + ramp(fill.mm, yMidRowTop, yMidRowBot) + ramp(fill.mr, yMidRowTop, yMidRowBot) + ramp(fill.bl, yBotRowTop, yBotRowBot) + ramp(fill.bm, yBotRowTop, yBotRowBot) + ramp(fill.br, yBotRowTop, yBotRowBot) + end + ApplyGloss(f) + f:HookScript("OnSizeChanged", ApplyGloss) + + -- Crisp X built from two rotated 1px lines so it stays sharp at + -- any UI scale (no font hinting, no texture filtering blur). + local closeBtn = CreateFrame("Button", nil, f) + closeBtn:SetSize(18, 18) + closeBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -10, -10) + local function MakeStroke() + local t = closeBtn:CreateTexture(nil, "OVERLAY") + t:SetTexture("Interface\\Buttons\\WHITE8x8") + t:SetSize(16, 1.5) + t:SetPoint("CENTER") + return t + end + local stroke1 = MakeStroke(); stroke1:SetRotation(math.pi / 4) + local stroke2 = MakeStroke(); stroke2:SetRotation(-math.pi / 4) + local function setX(r, g, b) + stroke1:SetVertexColor(r, g, b, 1) + stroke2:SetVertexColor(r, g, b, 1) + end + setX(TEXT_DIM[1], TEXT_DIM[2], TEXT_DIM[3]) + closeBtn:SetScript("OnEnter", function() setX(1, 1, 1) end) + closeBtn:SetScript("OnLeave", function() setX(TEXT_DIM[1], TEXT_DIM[2], TEXT_DIM[3]) end) + closeBtn:SetScript("OnClick", function() FinishWizard(false) end) + + local pageHost = CreateFrame("Frame", nil, f) + pageHost:SetPoint("TOPLEFT", f, "TOPLEFT", 8, -8) + -- Leave room for the inset banner: BANNER_INSET (6) + BANNER_H (38) + -- + a small gap (4) so page content doesn't kiss the banner top. + pageHost:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -8, 6 + BANNER_H + 4) + + pages = { + BuildPage1(pageHost), + BuildPage2(pageHost), + BuildPage3(pageHost), + BuildPage4(pageHost), + } + + -- Footer banner: a self-contained rounded panel floating inside + -- the main window with the same corner radius as the wizard + -- frame, inset a few pixels on every edge so it reads as its own + -- element. Slightly darker than the gradient base but not pitch + -- black -- enough contrast to anchor the back/continue cluster. + local BANNER_INSET = 6 + local footer = CreateFrame("Frame", nil, f) + footer:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", BANNER_INSET, BANNER_INSET) + footer:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -BANNER_INSET, BANNER_INSET) + footer:SetHeight(BANNER_H) + ns.CreateRoundedRectBorder(footer) + ns.SetRoundedRectBarHeight(footer, 20) + ns.SetRoundedRectBorderBgAlpha(footer, 1) + if footer.combinedBorder and footer.combinedBorder.fill then + for _, t in pairs(footer.combinedBorder.fill) do + t:SetVertexColor(0.075, 0.075, 0.085, 1) + if t.SetSnapToPixelGrid then t:SetSnapToPixelGrid(false) end + if t.SetTexelSnappingBias then t:SetTexelSnappingBias(0) end + end + end + if footer.combinedBorder and footer.combinedBorder.border then + for _, t in pairs(footer.combinedBorder.border) do t:Hide() end + end + + -- Page indicator dots: vertically centered in the banner, anchored + -- to the LEFT so they stay flush left. + local DOT_GAP = 14 + local DOT_SZ = 9 + local DOT_ACTIVE = 11 + -- Dots and buttons live INSIDE the banner so they render above + -- the banner's rounded fill instead of being hidden behind it. + for i = 1, #pages do + local d = footer:CreateTexture(nil, "OVERLAY") + d:SetSize(DOT_SZ, DOT_SZ) + d:SetTexture(DOT_FILLED) + d:SetPoint("LEFT", footer, "LEFT", 14 + DOT_ACTIVE / 2 + (i - 1) * (DOT_SZ + DOT_GAP), 0) + dots[i] = d + end + + -- Right cluster: [Back] [Continue]. Continue is the only action + -- button (rounded fill, white text); Back stays as a quiet ghost + -- link. To skip, the user clicks the X in the top-right. + nextBtn = MakeButton(footer, "Continue", "rounded", 96) + nextBtn:SetPoint("RIGHT", footer, "RIGHT", -10, 0) + + backBtn = MakeButton(footer, "Back", "ghost", 50) + backBtn:SetPoint("RIGHT", nextBtn, "LEFT", -6, 0) + backBtn:SetScript("OnClick", function() ShowPage(pageIdx - 1) end) + + nextBtn:SetScript("OnClick", function() + if pageIdx >= #pages then + FinishWizard(true) + else + ShowPage(pageIdx + 1) + end + end) + + f:SetScript("OnKeyDown", function(self, key) + if kbWaitingFor then + self:SetPropagateKeyboardInput(true) + return + end + if key == "ESCAPE" then + self:SetPropagateKeyboardInput(false) + FinishWizard(false) + else + self:SetPropagateKeyboardInput(true) + end + end) + + return f +end + +function Wizard:IsShown() + return frame and frame:IsShown() +end + +function Wizard:Show() + CreateFrameOnce() + if ns.UI and ns.UI.Hide then ns.UI:Hide() end + pageIdx = 1 + frame:Show() + SafeCallMethod(frame, "EnableKeyboard", true) + SafeCallMethod(frame, "SetPropagateKeyboardInput", true) + ShowPage(1) +end + +function Wizard:Hide() + if frame then + SafeCallMethod(frame, "EnableKeyboard", false) + frame:Hide() + end +end diff --git a/textures/Spyglass.tga b/textures/Spyglass.tga new file mode 100644 index 0000000000000000000000000000000000000000..8c55d8b2060481b6fd1b1fe0f7341c0736d62f82 GIT binary patch literal 262188 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 literal 0 HcmV?d00001 diff --git a/textures/SpyglassMinimap.tga b/textures/SpyglassMinimap.tga new file mode 100644 index 0000000000000000000000000000000000000000..df1ada2411b9b92b0bb7f549cedc2f1ae0965248 GIT binary patch literal 262188 zcmeEv2Ygi3y8RGBdheN;G+HPLB$R~Sd+)tN=)Dtq@4a^fq=|G85tJez3Id7+_1g8? zu6k93lzsMptv$1Ihg|f&`~JT7E^^NA_vOr-IWw8rGi!b8D|;V{#m$np6|fYD_sYBX zzjLDi{tc}l%pF$9X}ry2e0#rVJV%>;4qwmQ%n>k0;6Hl=h*M%aKlaH18q4{yud%N8 zJddWI92^{o<68S`?7RAVh3}A%5L?lrMVl2bUc6CMR8%QTU|<8%n>k0;Qwv}i1Pwgt2Hz!DXDUqGG&?tI&jQ)M4Y?gUi*Fe z@qolqrCKy>*l;y$ZMACEGE0{(-OSI=FAR^lV*XLsFGP+3XT*U+0&u>$nImA1!2jI{ z_yYU&6B84w*zI;3-o~SFuX#Z0`uJ)!YAwp>Kj6;5Aw%Bp(W}?ZMopS*j*g8jhv(DB z)4yw9ulM@7yzfa4@b&eL2n`MO$9;1%N5C9`|Gg1VIX?K{@Umsg)=EuHZGnHy7@nKP zI`>K+IQQfQFKbv>@#N&>e(l<~zc_Zn#GfWjp7QJHapQjJ-lNAmX=!Pryn=&cah@NH z`d(w7dsqDH<38YlSeKXsrvSR zw-pz7d3nWDu3B|$hfbX?4;nJ$`w5dKXJt;AnlmwTa?Y?3BYx@7x%2DQt5=_?@&W&d z12pdSUSprJfhXe)<`S4oh;*9vSqu%@1Gqxdh{<7GAHLwnle>Po-tEQnK?^LoIE9W_{fpjUAlF9r+ix4P`q9w zjGBV4ud&a)#=qY4m^b(+!C6bdU2`)>z#M@;cm#-NOQ}+&VuAfml`2*0QldnOBpl*# z`hJaf*F8C*pv`70R7Vc$*6%*=(~7nxJ1iOJJv7*k;6g7Fh4iJ`-X z|JtEr$Llp})R-O@7ng$PGZ)~>0UGxY?HP-Z1DF$Vz8CDw%^U%91pcTIV6F|=Z&RT{ zg%-}d8!`C1e&5S0I2Pyk>DjC2g^8Jy?oF9CEq5Ax{#5#X2lnNZ88c}HOdua1MvfYt z-LqG(4^bDGjMtBXF&CgY;33@OxW3QFeZ#}Ui#Y2DxNC0a2$&=AM~ndK^a;q%b*@^q z>HyU3OF3Wj_x!#)Y68ji>(`&uqi4@^W5$g6+l*PWa%aw(C1ybHPY2$?0UG;ikNd`C zXaeK|7z<=|>fHHO^_tbE7A{=40{)*bj2PE=&%alXMMOkIMn^}NU`_z%GBz+ba|Fy0 z`2QOL&x#c*wnn_)09d5HrY8SAzmHU(R}6A_1BVS8_R8$pbMDQVJ2wX=X3d^0X3m;v zfCD6SKz{s7r3t3ZaOi;1W3v19>-Ryu9M@KZ)nx;sBi+pjPk?L!ALyfZD;_ z%n>k0;NKqs74H`-R?LR_UK{BC4D|WLIA7*b*jKSW9``{nPsPSfn~uro-~aTiS+jmv zzI;XQqD70vy!rEmlLI8QfSm5+0P+B}fQuPZzyZ(!V$|5NqHDMA?^Ul^b0+!=)19@x zM{xl0uQ{L)u^$r?Q#v*_HX6sN70k^X0doZYy%F$WUmoi9^{D^xROaJ<5BuK8!53}Z zxbf&onVDCXEM5BdRjXF!tX{QBEM2lx%wMp8_?OTCa^|er-~bpnfTp=Xa{zRJoIG`g zK%OvnV2!V2 z$&z)^)7udv!Jd9@zlnY31K9)Iv}x0kBSwxqzH0UAzir#TU2NRAQLJ0LRxDq>Tr60y z(3m@Ko}4{rP96u42Q(Kb4w%W9UP3J;7el*=MYK zgj})D+w}L;2p-G_AZAHMPJn$yj9bjj9079#9z6m|^Wz~e^z_t%@2?I0OutV&Ju26i zr}^2l)3Ia6O!)Up>(;OT=Bb@Kvv=;=CAMtYD%P)CFP5)ZX)Id2SRw`h{;2`T0T0_u z=mN$CQx*QFD-KXPVA^yeb1HPem@zqh`el6Gv{|z~g$oz2>U>{17SOoYd(8o?38+{A z^}%>NM}J1#G&geu%n@)M0r-9IgoK2&B1IBw;W#nG?Kk~C{BSV+ev>9m22Y+m`RMLF zd%it%=uqx62M>z9d-sX$J9dZ-8#hYm0f~4(&R@7t&Y4RM@DC2)ZFL{IfLdTWG{98& ze_}sR2cR~PIc1ta&!Caff8gJ0*R8vP^97tbz?B1BwSeXT57Y$1p#zefIYFLpZsrJ> zBj7p$adBSgxv2!4R738JF*AMTZ(<*h`5{I~@71f<%*BfrpLy!3r#?P*?AZO|$Bv0Z zhYyPb2M!2m0AtJ6t#a+Ub#leZm2%ONB}xO31GN9w_@9m30F638aRBQBng?)u^0ev5 z2~5i!J$76!bih~5o44ExjnV+`!*#xpEB>_>Al`jLFh41wM2X_gSb%!Q+{_U$N5C-x zZg#}U(EH_a_fcA(T%h^DopXNC|5vF^n>IriEn0Nq`0?XkojG&n{@JrW3?4G{hXxHAZ3wekQ-d8j3gVoY=lj>DIe@tWPpmT#%>D)ETt%&K z@Q}HgBk<&o0PAYVl~-aPt#cH9%kL9Uf#~V0Ie6&MIV)GKIti`+!Amc_bnpE6^SS5F zofFRk|HqCW6VDzxWbE6&U+&zsTOto2kryza0gS~X<~#Qz+clLMxy zd?0*3@K0lXV9K8G>C3BEul{oF+O_PLUw&DD z2LyD0IC0{HKpwz&=9y>Y(|h*F9Zx+aHf`Q4)}R&u4Iq#U6wm+5IT(}?*1Bg?nPKhH&j);TLJ}aOB#LnHj1nYsw1q$Q>Q4d@p*b_8= z{sPnl=PT{6{Xg$Jjv+^goFMxIRIi}U1!x^GV+Q6A&OlBuQ}jcB0P_LpQEcbz|9AaN z8vj}wkRLq5&>O`1hI8H^A2T;|1fIkZAXc;y@2qv%ztgZJ#i_YEVi z`5E$afYv*HtO=NFiD87y?MWR0jeYJPiTzr&YBd}((8Epa1-mPd<6@$)}&@ zz{FoZ{7`@k#I4)6l?FHu4e&f-fa8b(4pRf57Pud^0K@@;almHw1gziS)B(%IV$~bC z&|q%>4FZBL`?6&>WzT zQ3trQcL^N8xPhPD+{_Vp(njF7@K3x1(C-f#G-$^D{rg`=oc}re{V$(=_F4AlpMRbU z6W{{H0dK$kwzzTQhIsk%Wr5g0oO4G!3u#{sKXuR(9n3W44L z)gK5gpz4Fa#{p`-06Ac4o(@oRhrtch0Xd^F*RX5XuE){4*aq*P>k4bUKMMaU7Qi{w z0~`l)Ge_V_8v*(~%>ml~yRjyKIlr|#cIh$}TKzb*{g-#&d++{-AAXn(%;$az-wz&8 z`2XnRj|CnRj00YM^;MMzIP<~_;^fJbN&`HLJOJZ>y?Gq)l`86PQOH$jbx#qhCntfPcydU@ZZ}2x1y-#*EzIBZhy`s#U9}P^ahw{wj>mM|^7@ z;9mKD=P};akDD3*gJEt@)CjoZpFBW6>CN?b`t|ES2etbPCr_RF1i1d`%{Sl7g4WM@ z|NZv`Vt?_`M<0ogfqmKsxc|;O?}(c>Z;C5duc}&r>IHE215pEz1NQBA=m7K!iLJ;7 zY}~j>tX;cKtX#QDEL(V5kB4?g%nKoclFcn|ma*!Anz zRUGg_zBu6Ev*3V(4i4C}=V`HX=T5P0+jg;O(`Lm1t5!K`151z-SO6^m4sgyJo@eMj z0p{(&WRHNxKe6wTkdRP@b9tbnkHSCSh426I+>0;XM{N%^{>!;nuD&9!z51HCftufK z=>50eeoMT2_pZ2m_dW3*jJ$CB_AQ+As$ecq#R04b9zDvQ0MrBz3iJgCtfmlGynWH$s0R9c&pL$`^l_^&5VJ(qp@`7=3Z&z{SD@ulX;gfT|1ZKY)2cdld(8uF&Sq zTf}M#UP) zH|EToIqTEz-Mb$|O@KXudQD++fY$unyZiX~unx#Lft$v^$pMb>F#rB}M*uN@NI;3? zg_{cqr7B6{Y`%_PSzh~dR`v;ymn2q^7x$NmbdGZu$`_IFtpA|2n*YCo` zONjMfLGAy#xOwZAxbx;6@fNU8{NF}BkjF1yzM{@$O_2FO_64yo;92AYr~^1pNaY20 zKP?4{3G8tRrLd7En%z|!kA$l z_+#hp-HL0T-m^Cc>y3QTty_;HxL!dAe1*JT5c2>U`;K`5&;-D~Jt!!O>x^+@9>kRc zOde3fV*dFPj)1q#mQbcagT@)-#!Yx;<*L>1t>3iy+pXJo{QA_cUD>-(v)c=;4juq* z539L7Cr+Far_tl{0($*kI)7eVdik=rcI{R1`s+7=`!|pSxT7?|&6~Fr2e2kczHsde zP#OTSK>m1O=Pu*~&@0G(;q@CfIJLlPXn_@~S75lj-g^WE1;xe2Mke4fV%@|) z2Ep9^)FYsL8t3}fu3fuJpFVw;&ze2=>iUhFzTUKD>(5)ZZp+@beTUeA9NjMX<~^9t zf8f9~;@M{p!G}BW#Xi3ms0ChpNnE`2vbc&K;Op0KPzxBy6B^V2i3 zjvYI$dXK>)Ip`_+cEG@a7jVsjzW6qIogkg(BM&ehpm||lQ1MvD+Q!7bHja_2;jX!v zBk-pf0fm2D)1wspBDVa&Oa?Eji!-u@%0UJv*)(aPZ)>z%Mx9 z_(}BnoK}AQ+_@LUh4UAf7cj2hxFNv-68S+%J;1&o#Q~Qt3FH#h96`((ewYKCdBFqB z2{`)%*fUHHVqSo~0&CZ-Q@Ozf$nRmEpz`@xQv~tCW~EPvedrR&JOlHJSa%?2;i5%< zM^Dgg^e~Rbx5hPr+3QCRc*wBkQE9Aakc#8XgK}*0YXBTFH**C3G$Ww!k9lyhty{Jp zIef&Z{j=xKe`nR&wLh+1zu{N#K+dMkTaicKrZBi;$5RSZd-m-|Kd(av96o$h96NSG zAU_C9pJ6;;T)22qLI=p#u3wkv1yCG-y^+TO`E)@3oZvHvGpIAzFUX$ZU5MrRzqh*d z3b1F8^?{Yh4=`82IYy^WJ!d@k+;b9r#|GyekvlNoC@em4 z$N@CoR)+c@C_r7yFhB0K|HrB3W{$w0Z3N=5-d~AwO`0?tHD>(y^GlYm_#5))zakHx zwPDldT=whdaR7C|(|ZItK;;7v3s46fg)c{)P-*+K=guh(xN_x+eD$^0BJQW$!1V&i0h}+)`GQOyAbgBX;Ge(aYdjt9QpVzG4@L&Vt0CK=4#Nb*7 zJf-sit{iaqh&Tcap8%Ghb8-Ote^CE3$N{%*-6sAG&Jkdb@bw$l1=bExIRma40PT)^ z!oxWM)(BLu5IKNa0DhhK$uoQo=Nog~f%C{a(D$=O$@t+J$1mhkc+6WZH`aUULx^!JVb=r*g7cE)( z6KZN%ngf&$*tT8e1D>J|KrFBq{+6)-wY$;*!0>V4lN@mN>^aN@x(J-Bx_|)=KySbu z)g$bv4LH^fxrBZqt{b3h1JnYn4=6o=7=txJa=~H52&eGcTxaAJ)D*bp0JQ}DKl+r= zUkLr6&Oe0b9)VWajrzd62R(ZB{jz-d^u_ox8UHixrv@P2iF=KIu6;)Q^D(UhaMRq( z5qKg-K*hP73y)e^qjnuSt{gjV{F}>Gto&u|`isRfAP%jglHhNoYrFNcrM1rOvNK5{ts z(BVV5$4?wbt?>nM5nRFZZ(xoAat!J@r%{7A47}4Ee*Ylq7EfXBz@pr4J^KAxF0I=8 z5mEMO_!fB$GoAlqtWORg?rFSDT|oTP__(P9FbL-M#EpP2<^Wac(zWYGtikokij}K= zSqB|}I6!g0X4C|>fCILY1E2$TvOfTMy8TWLcouQI$_F48P#oaw4`7cF@vi0%VXolY zu(zB%fSRGw@>o-lF?}x95zRq-p99=0d*SR0;tDw8I(+`k*I!pPMe>Ep9UupJ2!5ab z|HzSJ%J0u#v{VcjJUq8TrJ4`i3I%hkc)Ch1*tQs|HwCUcf*Y*h$Gv8r8VEx9;*}5h`4nU8f*p6JlQ`7;fF7OWyp#CNY zur|m!K;ptiK%f?&<_o-ob%Qu}h&=En;sPG0rbiD!E;N1)@`yQz3v!r8xN_w( zFn?Y7eU&%BG3o^7{~dM|{X+*(E8Ko>5z*ew+=@G<86xyJBe=nU2ikAQ2AA>YS%f6B0Q>yfPR++Ea4Kfnx!SIf?$9e4H!QUmyw22C(tFgJ4qo}>{dfWEkx^eR<4cI(mOY0SC( z0(0{oV2)5W<_YAgJODHR^MTuU{38}n901?UIYWn>I)M3o_Wdx|$6O$>uGbRcIwD*@ zgzE<|Kftwwc%P4B%|SJv7;}hnj~_pgOU$zlc=RazK57NH#)w$DVm0FZks>v{wg?C- zA_}^DN@#a!@hv4SwyM&SP)}NlHkFpbjf5qxuCxTDe1;NWSA3DgIQ_k=&nG9i8aV;= zLQ@A|9L(*>8v!@;2Uu&?snct~puxv6@9y)Z%b^3n0UNh$$z@*v`fN}abm#!$kvf3A zI0sOR*Rj9}%pYbhfHgs`4?^G0wS$TA{PrQ%6ymyKJWf79+>nbN!yMH2a#f9wuM2)* zkI$lIE5zX8V@1PeZADN-QTY8p`29fm{Wxift_<8al9uAlr6sX7td%TSyoD%04hTv4 z(&7<64qqqhfWOB9)CSc5&;kC5rVhYJnA?*!0&cOziWLS2^y}5T_W@j^^K-1zk+pHN z(gCVBNO1sl0M_=?9I$tvg9FsuVbtiKd;YYV3(WOHnDggc;diiR$h%lOr@TNJPdZ2{#XK zLt)>iq!arMrKKcrU!o1L-%h%fY%je_caXj%+K9l!7SbcC>OFA41bmtF_gV|M`hMOf zH|RV8bUe%OZ}ek8v5;tTQBS6`_;*A|CH$US%N#heo- zPUf%|fPFyB_wPeb;PO>##mI4)qG5}6!Y?8bem?}7-$z;s#r(tXmue*~WjaYqa%X8t z>LNYMbd&xm-DODG?lQV;7ZD0w;1OQ_TZ>oR7<`k|0mQq`3%K%t)(3nHb%B7Q*z=gV znIrHdjX**41|&9W(qvS>{{2r)n?B=9%(1)A9zpg8ZN@x36%Vj3fPBDCiH%{SuPZ@(2>U!1W4A7>qqYYm_$K=t{t=Wx}! zjbhO7@uEh9)*?2syl^Yz2h9)sdli?KNaXma`Afs+CwGvRl&;cJzNd6g>nr`z`pJ;A z3>i_Ox3r~plh&m6Mr2}B>1V6_i^VH`3cg6ZehzB@dhy6jE&x5|+ zT=e$kpf4!rBIUs*lz$|5A3JF=a=gy3smSO3svbaeX9?V!PN)Lh$@4m zH9bSdROl&VQ@hI8l+K2=R14`9QT`i?yL|}0NY{LTEB5tmVxF}C!~&j-1;`2JW{$v< zGy;XNUPkHW&6`gcJb1`CT&MT1%U7*_uzusl9P|h&4%h+?$Xi1Qb9JBIvoDwH38E&L zjq3wtV@;r(TbK{T`rnsd()WL>_Pnkb>jCeu7J$cJN1ou~rHhzXa7t|0vO^3TGg&lf z(ODEJoh}NvhoHV63G5e^mWT?#KH~k-(EMdPN=t?AvS5|I(zC`u>0f7<45>R(M${N4 zt-!xMy`PM!*jvVy>#jJ!mfX$=E7n+gMOXgO;!~8p!t4jqv4FP}`%{|MIVY6@P^>=g+#|dpLggF7tx#fCt_Zm$1Ir?tKTv)H#bq z-R50{kF~5QP$&$3-%VQFV;!2mNPUOjPleyF&{Ya2jHLzu2iQ})NNd^7BBEp)=^mN>ol^&JUI6oaG>v`kiGOX>0_Yv~ zFNVDt3t$k;?FkwIyWKk}DX9Y1*O)PU#K^0-UiY`lR;;*>H9E3!op9y^s0T2IcuN-g zh3;eC;Qb>t*`6<4!plD-hc0|_{#^Z{oTRbp=)CQ!6PDb)*{ii zONJ;`wwkaMjCT5cX#Vi>4$WVxCHy|({qoTKm3zVO4^Y?-X);zuHXJQ&bw|mVYC~ik z;{Vu+eS!Txhyi*_d&OST3Lc0o*F%OTcQSm?Cr}`&^7j_+Vzcl~vj3O3rsc=Jiw@v5 zhR6YCpCHD`+@7ovaL4sF(_6M~J$uOT;g{zwSnwTmz(XDjVyAseWH-fBO4dm>0y}|C=|_7xd;E@c-|M5C8IkT2J)&sprMQ<^QU%_1=4#;4`AP?-f$V*WW0=MHc?s|jFvGq zhRV3g17$+_zB0Z%eSaTdzb|qD{bU66Kq$B%FtvvaD$_*-lxznMsPcow%{~-gCD#g| z2GBk~|DKO=?f~=xYlCJ^5MyC(PtXWNM@Rc$jqS><+qRuEV)W>1GiJ^Dnrm+^L!Z#H zAnq8e**bIp_T3Ub=KK2lM%IU%7fk&GAv|1%eL_9X%x$ zEMG5r4;(EL%heSYAN27Th?16qR`m2bv0oZ~KLs_u3h?`BJ)~!~4CxK*2Q(fd!-4q)b=gO&t)C{3A91 ze3Fc6JWkr{!S`1k41eEG`Tc})@cn81r5(p2Yd|B^8YKg2j*@}ZhRHzW27)Vq1IqT0 z!O5M4f2p?8&06j67WbHu_hctoa0oDb~IYTBOp12W+jEwXxpPpX7RjW1& z1`QeV-1O-)-d?qO%~!Y<&rj(6&%%1(xm*Jbem@uU0J1U1|5scK=-#1c4?Z|@_)rel z5j}D8wAlXi0WoL!2GM=sSP@sQfv^N8A=ihwJZ|v)d43;x{&HO$@jm^2RrvinLu5ds z(K58fBpC_(+v+=Xe{A{y$WgKVJIP8z%$F0ab^}V4NF* z_#h;;rwmQ$W_YuAI3n#!i&x?4F1h~S%Lni=)DW2uFk^uy4g}^0_DQW+u}16m?dMF) z%zSb8?x(+o<_Gp~=c3P-*9FPJ{Qd`6^Y0$k^!ge2|K-@xBUwj|9M0ME%%R-%+n*Mr zrY#l?x(pSGRazLHA&Ihpn_U+0Cs`|YnCa48c&je4JXLJI%8yDjgc}K zu|Nnp0QrHClpZoLsiW}4ya6|B^`9}9FcV)dbpSEWJiu?|1X&LR2dLU0&owu51fG}? z;CiB5M=T>FWBK~^8*ZY1|9f!2&sekT9{Tux+`n(%SG#xa{PL+CJHEzs|Gvjs-uJdY zwJU4Mn$0<5XD$^@yABhfsZD_Qa`5|+i20G{4+i!VYN5u5c)vXSej4ig>B#dV-uJ0B z2!4N*3~7}q!&^<3(e(YO`NdSD?g#d%`QiWVsQpLR0rr^}Y&}hewwW%2n@^EJO)_O* z0|y5LfdfLI1457=2r1ti96%k=UHE_l96BIwGH|We64gB5$^|^eJOPb5fcXI4HaBww zo|qBf8baym>9zXy&6quN=F9^N7cM-teEIToixw?9J7>XW!tJ7&nZqdpug*(^la8HEhhu`NXv;byxlYz)q0AwHbUIbT0bxxpNc*o==m7x{yHOMR5Qc|ZKlcaji6R4xkRu98i-SKpg-MfDQ;kED(^?$?#5WDcx)}?peHx&cheU{s4`2>Hw_; zcuXHhzQ7ynj(Q*#2qj0Dn>hkc(g=9r+Pjq+H*VacRjXEm+P7~%qD#k)W4m_kIzmT_fhD{mYJ4#;nX#a0|L+^R4@WP zLLRn>P8~qs{#*W^aR7S>IbVR+A0S7Vn>hkc%m{Ez;o`-MC!jV^vSP)Gr7KjZfXj`f zmO*@whU@axtx=~=s+)+}?;N z-dOruEB$2gF0ue$B(SaUPoGa7(7vC%z~THbpc)ppge#U{<@XIsk;c&di21pGM^Y#0hJHPt8iQpJ^8De5_lf(+Zu4YRr`gil zW(s^f;{K}W@rCA(E7x1bBj*=eceJ#(nksFb=Sget>BxF-g^2JvM(7_sO#^o~sX*5Ym*i}#KGzr2`V<6rxJ z;-5J~#tVoA{7PVNasURw+#dT8P}dFkgFc1JYlU@2+U&0udo|#`xv-RKhc!Il`;)uM zLY4Z-fZCYP!yI0lsqo*kWfbsl?Jxtrf18S*Z=2xGwO3()5i_j*t5ukp`5Le>PZc7SgZPN6n1H**9Y z=Mf-gU2yyV{ZaJm`C2?<=Uc3mvMr@r8Swjtr4)L5v2KSqa`-{$^Z7i>RMhB7CBx6YXAm+&d+W%|c5A3V!4RKBZasfUm2o-p)xtSyIIFEqF>>u?RoP2^cHiAkYvc%Vc z{%<8LB|FQ4DSf0*rNJ_=_9z+J1a*GY@FIa#Yo}S#)-qGZ(Dzq?_J{6|LF{iw&%dql z1ZnLEeE^?t1NQCxH>$lAIv^UcKxB`FDt?FrheUP-2Y?620TGB7A{wE8xYh{t3J*}Z z#CX&LV^#mJqKqO*;P#SxLP#X+FudrW9 z_6k@33G0rcKS0eJr0&<+pRqsvKYhOw{}$u}3zdcrDCZIj@N?^Yh{*vM40C(zMu5ND zKjODb9AJy6WC<*H-ICBux|QxGJir0IRYoBHKNk7_NirNge9?^i>yN@5AN2aM-v@p= z20B3b{tk1bwbu%19SH0X+yeZ=`foxEunsZ6DjD5#v5e}m0Pz8g9MEO1w6>oi?Z_`U z_@uuq3@s6l*R^B5P!w_jVeAt|PB0j~g{n`mTrU|~wi|Q+dWFL)evP)jxzGW;-WX$k z+N1E#IYkMuGT?!-*fVd)94L*S{f`(!yo|Y-Bk;dI0)NzR*U16)8kT_aH!X2ZWPzlf z!n@)y=~Hc#^sO^ihMuQ?3!`ZA!;9XM@n!&|#8LfpRw*xwA>Dq~>O z0QQXa2J->dUdw>}MaT!jdd`=2)DmORKOBoWgK?NM6bIiQkJpUB+#tpR;ph_%VXttl z(at$ToG*YmL+BL_Vy|!s^a|Um-$RQaVgXA$jDFvl`&0fOH30g4^%(L1>biu)e>{vF zz-tZ`f{_RQsBy$gnVUHR|7RmW9P{_=4&!gz!+9UOf4Mm^5MAFASmCavaBJySrjICC zaj^8NiXI*G=|t8Xfxdq9`zSw+oIiYj4CemW+Rp-}mq|N(y~cj*z-=;y8X#kXVeh+E zSP=tQkq5N)S_rO~EA8kXjKy=~&_ASlhwz#)@cTCWkI07OWf(L^2 zfgs&0tmX`LSMvod;c4Gl3L%H-@a^mopi%qN?-T!pVdx9AAO@fYP&~lL7&CA!ay>>S z2VgMF?XeyK;-A>}z}lgnadC0l$Ng7-+YTL2Fs_j$DD7=ad^6MndkW7q)B!6GlaZCt z+lM*5toKs`*pUaYA$Mm*&5wD0_<9%oLjw^1%mrkuGpzlf0eV9Npbo$qqP@{r@W&v; z5#SN@4#rf*T;X~nr4{puB9LzgclHVflLI(^5d7odfB}xV!^k~A2N*uZTS&L)>c3jN zimb!uE)LUizt;U+L!2=J@_-i90u%>ut^glryucoT{{#-m{~i6S`*_>`txdnK-v8E% z{Hw=*>v`t!$9n{v-@C1eC9wRP)B)~g`-$Lk{bf`|V6;56I&*Z$%W<8*Xw>|zoe|F? z#)q$0_@@SlRWShZj~IY?!2W9;{XmTS(L1c>4REfo%J-p$P!n9zVxo*feK7*H2KEbw zBCZH&nkj=hZxHptK+G2itb+N6YVI&&0n8aH+uiU-uQ1jS`8(DoMxUU21q?qip8%up zC)UviV4?Oe4?`YMaRIr49Kg7N96;XWXaC>+-RuAP@9gn5^WN*f^Ss|W_xE0pkD1$( zGXnZIf=?XNz!F^PrX``d3@FuEL<9T8zpDGeUq>T$=ltF%)cvB7`(v(8;T?Y8;roGo zjsFbv0-zVrin&8}tP{xCA+9Xe1?3t+STEe(cr5aP=o{`lTlE^Lc|({t7}{boH~_uE zs6*s&0Ac~f0mv1yE)b0M1bs1QsZey4`{)tcfj6EGBfhEo*#}77Pu!=$_!xPB$9N9= ziC7!-hUr{@9s}+AnJxBsa3;`%@JYq}nL)NOs9xqmW7fWHB3_J*hnyy;!I zDc0`oA?(op?D@5!1`ttuB-Z{ye?WWG@wy|nXZ{a$f2H|}dm8hB1K|Iu|GO=ecGeK! z>$x^)LYW?l2gnDuHmC!lHz2wP`UKe{fPO>H7hoI^)&jl4t~x-)0+=s=xo>sH(s7>7@i5LKe=(nt-*}vl^Rw}M>KyVMU&Hlt;_gGA`M2J)K975I z`*V)~e*@TJ>K6#9_=Z2`2t=0dAZ&=et;o}dVV(bw2IFLS%jv)?;`$!TC31bp10oLq z&F|m>@Bw-NY{>oDF%Q^|oL@|({>tyG^#L(g*oJt3y+y=+6!G7U{Xx(H9nc?yoD_dwW|0 z0|Vp3!orGS4TuuBUR-IL%~m!xHntqBJnmPp+wB!;STnL5_DNAuQ6(`)tY}0;MB(7z z;20ktpD66fd7dzGg60C;d}!0}T6A+2sfJj&TgA*cATL^DM9l%(?p#y@D6ZA)2fE*Bl*NDVC1Us%T5Lc$7j4jjN zuqCyYKDH`Z7I)iLyz%C+Ixu3M*stxpUmbhu0Okb?!=hjTF#1aECtY#zd%yqukNqCr zf9o;+PyTOm5U+{i=e(``I&&24b!Oc{^*Ip3uxJ`Nfw-o5d3n)daXS{r?L5!d*EiB> zx=(_y!`I{g;s2%afAK#5zt#p7F7wY7_{qaKpc7Gpyg; zRrppwpJ3GyGNA4_^zgGsa5mz1*7q<606bvpjb32%1KYbVmiD&OWejuvsPV<3znHoI z81((wTcdY?eL=l2SD4mwAz}$?0Q3hUSI9U(aX?#e0Ot&`E`T{hoHrO!XS9q!F2Ks` zjNrNgoHOK{GhhTIHZ=-FRl@ZKi=Kds8-O?85LOpf&$+LKeH9orfX)LD6B-Zni<$?t z7I+kPIX?N1YoGn7=jq4DEqr}Htl?s#e@;k9s93C6u^MH|mTiE`Kh~y?$K&+T+K5-) z=KfKp&vV_A(_D=_;%XZ2zCl4jaroc0F@L-f_O(isC{Y>jsSMs*oKq|OD?Y?ao79_ef%oWcX|9qbz{{`cDuCI}w3-}&re$WRI_}`^*&AHUVg$pMm zb|}Gk0Wk*S4Za37f*uck7X8_9)7+kb5pZw-dxe84T(cCxS^?kyw+aK%BQyeQdqM{^ z#u`D;>Jc3o!y}&uA8y0^;aIE{#GJpX_p^7PK6(ie+uORq_xD9_kYlc}fgWOE?X}Wj zJ(h~7?hC;I&;wjQ1nUQclLvtRNX|Dx{xJqQz&PXp^>v1#aJ|7$tRWJNxkG^|Jq?c% znAaGQe&6B||0*Ci3*S)(SUnhH0oDVE|5C6bF!l^m9}`!^8GR#h%HKSVxYg!*jJLIh z)_XpW7C>zdTo;Rvk1t!gbm^+No?8vzyC@#x{~#WYiQ&D)*67ZhkoCBuQAWPx7BJbo|cwYr+oSHwRlY`@&(qTjt4*RwaJP6jcCJ7 zbNiEx068Duw7n*Jgl<^kFeeYa!d~bV_Ny`yYX*!_^98u(C+GP`x5HXNm_y9@!tv-C z;97xdtpKbKXhlwdeZh9r2J9Kg8!~s;cb%~J+aT{0Wt=C19tEO`vFvbK+P9My@35g85<3I|1H9vu^Aiy z9_YOq@c`xzcgOr;;GgRdS({Bz*BOgP?mq!I=kL# zNJ@9ZE3t)vwT|us+K2J>`{FYY|H-h@utZp480!G8_}BO#K3$ENAs+cSjToe#C2#0C zNW=q;ug5))(^vC6o=+_DxqKas@%($&|LMvRJWu~`z1Od&?{lw@Y3|6s=j;3)_Q?UR zx<~6AKF9UFx!(6jKEsaqp%^reo$J%$TvHcd5dLJFBNkwtlu>&No@ePZ0zT>Zb&0C=3dp!JXI zd*tmv#t4WDDx?2Ag*6C2KehG(Ily&u{C-@In71Fd5paHoQ8l3hUbYkl#?UM5S!t;B zt%0>efWs*IdSIUOdgFju_6%FmGpKroq5ExEFW?{jLhKiYDg1*2GN=PK80;-3{;jAX z*m1o9#t-rE>%>2=H)=&LAp$*uAuZ4&fHeg3*TG&BlC zSK&2HKZXVvh{0x$kmi75xJ?aE97fC%n;I{~82LbBUgMs4Ca$%wkH&3co_PdU<8hwD z^Z8uu|M|N5f8@uJKF&R{>iYk~sJEF{fZ24tkNJBVV+-zyJ=OtP1LV2PGdgpMu3V%) z=c9N)V_)Ypxz`+^@z3Y!_pWdAJ@d0EjzNAPfq4SN3UP=Z$gMn&F#>--u7{|SG1 zO#6L}{rtXP>wx_J-_`FEC;ESA{1I2K*a{@Zfg$229Wi%Z@BA6hwhI@|1phgki_8nV$rRpIMx$npAhB@g|(yxz??zg z-w$&X+_8qB1#<)~vACWYeSc`GVeu_#SPI1cil65ujBzU%bU?vofnl)=l2fakPfD${ z#?LRLB#!g1957(Oi6KLVyfA$D@QWiy zj=T)J3cE65#E477h7EgZ@ZiB`1`Zr}64!z_jOXm>)Tz^ETqk0AqehLUgBONko%puo z7S{Tx6Y&4}*@#bS4ssuH?aBfAw&n|0%~F@uQ7x*2&o66u!fLhEkV=C*lKdkAZhU}h3lNf!v6>40Q0xu^Y?lM#-E5Unf_+=y3N1o z(xdN>ox1k8Tdi7+>A+qY{$B9~5Cb%7W!@*AUH!f*2e{&%I3i#0Im9-f&(|UD1Ni&L zJnd5GwW*40I5mc!?$EGd!(P0GOUI5K#{sKz2Mrpudeo>2`e^jiX`9kfP4`6>0u2In(+*XCPQD_8?mCg~E_{U&8 zhMSWEY)vs|=ru3&2-(VZ7wUSTz-%<|A6XAQdss^>vMuU(UC=Aoa|LRCoG(E9!|!9= z5c{C**u(!r8*om64RJkl2)wpfJg+Z`n8J#AM3I;;NbIvVsCol1cbMxH2R9fgeXC{Q zy2YKPr6hcR0935d-S+by?XVD?5)SS+CS5G(yEX9H`=(8sb{g1x8JNAbb?esmckSBs`QE*Izj@}FXMQ}0Yq&o9?6da| z9XgbC`0(NEBS((p96NR__xSPSxhGDX$TM8;IhS_o)G1}S25c_v^y$-TkJ~x0Y}|hU zyN7ds!fSko|MA(*ojc!MzkdCVrAwDy0M{JFd)nH)d-r+J3WE?gGz4cA$7kSY;+T*N z=-*v&PYi2&6c>;ye4sTV5epP%jexh=YvimU@^PITz)f>|j7GqPx6vycT>iB{^a@8| z-OecF;UX)eJ`Y{aJUw-~nm5=5{eivEBh2{%S_5$XK-^Y6#ONioqt~CT+Cm%Knx!_@`nx`I-IRksp?Sn<>5P^AJ^KQ zzkgzzJixuat?$#<^PE8XI&er4)cn)fR||jI34V1beC&Ma_O0-{^y?RJoyJ@6&wtsp zY13bMt=4_}_7Uq3fJ5efxS3$=8UozUU)&AJ$v>cJ9qA!c=5#-^X#RU zUJ~cepBK0uw77WjqPTSFk}_Nyp4W$`<-Gjz%Q<*X7GC>T{Lh~b960bDxo5+M4evrT zT$?p(*6DHM#_d83u^8H9MAN2CyW+Dn!)L0D++I=iGlg)y8N~P+^O^%5wdXlJPMq_$ z7vlls2%4bhxRO(6kXKk|cKQ_DGPlQN1e`ozPb(0da?z)71I*9uE+W$h$&hNp)HT3^ z5Tmn~KZ5Iqb|nX3j*yx+$oa!C_5vYCU~i5!0x@SO4r_gIjUXFhertUspKABa7H z^!vCTSxCbX(zj+m>6VU|zbtwai#I~t58WS`CM{lxzMci_6rz}SyZKmGL2!23P;_xr%S z^6f{D9?c>4;n#EM$KkI9P2)d52apF`xquv?dB7F_nghrKG~hnhY2<)xa6>lE$p&9! z<9S(ljR$z$d-%UUgNLXQK7lT|4SjHS(xgc{p<8Al-`EZDK?Qtn@}R~wvHe@7IYD!U z2lYSlkxAf?65z~mZ~(`X9AIvb=Lk4CAgZDzw8AUCMO(-qtRLWqd4oRHxvnt#1ShKN z0Y)(w;N$?chA6Kef}Ecn{1C&wVfcL3`(n^T$ljkQfod*zi^1nt_jYvR>cUlrG{Ul%uS+{m*lSFYqhZ`=nzeFy%! z3vKWc@(0`d_wPR)dC8uLnd%_6PXU%=ncH*ff#2f9CkISY91zhS*B9%u06c)40M-+; zwV46zV|`H07i7;c`+ORVc3_{^4T0tlLvL{i_#mkMaOqd0uPjs%@qQWjeOxb0VLu#x z-?KQd&v?Ip^7~#sR#CNXJEPaY@pAgyC354I?Q-*$t-$_zxpKvFIe*?fF=N^c0lbRw zUHb zZ~g$9p1A)Jy82hf@AU8R?Lr&5z?Bbv3;WvdYk#kOetzr|_xZ8!ihtr>V;`9QC+utd zzxLW|iU*urkb`|TxFQSu@c{htGv3d4_za)nbG;32yD)d|+}%hj z-|rLeG~%D;)B(A#zy5m8&6_uKZr!@2?Dp;3IW#=}0RQvv$V+~PoWWI$+YZz(#vw=0 zh}WUU@NkY0wG(lkUk~uU)&Y4j0ge@9EP%W~ea=aA_70Ih%_Ukbpfb~7X(Z3Vbc$~DMXINcZ9C<_CGaSu6ANc-otRWcE9M=wq z=J&1MSGrg1;>h(WzmFcDuypkJG2RaZ_8opdB)q6-(7dZ0GJ2|9zGjo$_sk)A;F*K+ z!2W%5$M$V<&Fa+#>SBWUM-E<0ojR4+S9{<;8^7fb@mpR2e^ebq&zN8Nee~GHK=U^3 z+O_LsXoWq<-Csfd?E`4zZ};!t{}b@=0GQ7L=Bf9YqtC;Be(kU0dt%-d_w@PN?=$bG zeLwSlu72O?^9^8L!nil!^OXVj5@u-p6Zenu`>vRG#lCAC;EI3E1K@=$aL9eU{?B;t z-$CPiw0-;b*HE84fxO{{Zr!?#N3Ej?djK$YuD)OE0nGt=PafcHKju!E8$?`H3irt$ z=JxoFfKvxpD?L-nJ%>#Pxst3rmjJJsW zf?SKl`Fv9gU_c(D%|!=Px`=Cy2}_yoq7c>*^TV2+LA7xW@LI_4L-(tmVb=T*|J&Fz zfWDrvmgwzoiFJS*jgVfzzFV5B-^cu3<@bvN`_394^8EI=B+;~GcR6~(Oz8dH^7yIK z^7%7oteKZ38%7Av4)2B~2P@5B|edj>;e+CV^88zyb zs82PhTD58i^v8@rU)_3~cLH_0TZr$!L~i^i;GH!)Fe;I!lfb70_LKpOQX8--^P7GQ&oN*s$49I`%9sPh z>*nV7{Z8!Xyz#~x%HDkQ&1@R(XX7;=;62{Q`~MNW3ZFm^yn?aa2R@yPTwx#d|5Rb^ zAHQGi^SO7$zvckf;|19_g?eK}XI<9R1CJ2_;H^4!Ky-EV3cqYAf@}Ge&oI0(XDF!Z za2bI)12*RS*yE4>{TQq<8ihGOp{V%&a;l!p#IE0B{D_+5$(8#XEqK%ble zUu7Vtotu%7A)r}*g1yZZ)n^*HNNuk^zZcR+NaZp({~f|#5=LBaZX$VZ}QHaJIdaA>n%xp z`|Y=tz4OjHQky=;JR;32#_`9bpn_r$y}Vu`ZO{w!(;b9;P7z?l!m z^#;Q#zhWucQWk^`2&^~|Yk9)=t6m?>8{qsNoP<9%5St6+ ztp)1u0{&Z|2B2bq2@@vJ?+eVG$$`KB9zNH0E9Q#Jt2g66Z=hM`|tas^RJDX*@>Xj~HK_$Mg9-KA(oy{Y~Tlas6M2 z9n@=REx{Op8UmVMX$Nu%@lP($+yTxoaN7V!2pku{x4=09cz(!mJQv4vAA$debOHWH zmQxRW53TerG}m*ek*q;(U^r@}4G~+#;U`S3PyA~R(76Kc^=K#vY|b?ko&Cc& z#oQi~5pe1N%o{{Lpg?>R5t!QBh^mNOKd%Q04zM8(hy?ya>Y%a_u1p}wHw#vTX)`+uf2LrUP6q_ z{Mm^UCk5hk0SpWHaDm^WK>bS9{d@K5C7}23qn7^zYW`oMNBtA{{BMyT_zC^7jP0|5 zMPi<&dUJraJZ{IJIDmOsGZw%gJVu+7 z18kKnA!#ppphqY?xrTrCfj5;ifByM1@YSaUFd)!- zCy=`n=y4UO^W|a=PBv=SzrgqZfZIQy=k+J#^?yYlzUswe&K>$(<>Be`e-Hb748GbR zj$b(Iz&y1*eY&e(CnwNf0}m3K1U1Rsax3~0;3MVIWy|C|<|L4loG@{s95s5hM2%5m zPM^ekljt>&;4ImrM-OGtE_t_k9G^)dM=4R4m(VW~eGSZQN@#G!4eWh@o>BaQ{DtBi z>H~5S;|yS1=>q5g195|R_uY2|w1MDl%?Vl?XfB{80PeNrae$Kxvarv>Ia!Pe@Y%lv zr(HqJwI1(%7-Fv4m=}c*-%cKI)dBhOPpwc0c>u0k+zNGqI?N3?fB(+k)?I&k`jZupV6LIWoIs^3$O+UPj8Ui$i2n~h_`vw=v(F^$lTSXOHZaHs zj1gQpfVZ_dc_5DiaGbYQy#SiyKJppAAlCUFJozztCN6-Z7NJhi2Km-le6Q32ngg^Z z;C=dfY5;OX5PL51-Bp8DtBw2Q1#^3RM!?C{A!!9dQeF%w(%1-3?kN1rb}>B5c9R8g zT@MSc?WHt7u^$QS`>@6r1HWHT6m<6%VNoSSrK(NDgemjno@b8BiSF@$!)V^T zg{)b#mP|=4Ckq!TDkGw!rI)WCKCk6}#E{pNz?y{Sk*H@XE}&+BCYN*O%uyNyc~a&~ zsGn4=fOuzq0NAH7zesJM^Z_5E0pocbKrX=jJPvTZjdL{@WMQnoffl=jo`Kbv2i+6v zrIz8`B7AmTCnWZ1yiX1w)>ZD1HNkS_$~6S9RmF2S51V{p>HrMFW3V~Dr>Kerg33H! z$X?@t8`tbi>R?z(u%{RCejIXrQI+BMO9Kbtz`hUszPkttEh?I{>LJEu&KEm(ACQ-? zTvc_jH{W2*>z2~6H&7o#?5VUY^H<~nU^@p|_#X0l-$Ltuih9)dnA`R%etX%_#JS81 z(5Dms^!>oL^6wh&u6w8NH<+KJzNdfJ#{0w${rsj)8yTM)Q?&1jMPE5mlsDk%MtP{_!JdmFQ0-=Wz@xE(CqWF!-KaZWq9Or9#5P!^iOR3zS%5UxE{4NNAW03yT0&Bb7x) zM#;$NC>dpgS*VV&R}av`4~$9V=? zWGw!7S!e&Riuv*W^XmW|3veIIenGCa?yL)tH_Ywv7y;&Zu!R@FI$fL4$Nz&R9Q8fb z*GKGI;rG$ohaR7hs8mtAQD-q=*hH~<-4^5MiIb8#`^uG&r-^+)81K7{=_ryE%=&ad6 zC&)vk1sI5f;aE*r)@E!;q>tc-|= z29~YR=QbGyi;M=oq1BZ|VXqCxqOCX=_~$v1I35udCBq}g5#cfvJP;BZDuciU0sj8d z*V|iZ19zt`aI?5cT0yshN*~}0Q2gMk7yR%%<{L1lPiX?^CW)9u&Oy#%3u+P=AJu0_ zj{We%4-M83KKl4$<_uK6P~%_g0o;G29$-8GzJ7ohjdLeHXMF%;xDxR|XJ>xzQ96KH zfY>KL_(8uUfdlGe&5}k=-oRlq7T_Q}2AhuA&?6c%$WpNA1xullKfv!}%`X3(0&bDH zg?vkhvXz>N0mC!Jl9d~cgU=oY_Rc|jpGCgSQCDaE3_4$Fe*7j4>VD+1a?!i<3vyl` zBVTkKIkQ*M+xs1~W-jXh?70Ju)t-8vKHe4g^!LO)Vtq-U4~|g2o?4&Srv||5$W^OW z8kv(O8Qr^gHR{x=bynTE{SVV-d$Hp31AXkDDWMX28JS$qX zxGY?_D69zbcSU4ud|??Ammuviaj+PP7+0bXS>obC(rUFy8!Vds-)X!}PQdRUd=QzZ z3DD1=IDy(AG$cd@2L{O?=nGn4K%n%8PVn*elIVGQq(&eoFrJ`MXVj@%Pt_q%BazG# za2%ljB{)ES!WtrY;O=|xVW{F#&P!YC0#tIm5xwc6Fg07(eiQ{ky_VbNde;0sUR!yY(-Q`TKpaoaQO; z!Pk*-m2xnLSM=;RQp{bnLTuZy3-kC6qM!emJaPP(WDSehB>v$;4ED3)Hz|;B%SBG_ zSNQ!e;Dg_Xmi+>Cu^*9t%O>`TZTft20QElaYy5K$Gw9oiacX=1&iOy6@tG%Qz8pMa zAb%jbck3paG;S=?Dpn9B5)%cc3kdjh5fTz6Lc+oX{I>z_F%l9A%VNbGxGzznB=Yj5 zoK{ltK(S(p$k`W@MUV$155(hq#0)YPIeq5)$pN$&=iVNJ`!v`4c5nmDW_NG}^nn$e z5DiXVM`0&`Y4S0D1`&7XaTnFYr%$Y6Efrv_dxOkepli18SnT zFfU^la-<`{-CU1_dA~<-fYt%tTvG%(pfNb00mh5#7jo0TEyq3c?|_< z{_fso-$<*`COc!u1TlNTGO>00cI2WCsChj5_U^@gUtVtuFv>Y_YQ8J-Rk>In_hF=D<(*4n70N z9>e+qy`W!;;XQCImfzw4@R*qFlLDfi_kFEC?@8 zFX8X&FCq}%)6dhtCjsxQyVFw2roeDJ8CIsOER$ztaNB9g*e54BEGbDQCzZx)z)F__ zXOxoggKE#)Nu}`mrAs(03Hv<5aXtnsU9yBMnOIzwC|*n^7Dq2YF~l836J)U>@v;bc zBR&Sb1ke+RDQwIsK;K6p&xp?;!Ee$Z*Emx%fxe=NZBh<_&sC@p|kAnVGNEB7$&AIzCEXFoV*EdEa={BLUg z-{SyJa3oqMN;Seh#kmdvo?>o~!3a1tfM?Fj&SK9G`+tdhYJSH0^z%;K z8`vB0{R-zgmY~0<-)GE^UO>rtf~Zf)TD5AzzgtCca1dgAF9Gc=poxtlg^L=D=S!9> zt$aK3(F)fO<3B~FqRyr)HC2M2fSpw2*ivQ1^5tY&h4S+MvG*3xlAcxC_6Ni|(&nrl1PcTW8ZJoH|u?x~txM?|a`{p6=bcq#j{@-!-fox81t(ef*yLyWfzyg>~T;jMrw*xe$Lmw44%g#b+NSIA8|~WC7u32Xd;^}*yjf}vXJ|r> z(MVsQmViG{J5V>EPdC-Irax94f z#V^zdPUhw@2~U&ey<+r>@aE9 zzJ+8OyEhS7N^s z_hQ}0XnxPUK%z%2p(8}kEwzKxxbd~@7%S>_f%g)*|kA z5`SQ^3z)s3Yw7`xdvx!XdiLy*dSQKf^-O*H^i2Kx_D%!)^+^N!V*~nz4I0od4I0=F zpVL2V5VzXkL0Z2wcwpbttav=n=XD13?UnlV>6QBS=5O@sk$U&)o_h7*?}v}TH*}Gg z=o9#Nw{4x;;tyK2pl^`>kJ^7DHHWLw53Dz;DWVxJ@xp+kp7|1qCY z)C|>S(E)-7kQrSm(un+NAp9)vA>r=Yum8pGzDl+Hu5aA;=eM&f$k`fu>g|(5N67W{Dq?@t6%DSax(1D{2|0E%^?Z75TcPQH$N1owTW}WX)68Kzo1rdXKQTTaxIiWMKa(Gzj`I?^z>9FP-=op| zBR=Jm)Ns~uc9A`Q#{0|zGXA|!A0Q9F1zd71_`i}oz&-6*U*g-^um6SbK%LHAyDuI; zX&QSa*FJmj#(n>C_|X2}?cKBck9+p)eqsN<{U?Y4C2CLM1MH=Bf7iTu^PVT>{|DOd zzdFMM4|pC;I&yt*9dTbwXUuym?#=5n_SN<@`9FOD%-|eL^a&*T1fzad&VQ>o%cJTl zxN(z4jlqW8xjA}$f&bRvza7`TIece0cUO45*y=<474ydPA%h2`VS)Fdf%S1IpI;pT}QzoWqQzwN@E#FS(cKXzbX~xtEX~vYorgJ+Dn~F`* zCXI{Nn>=Amnlye4uRkhHz{ZWmpWtIgj~JRp3>zFiVK5wHK>yyUZ(liyIY$@r2J{Mi z*!B2=wyn_*;00<1jT>D{ZNa_-dJV3qk4{i%1l9()o=!4*4AlnKty{wk zaEZuk&3UKV#pT zzUJ?p$#d3r$lxKV&bf7rW#;--XVZtT9wygs6!kP~YT%#zxH5gBU(*`jj*Wyw3%*^TG9kc{9_(`Lola1#{A3 zZQ-1>WZ_(FUSW&)9$UH?TQWB-<8~Q8YfH<&Tg>eujpr^}Fgq<=FpJmpbqiZCcV_sE zx%}PP_?sEp69X@nm8iK|!us`}iA9DmbfprS&5Ur>sG^5to zh?*k3if9A%=_ybHw03CUG4I<+=4=AoEb;lpyfb(XAD|!5oF}T}0F~HB2h2N(PS79V zxh2n2^Z5tnIUYn~8K~CB->c*S;ymL&>j7#4es7*o>jQWb{vUo-Vqa}GoA{4B&c3fM zT{?`Vj(5xAMTA55Bqi>6Q&78Qo zx*zed9e7YjQ+Mx;rYF}QGH5{L-D8RO;!1qU&u0*WjmyUPdBpPt^Jb+*U_P)8ZkH{d zmzFP`pH?he5VmsJ!nAVPqO@u`wgOwZD6I}#%x&3LaEq-f+e#j{a=Fjbc%7v+wrt6K zdZbAdi@@VqyWi+_n(@t+Z&{}uh`?};TpU{>vi_<(;z|NE1> z{jBG!_2sxN zYr=Ib=eKs&j#^sBj_sq~)~9z5a6-%{zaKqvcp4A4o=m)-M!cU12cHe@=gygy=FbE7 zV9yvY7MCvu?_gBSisLn_mZY_-mxirhvn;J&yDV+c)-O*Rt8E3}Yb%S}bt}>aZLPP< zxvjBy4PIyM>Set4QvAk}@F)5h{mo)HhP)$u!K~?#hfF6=m@;`RoQ3>g%y9g`kTi72 zfY1s0^zIgVp*2N&5L&ftmYOwz3zU5TeUJEnOH!RW7chr-vMyksv6_Ios=JEZ(e%R~ z{2=;=?I9)?aE7DW2jti<_Tdb9Z|MolRe2dVwM2b_KpPVgCi{t6sG z?EjwnU+mll+kt;$y4qfj`C{K#BlhL`#(nQYBaGUf^RCXh&@&owzwQNfL-UjKi}~DN zdu_yhbU*6lt?8?2*S;+}AGtnySzob_j;5w&jvq0f{NCK&{M&q9jxQfK*303SiFsoC z8t}LlT&`aOzQOP&Fui5tstGQgulHQzq2SUU$P*4(W3ctk~zX$G=y0*C#UJt zCcs_r1NgEr=!PTFF$NFnOYNX%)Do>BniI5X)dC;TB>ZDT`iSU%rcQV%xxo2wfZRV= z+?pq_Up@GiJIy{oeg?i{p3vSweSkcm68op%-}6dd!hgGe`Cr+C_A#`d&BTg6Vw~$n z%vW0W3zy(0decuignvUD9^>^^`}IHn4&?Zs`TnVV&7Z};zQM;_00!IB*RzzEe=Bn# z4>HU9Ni@O#pvHG1_x9LtV!oeopKtXAa{fHG=Pf!x?8ZW8jM|=a^L5ExuWEQz4d%iA zRm3!F;9|c?BelNf^!Bx+r?*|q%v;Ov+oxC5<3|n$`{d6P$@A6m&8^L`&F{_a7cZP$ z@cU)+i&zi#jLqV4^M(TN+ra9Mt?SdS?HkkXotx60U7OS1-J8??JzLU^d$*<=_iao2 z_iju3_GnwNEj6~A?|JM#o}=x>e7>*a>+R%qw}(Gijc-|nFMvP571r@@&KWjAkbMiCO2W0%q)h?8` zk)JO?^M9CJ;5*EVdp2dR^F@3=Q{4C3GVa^Yr%zxPNW^`vm(Zr|bMtz-X%@Eo$gP(Dz5dkH?Q40pA}(ZEiy7?sI2P4Nk2Nw-laj zE^h9=4sO35+-(B$#^&wda_6>=#?urnRHac4SwaA!J#9X+(W z+KwFD#eJB+$9&wO13S{e1KZQV{TRRw zL>{r8d|@ql$7-~L$P1RB5iBY+0&5L(W=svuatfTrI^vj7!_Wu^M6IBA@9vTRsDHLa z2bCW+qE0A(vVV|%3Hk=>ga*LpSnUJOr>@eL8VfpZ>>%^A1K)vvd4RP->H+$JO735c z|EvYz^M6Ymcn(eYP3&@;Q118f+@{O{yspH)XOd|b^Y3a|t_jxW0lXpq!(Wy7mxEQ} z-_Om@%lpK?enJl5<1R<;kDPH3AQ0#btLY01^d0hzWsGW#XfyD8 zIRyWPZJWsp%3MI*V4XR^Dzrqjh!yCN<|+EG`EzEbxihEn|C>Z_!uY}mjK&8H8%W;M z7u~c+a$8J;(u@edbVD`zyF>+$;^#=O?Nu4;V% zZ^Hl3SLXgX{`>x`)yUWUJoM9=pIvAG+@7UoOTR}K_~KS-zu!W)^ZbpHoL`KKd1Je` z`T?<@as3pGQ4jU0vK8N6oM0_&L^_yYJo!Kd;6-G5nC&1@A}pm9Y-a zZ@Mwve)A#hU}64Tw%fSX7u=$65dZoLEPTKbI0F7duAnbDd?TD-KmH|nz!vxgImOP6 zQTR^=+-)8MnUpJor_vlg73d0BVDSQBR z!cLv8#|N}2`vCd`u3|Sy_<(vbXUrbQG6yL10DXY7P38jDVC~5u=0~59eZ$lRV_4I41@p(;Yr^SEn(+A}1&FjzWvi9r0{~f5rzxbBt zXZ&aE`?-jJeM9&F`bk@W+jY$LeUSdVe@EksIzPCM{Tw;h7ylXi_=f1`Rqu1hcGmgb zi)#-%`=X+*C+4k9iF;>QuEiR2U-PC-QcJLJJnM+o-Ie-Y*7!%DgN+?MoF1Cd;9&x| zn3Cp4jz6b}_u?Kb$*;}1cM)^6?QAP znm_9UGWNxN#D9E6MeHx)zu4EG6#2qo@_|E<3y@Fj^~qEpjK&*k(!1%j=7G!C;NcG1MM4v2ZRrh2QbTgopa0N0_Z69 zsiR)PUbFMhW7j}MT|ge-43Rz7Xu8SWB7TnM0P+AifIa{Xpeo0HZ>a;uemAgxf*AwP zWcoc?z|Y_jPxkHGcOutLUG(FOf3GPqUuot6EqKgz+;2Sc+N|{hyb1q}Um5?E+F!=M zpOL1&_CE1n$KEaK#^b2{KSs^>XY_u&NQ?;ld!1;Nv0we)7-D_Tyx-q>J_daPu>;## zed~PI^v@Lc8{-SqvFv}huP18zX#BnD>2dC61U>ck-m8n-w>NM0wBX3r)~&s-L&rDX ztG$bRwY!60@o;c^;~kCO-^YUaTgd@#K6XmHC)OX+ zLK{#MP%q$bp#vNu?jPK@H60ZDd(;t&*uQ(nhO{gAKeYv7zqN+VWi8P7Ux|Nv2v(p4 z$Y&OpIlx?c3ujKBNU!m@@PED+qe2rz4?q*_qaHxtV8@Q_Vy4I*0sZS$>?x~{Cg=_# zdn&~}pKp3qxjvu<<4mNV6Zu{2P8a*`9R3^5DlrcL_u&JG`PKLb`<`!n0-pB*T<}lK zX@4J0VIR8A_2qhi*OS*(r3MgwfIP1Qx>7HG=k;bDQ0oJD6aE{&{ww_ZS?RO=JnC5g zqkpt7_2NV5ecxdY?0NWoj{SlC>;p3IR}c6CI{}}0=CiSn)1F?=_j4DAv%Ar+%IgN# z&vD<_U-a}f2K&wEZ)?d+9Q8eB_Iq@@A^KtM;~hm0ue^6kneUtD%a7Ietl3*%6ZgA! zYzm!T4bQy%X7L-C2h+xMF@IZ)XedhRL{+45ViS>Kn_;3dJec(MfemT~c9@RfqLThEvt$B1@<@DGeDk_vA+hLGWb8W3gdq@ z{`vpTv$uE#GYR$$1_v+?7)~BAIQ(P3e!bWwOkIFnDKtT5iJXyWOwZJn%o1MC?xBk> zKD|#+e}J!#eJP$_LLYbdadQA`0&)QGugUuZ|6;#vC+I7-Cg^^re}N~ypBmAIa$kVB z&#^_^I|~#>ENINUk~IS#ll7)r4#1o7-}t4UJyZP8*q8eUCt!}gEBV|eINgWYsr?(` zy0d)t`ULM6`{G`!squd@c2j=h6Hmw733_1_GkhiDOUapCwLN@(;9u?^S|9f{Vb|Vu z?A~*Z&m9}q_IuFF>ztf@@oM~$>vP@Do;4-e!(+_156^y^t!QcH)#~j9?!oOb^xf>btmAC4E&wTcosPp^%qm*{#`Pm${IHgJz40Ba z-w4jLiTC}xH&XlGfX=@mFu!{TxCie$x2~b?w>o0}w$0QXH_>yjkvwIcy@gBB{TGw- zi~WUQAKWjWNA6GUf;oewi|9|LZ)p*Ii}U9?o0ReIoZ(nz3z-{03$aIV7@DX#(f~Ms zv&HsCbzyGcdisT1*&~FeaxF6h4VW9Mcj+Z@j=`&6Q|uh%^DgoMcz~aa`xc1fk-s@F zP@L`d{m=o#e#XDp*NFd`HK7;S-SAuZ;y0-W?1fX9-y8RH4j}fe4~TvD1ZsY-U+~(i z^#QyI|6N~YO(xg(%<c`x%}*^O_NPT3k9>dqnx#?8+r52L)b9%1?@zZA&&ByYcOAoy7j|cXfBk{jH=j4oXHI|g zP?5VIF|MQ89Wrm1zheh0Fn%LkUOsPJ-&e-^USj*+o$Jz`9poJm-}Ofs@8}S4{H>c- zV#|s5g>70-EpqLWv_9j0s;l!R6`VdKXiJJs&OL z^nD`ofbrwU$9blHzVd&0fIR_O`zz=E#{R%RT5kz^iCXVV?1cJPxZk7n1rFzWYQptp z?AOG9#=d(5GX9+*@b_Ma^3&R{zwSE_+6uVy{muP-;=j@d=nF2O9@L4?ekHvBgVgPQ zE9d7L@ciBCTEDSBYyIZ@>U~@XG4FHht+$n!87R@$8#}m}=P5P3LcAOEon0;VTp5d+ zHDiX4*yO%FvEPgO-T-I!h7KgJ9Txq3#(j0Z#q`9iB1T#(SJ$^+=kS4DaP)n|b8&wp z-F@fD*!O4eSL0L9vzC98^?EhC(CXyvg+?#7_rurs?c9*|!q<0$>0Myj7`_Wk?<9s7 zah$pW{Cyj7TwHJ6xGZhiK)qo-8bsI7p3~Q%aJ>!Sj<1m!#O59iCU+mihDF4rSfV@Di;PqMi_1Av~GWXZN%Cmf*vk%C(^_cIO zPM^+O(Sd)W-f!F|uZx^N<6i6=7sS5ThIM}Re){{WxMl+TXuyI0i!QvVWG~OPS99IL z0o>u?-b?5B+_l?|Jr|wXdEuU(0{era-+dx8@-w*Bt?OF@a|UlCTDf(6^ZX+RcZ25} zgXiB%tbgs@H-+7McM&+X; zF#^+z%a|6+*s6tac6$uw6T9buWehB@g14^(%PW`8riTYxaTNd%Z zSIk59?7{A{ZtOkm?7R>=j?Dw`x!2%(*`t&$WlrG23yS>#Tni^@0rc{Pc8D*JeQat0 z;-C2ddxEOu{inqKILnFa;RM&l^YjJ&g8gkz6HC_c+Q#`x{EL0}f@BVm{ee6{K3e}ULYfA$0C!4DeEojZ32^I>17p8E%9dcnGyUv+PfxYz9QG4696M6aLc z?y-K4I_;wremgo@asQSzvZ1bN|#H7V>XfFddv~@RRu( z`oz2`Y0=y%aQ2gJ(L8>~?|sbTdBx-Xx!SmI(fsL!@%;iGKY#92xDPW-#Qj+_m|>pg z-jcDz{?TwFcM7qyj6R@I!v`gMqQw8;GXDE858aFRwR?AWow`Sanjke*cM+?(T+Kd} z2ApqZKZg5)&U6lmwOZ!A61yKm3n17`6Z_pPsDgUfiQ2X`Qdk0=kt-UcXC-;{Fi2ZD0-|x@0)(`jZ&MuGFKmPdR zzhu5EX8XW?;6KNF_&{JkG(Y-#ye6FYz4g{xs+KHST%v9e=kJjBsrOU&W4|?fD%@r1 ze%r?A{`T{`ud53+zFy4n4CvPf&R<~vq__{~w{KtU??m4}Ko5_6-m`--gvban?Z9H33>7QFv8a{~4T=>zNwsPqB3N5J}l=M=N!IXIp@ z!twz0o~kp&f4HFMJiG)qe33r!-!L2SUgt>BftqlgsrAeA#k@Ws^MAi@QZ{`=Z5@y| z;ji@d8uYiTsk@oqpNjwN2kM~JbVRdT!XB@W;Jg1wjE{Z2ne$i1ef#^2{qlbKKfV1` zYu2tQF(U}(uaftlxa5+{O3tFh&bo#T$p0Hs=f8$JU(?ue*@^tJXU6^zIsb^*i#0uS z{))IS_MOW)X09*x(e&;E@5X$s!2a>j`SlIf0FG0`J4#QEvpDK?yNUPPw=nAm?l+Q~ zn}@H4uglTJyD@yxe6T*3USjY#cNVoqFe{E{PNmmy%BaF7k4)1ijY!k5sS`(}DHDp@ zsS}6uGd5v(+*jN`lE+~aeGbNRXHFSiHhjbseMQ-a=ug5o6#iij{$VyTf7bNzf&H0N zooQxIA+dkb_>uJg4F~`1G8_r_A2tB|_b2}M4gTMsx@li_o;oXG9kmA+4(LX@X&fk*vM~}aGe`VayvH!E+{)_PbPvQrd15A9zrC~#dm#)6*+Bkpf zqKhsrHE7VVO06%>#-raict1V;&Z@ejyBobd{rdJ!!v+temw$Nd<#XoUJsnFH6Zff! zZK6MJCw+W3?%x5PiTmjLf&DV>GxohN>U&`SRO6qe`m%de9L6~0)D2jG29k5gWKuahNjUO|N zeWF9i{Re@6_ZjyM{LBAi|2TUQ3Jnna(+|{zJgXD?Qm$`b<^b2RN6b8c&&Ymkaqiip z=P?sdd3Lcm0M|$K1;_#9ebfMAMv%T>b>F~##y>hxKIciz_yzX3{~QirPtaOkry19v zwLa_r;@(?LJ|O;^VE$Z-f8K<@!k6!(_|LO^r{X{S0@uJb^nY)oZufb5{QpDlZ{B|< z>_5%tpU?b#>O)VZ2OoH_YQu(&rR$ovIKkgJK^jrI^2)2I_Z4SZTuFS)(FM;o$f8-E&iV-Mu%5-G|n9 zZ(!frp4i`CU?0A}pPIb-y;`2}el7f7{Y~62S|G1SUx*l=F>jm}?_eE_YZJ!~2G4`Q z+Q2k!^nk)f4FGr8$o^RI{WyL;lZ_ueFpVEQC`}kW7=MBBdf_|b^$Wi+1)nfwe8C(1 z`|^e03iyO+_@SxU_<1VWpFCkC|2B3Ujd7>xVDQf#^I`qU`0qph-y8h*O#LeGA3Mf7 zccib%vk}-Q+O8FT*d5C36NRIhqcvzie=zgH7m){?_nMlTVCDh#ZF#;3x?a?K?F+UZ zhz?LC_BCUF#y=d<`S2Gx!{v9(7Com9Ko4jqt`T(rW4t)ex0M=z^#HHg+ONOt9T5BC zyAu0n#6Nj|Tlcx~+5U{2&HYZ%>m%nk=I6Mt?>FZ2nWryP@8`3vVy|Rr(xl1Yy`lJj zQQmKFpEZ6pX3xM>@3*ht9sTz9sr9>~)7|^l_w8q$LvM@wH=TQNR<|3K zb@!V0gMIS-yQ%M4*SEH3pPm|@`rZzDY&WfAo(3IH+%H{(PKP!(e~x~}eBAg-oS!Uz zm$Qp|@IJP{`}hjHj~fNn%Qj|YzcgkT$j~&nR##ZA$$A9tP6a4o8 z|J{rDPcLAcO%!JmkppyUM^BLFC8-5o$DF0}SNLjoiBc1+$2o)-oWuUG|5I}wsXo9Q zVeHtkF~jXyjMf3fzkMI(0G0R``x@8FOT?1rxlVq?9@+O%Qyk1SVt${opTEy~fc)R@ zYw^#U@E3laOAXHaN}hiz?lbp45AC-;=XVTe7tFit-GcMSK0mNubsF~R^D*Z){+k1E z9$3}v*>g%abnRAxJHEuA;J9s}izw;|oCxb=i z+ML~8v6MRbnib^f>(TlOoj>RM8T$q2x5fwOr=M5s-+J8I-Y#-|_HDo!w$pR7aV`5g zm%F2veE{ME`n0;^yna;N;~vAF*1j_9w1Zn)vQ}k_GmM_bK}T{Xos{$MJnw zr4Puypv)8WErn%%P~#JF&S1V#n7N1l&cgH)<{;zo9b-og;%~d}ls)Ic|JivS_@{@e z694o8bf*`nD?7tFck+BRIe=%Nd*(6c9Mgw&H5?%Jh+aZp!1?u3o!78CsG={xo*>S1 zjr|RtF{bV-|Mxa?fE@oT@E<;aYw88gEB-1y!Aq$Nwcj5Gp@0A%%yjw&fm8eynmqc`_Ax+ z$4S(>X0VfQJ~J@O=#5{8Ubi*obe-F|i#qh`d4F0TcV<;1L=BqlWiS zBf$T#fxXj^3jFusOp%`G|2-=3uMX&ZVEfPkoD*&i2XH2%sLy%^>1FzW3+tvjXPt9e zPk{Rx-5cg?r`LlzApHRDb`0(>|33x)#*>%WH~3R>%-7NnG=+MDaX$A2>jR8a@_zdO zYWY8J!e8(e+6kIYgQiWJcIS1h`x)=e1#;XEPC%T#63u@u-0UHI`VaX`?cIpIUSht| z?D19i(fsW1f5ZJ%%a^a9*5B~Nt6u$@6Ac<(UApGl##Q$DIqPTqx6iCG*H#PSjAvl> zLW}mSy^+kTOypcGcXV3gch`-(Z?-TG?~V@V^qtealV0AM-X8fralefHqL)|ftMP9^ z=UW%r{yKW=R?t(kn7W;{y4f?vvM0biS`H1THm?t!59Y^!!O;azKNfHL+2|6qH> znd5!t2BYNz8UOfze1GNbI9@AqgAx2qt(q59e4q1x1!6<|Q>I{>PN#f8c*8 z_#af@e_-(cZb|(20{fmN67ioLz%x>utG@nv&J*C=6wgn&&U4Pm@5669D-3VDwB9A@ zBKm_#9>WJ@9l+mlzGL!yM)O1K0UG$P68n|-CuSJ`qkoir!M~!f?9*ItySVP!axGft zckax-psWMrdSEU8=S}%bzU=kq^VFZ(+b8xb@$dI%vG<`FyZ3i;uHUEV-TOCd{lxu< z{bJvIKb!SFxt{qRy+Q7bDova?xx~)-6aVMcuRhTb4$znxzN_i;6aUuw&HJ4}Z-?gB z^@c9=rT0RoB5sWt2_DV)(fi2x-L1co8ox99&g&Oif06TN{G01nV&AxLPmeP=_TJlr zyN+C6?Aw2%oO;@KZF0VBjE({g0fZ+ zw-p>AGzR=p&23q0RByx|RN5Hs8-xBh8b4Bv|H0sY0Oz0dga31ONP&OiKeIs9`1kBl zbU=HqX-wl)vCh4v=-f9P=~&b1rZedQ5xf z^zLEK=SS#%f0X-+|4Qu3`;GnRI}!W+oSE^gTDWj=sTbV;ygKKfc=cK5l&)>kq^cR$ zkuyG5qy1kC4`|A)pVybW>3T7f<{ZEC>Yl~pUVeA;I?Lw{%x&z%a=-4eBYR^%$KCYz z6xc6xKC$ooZm#bK_V;ZLjo;oq^?h^wh4#&Wed}my>7naI-A?Tt>}RfT+%IFThVN(Y z4~E5kHe&yujsIorkK$Gv4feyzxk6?9|1ZDKe=oR)`~$;3XrVQFOWh$1+>3o}xYRO}6Ohnu_G-S5C0K=cEk0eFp^G5&i^QB!__+S0G>2cj3CAFm}Z&9i6b z0-8KPA5e>b-h{v4tFrEw@tcK-?ZP5+A_r==h$W!M8pktn34d9Dw`v0rC6%xR-I8 zUq9cL{Ycgw^6jYM{qQ%`P{jVwKIngasQJi~Q2nQQyv#zjw>K0pp&J|GX!2h`%9H{lh2iG4Bd zdngCUxcAokwR1hYja+Oxoa|}x`9FZ`6MjzSYgp{|H1@0a%lZ9$%?0HCoCO5-+m#w# zeNE{~c>mR$$=ie({wDYUv_Rs=wW(QS=XzU2y{|XDXr908`Ph@u`#p!tIeyQ-@XU>q zc|Ws!q4|~ZPumFpM=5G3UtK&Ys_zUM!*8}^r$o19uavi@K`|#&N zXY0>x4gOEh!PV*D=%@OC(|*<;l<}Ya0JjCd&piKS;J)lHGQLOiT9uahMdj_tVSUQj zw~vaQ$i)7@Km327_~*Q!Uex~r|MURb2kcG&`v5#|ykiGyf%F8sTRn6D^8kE+yV>jw zv~T0`%a|3u@Z##dfmsLetZ{V!_d9x>s0+Cxi0kDv{CiEI71&SyBC{pmVJ2t;`RR4$ zs9cNorFyH$0jvqu;-5F+m3)!&*Rh|E=lUMzSf71>Kfi|j>tZ-uw*?CpY)AL|2A|D~ zV80UoafT;;JYv5&o<6`{KRF+J;z}1Y(|=LDOG-^Ri@O=KJWbs*MZEXSF3;+8x3u|X zH}ZbZ;vF#@J$pR0H~M?$v(MJ^uiVe;xjDi6o#|2U2kXx6S?|{>vF|P(ck1n-m)Cq> z&R@iR`{b!5SkJG({v`WtjQgix|0MkD3r^GF^Z^AA&<|AGX!N^mqx$feQ>||r&*LjC zG(YYOpF&Iz4Zzzo@Piuc%O7g+Uy1qnzKr`3L*Z0IdSN}mKR$LK)}Qzf{s;8oe3V}3 zf6M@e{)hJ073_Bb`_=$Y!auzLp#!J|;scuD#~b6*uO^4H9&qW!oF`O1S1{w>dZ6bx zc#aU6JQkd9Br|^OjxL>l!9}I!EvO;2Y)y^N_+R9YP0;*XhvwftX3z$6K9}?SQ^@;0 zi_bnE_wsM%TrAJV@C*(2?cZ_B;lO^et1Hj$ABvqF?&3OpU~B07&fMGA6ZyWJf2MIC zt&edw-d(nLq|B>CURe4{kbh{0=HWJn>D_s zoM*`MD`NjBz7H$w2^sGrhv7qq_KJ8f-xvEM!2R$cJ<$Do!2Q8K+kfv7TE7Z>~|*6`0qKY_5-zs2ehP5<2rnLQ}~;6+c6&i2e^ni zVS9nOe!|2*^B#$OK6(P>{#ge!ZixR1?1!!-{vUq$;eSSRdUEvW(JSyZ&3Qd_sjLOa z0n7zz@z0y^GQZ+IgTGyiwd1)N|0iLe`(H)7-*xln&)-T<#z*0A|Eh1!pOv3m#=d$k z`1ef6@ayh|UA|&@Rj=N?OU#OuF1V;}sYNU2%G)qkUeuABuwSA%Gv@A`k#F{87tOFC z={HvX#=iAE`ey!1>=$^S056z89pEGz%g-kn_~qw`+PDGq43v$ZCso*FzCV-Y$4tzB z$G-{RC^$%&W0d(oj{l>G{naratd9!3i+xQ$0RD;lBf$P};{MP<-QoV-!GHI1?r-eB zA@%9OPL=MRz<)^Y+L1G_Uke1KXYd@k1mtmV*0MsF}W0OyJ2 zzCh1!a911GQJg1geaN{W~7?cn*_?)c$z3j9}MpFA$+ zy2QVE-AAbh9zJrUYQn^crH-9Cm(Dx?;?iaHudEXPT9YQslW*DAbAP?L|>5kf%yi0AoKnj{Pza`8T%Et@_w*CJYs(h{s;E=d?aGO_>b7% zk@-JkKf4v_{k8wUW5+h&zYRSAZBE9&IzVWF_63s9Sr2qqi1R`04ZWE8pmQo_1MKsb z1Gp>DIZ-tL@vkqC1Bm~OefvtpKfSx;ldR^_K)oK{-gM>#J)Woe2%f(5ndNL0FO(Xw`{5EN$kI_ zWt-Av4X!F(bq#0qv}}{EBM0z|PWSj4H_RnFckB@7^>{{~JF2GA<8QCe@}=`*XRopU z#{JIo?Z-)K6_VCZ3zCW#u z{XZM`#eTNHoUyyW{A6%{l7ZC%(}TG!Z0f`z{0)qr!LUi(P98#UL5(AgH?q7lbA;kZ|{q3*n%KU%9{d))g zJAi*s3w`}GRn0p>XC zQdh|uiaw#*4jecT{(#(TBt1VL;bdOpdUIN`WNB5))@@3S znlvk2)2L}xlV48&yU)Ye@AEoY9Z_kto&e<0AefxTHjL$iD z%`Lwp?%M}oyq_!%G4VK+9s#i#SQp=6L%AIahOx=zTfUz;Wf(QaVdNbr+iZTWwD>)b zo#o>v537ES=@WU~@F#=QwD|k_Ab5c}0)9a4F=~x!fWv#I(ZhPBk(C^Pygze)<9~2~ zz<)P#fA*&L&fK3f(r;kax=Z^q{*C{{{tjZlHMxI*{qOV0VX~&v`*yQ`Kfakmrcn4@i6uAD~tw2OzH4L+Tt!HU6C~hjadkvxdImj%i-2 zPPq=4`%}EF{d(o^K;}->xaXzf`!fFPaGuv3xc?XVy#A#2m-Bt&zSx)h^BI=%eAiP? zeUNiuUYn*&omy4zvIeDwS6u@KAWua1YtaJy;|J9I#eYk#oA&gTcI(=ionBnG?53ME ziM=@NuU*1U>vf!S;|{+(-=pU5T>qWc__%dnU+nB+7q{o>?&Zw9ZS3f>r$2i8nAb1# zKDB;lcna)`XY=?o;y-J5=J0aw!2S@hZ!9iwKTS-6?djq?OuP z><9kK`F}P3`+@&H%vbf|ti&GdRP5Fvb??Htsh!&e{yXRRFaF!NIt~AxxnwVZwZInE z0r2Ja1O^AN7RZ^x?g`?0I?3D-Mm&i#U#u6G>wp>m&XH8*_)iV+1ib(!m3GJcZmDO{?qVp9~Ze?$#bBs`?>e! z*s-JZ?~JH=&DrOa>R-{Y)QY~5R;@j2(zEBASL44G*NuH8?wuMm0BxHcRa2*q=ZwA? z?6zMR=X32=^Dp=LdKOncm&ddCoZ~I@z8&a&s~bNj^p;Qhq@JijOQ zBcC>Yp26aGa_z|Hjr%pY*MErp8IuaU2R6%CpCx9+?(~sqt~O&NSRa|@&m5H&%o_b? z%-@Yl^Rw|-A3tx#sCd4wrT@?$%;E3M4*w!QC^&(+hxthOso(#nFMx9amH2mO zF!8=}KM2=X><6+RnEt+q`_=)?1` zzh3@3Ah%cNtH%4O-&4z}k7nC{_3G6(k^BFMxF6Wha~nDKoBQL#tK9i4{vUbd(X@U0 zcFy7JUBzpbF1@^dsSW%3TjK*-hF08^+@GEw^2OF%JDsj?AG@dAN2%^VQ{9hUerr}Q zjWfE6{k^;5obA9r{NEhF-amKtd6u4gI(Ka=>ip|gEkN&Qj@KR^>ijXcA9eoB`^$P7 z*w22Txb=R0gM7c(=@IchVt#@9=^6LL<5^RU(FNw`MvMpVVmGi1-e-?V3$aCWu(@OZ zjQP957V@|-pNGGguT}Vi@Co4$@B`EE0aFV8VQ%2RGjzhT4~YI_d_Z*`Aoex+KXw11 z^!yF#%O2HJ@ZYrp|6PdxH-P`n;J;G`_As?)2dnrmW8ZVuTf^(323W>_Q}Evi4ZuAP z4Z!~;^aDE+z(rQg0nqW%+O=!r>_D~uTraA`f6f7%G0})W|GskN%GaYs_qAqSzIL-# z&5yNTFXtW5hnD@Q_}3Sl<_D+^G#)y1=w$2u=&(Nr_b06Ji~CCK)8ilb|Ni&CAAX$k zT#_0OpR>FDN|#+;zp72U>xmU;f98qczOi$@+_nwv?|ROl;kq13%wiILvcrRmp@!WA~$-MDt+5Ezm&YzG@xAENP z?|G~?udv13E)IWD_>RISl=%aHXWmTxj67om`v%Pq;3DJ&>Jdd=qb6XVK%oZ|^?;)G zAMwA8|H0sY0J(qPo<;ocY5c#TT{Q>jMjp_uGqYIX16siWS_TJb*S00Gzh!WLdp5vLo->$?fmg=+5vrW5psU@=`P0@&~6B#3#i~lw)W8ZXt zc2JF^=g<8f&i8w!_14Yhb32&tRrkM{o*#GkdwbK-{a}ALdBJwh(aO9Zy`LQ(OYQGv zKW_B*vOjY=`!sWZZ{R=pGBMsbua)&Zu~Qh>2lsOQslfDe!Zv~2qOQ8<^Sr&nFD}-cS%O<_ZI&7`(3+seSG4?iOa27bFEg^sJ(`3zh1^WKrC#y?fCKIYXAR6%s-(IQ13J6&ow{hd_()UpW~^gKE&s`hu-}$RV`Y! zEWL)aJR8yz(wcKR-3Nnq%k|+IzU=npEU)PMq2J8&sK?OrXTPsC|8=X;{kEd}G2`di z9XB2ItWD4268pv39Ea%xl=tu6zMlQuE7;NN8Q7kGSM2X`xAsi-;Z1WtR>XeeBf5Xa zxwt!EdF zCJPSGr6Xso!vQ+9!w0lE1^@6mcecsx#J_XFgc#<5my_|Od9JZk!qdCvvqKd$d3Le(d>mzn8nc?DgNUZUyJ;u*;WzzoT&fz`s5KJI>F} z{@uvVuRZAf?&sgQW^tUqVeEJRer4VtJ-%h`Fa9T)E5q;Q_|;aS@fG@>STgR5eeng( zW|)tUA+HyIa(219aoZR#=Ed|hFh4D=Uoj(XSUEFoT#c>5{5~5$Z{Yq7t7gRG)-Ru) z*6|#lr=M7}Oy8kTn2c}0CoGwSU(hG`wmq$l^08caaTT7|r0Q!J)=(iZe4wlzF z@W2D#W-sIKjrlq5i+y{2jQ#9&3;bLA*Pkz5yo8-`orv|#=>y^XE_%aTq7~Wm@7}0u z=@DxJ{@o+tT&d^I3>!@T&H1!*$^BO@TgX{jtEl;-`Rp3e*|RN9L&yT92?MX`K+RQ}_UDgZcpb zfV-ms;sfaYlLHJ!102x1Gj%|EFnV^t51_|{;ScZ$z3>S=yRwTB9&ke^IRLvE+p(Lu zbp`&({hLMnFY{7TO9KBj{Qot~1vG>MOrA7}bKl<(d!vl~asYFHQ}OSPN&1DJ zCl39bKGes&F3Z=f*J&;Oc@tjl7w2qug^zdOd1Bulib@WkFF2Pvt$RL^ULJW4U;Ky4 zxbGhKa^7D;zqRLA-%$1Fqi;&HX3eG^)Do;WWgY~s-?&+_{@;{qgcU<@e|CL<|JIx< z(V5*+YJVe!flKCn7I1F8xqs2`%WkhYr+atQ_^k6Cqt5U7JO_5WtKa^9G(TfMeSSsk z&%Hj*^-}LI&-dgwKP7ZMYWQHjFmil#K5(Doei8Gnxz%8Q;p}n5{0YQ-V{pOoSA+X? z%Z>Bk7rbvS<9+M;d1?EG`Dw?-1!?D|(`^U$ZP$3rHXgfm-MsJ-@qAuGpRjqguc=?a zr>vAG=o9>VMJ}<%e4(5xs241Ok1T?hEHF1QN0AHILxc~QYM+30PWy!L0i)0XM!*4v z%K`KO@PI*m@d150Q4i=yKM>ZdW7Gw$4fgHXv78&I2eOm7Lkn~O@ZW}W*dzY)`I5_d zMu=wy+XLk1>`svS^Z;BMX9w5x0dj4nv(Gw<`ta~LFBE)-Zp8H!_$T)d{V&IV@sIxW z0zD}I!u-&aXn@0c4X@i;{PQNfk}q=m9?bQ1;klLg&m6$pI-JcnmfZhs%<%t&&*V=T z|6unW5A^V{d+&hE{z$BP|)oXfMA z^S6xsv%qca@v4aZ;(ltjQ?M`Y3ymMnuhvJeK#}Vgd_S;1$Nby;-aLI8`8${l+!rx^ z8(7{B-gj(Tn09Smly+}foc3&8lJ;&p-MoMImL)uHaXfY>&)KnYA^t$0u%Pe>_=VsP zWq*);!A5++2Dt&b!W!}o`G|Xq_7T=b2#g%fz|Au^ZOBA5Iw>fcO{t8UOSEiT@Yjq5pvv{ZY=G z9aFw;Yw`a|z|V7-`RP-h`!V+C_%H6WozK3H3G`__$-bX|pf5A%9M%QDAR+ z*8YmR|2|^8Jl|a3I-herhxTs`-tW15?(WTb|FVVd^HKA+&$p)6&-z`|@r>~mHqE-8 z#-6|FSm54VKd?_8Vy-_6EX|Q$tIL~zPlbcefRmfogZs6{dE@({v@s}B((o0h`vm;K#>^4OBPx7>xq=$QTJ(n1OQwp(e1`QsX>RePWU4ic3 zh@3F+&%9qFc70sUY{<3ji)#*76#t$zHJ~5*6xyFX|4SFoi9H|NwyY)o7x-7__Z;rS zoYiq)kNZ2}{b+uh*0INLCFgS)`}2$sn*WSA6K8nD^r+zx-}M85|G<2i>%$LbXvBOi zV}ITZa_PW6SPETVzK<>k_Sdf@Uta@fUq3%^FJ{H?{v9jQfx!Bzba40TbZF0-ba>yI zbY$P!)9f(!9ooBw$E@bDJf7zr*ts(8-(L8Hy*uy+`UGBE9Z;s4iF;Gfuk&D9OlRaevx{JR(Qf^*NO z2dLNynDGw>P1N+F50KaqJEN#Eg)hjlU+%Ag{}<^O{5^d13)Bv$aDCR|pEu!^eo=#M zRo4H-e#XDJ*9Ule0sC5JpwD~+?eAaEXP(czU+l~M<@}uOTICt9;-Al`bcC7T-hKL} zSH0$(RG;0@?)YkI{0IN){+{bw;J*=dKH?g;GEBXEo zu)jSWq}I0&-oF#x@41}o>GfH)%srmW_S5Gd+@IP08T9&12iJ1?YW!y(khy*t`!jj0 zb~5&Jt`C;X_2K(#m#gE;_2v0+e6YE9+w#EeA@F_}Jd5?C``4#q2R5YRH*QQf9om>~ zKD6mH^Zw%pH>P7ZZb(NDtWQVwujBdn1bjyLggvXmm+Y_L2l|=aFh4z^VA>teF=dfBf-3Py_gid+4}6Yw^#U@Cv`wOx>N3_o-$4`<{w@ z?V@SZrp=}A@@YPcf9G>~-kd+jeX(!dk8|Cse*EJf2maA$OZyHSsOs9STX4P0*ag+h zJ+a_Fa)0oD<&_P<|CPl50{=b0|KI^d-|uYtewT8N?|S-vw>t9&)~)ZG?`!+f`y=+p zc|A+gnw9MDHuf)=9_MrwGk%o6 z+;`j2tvqgPx`oHy%yVwyxrI+yU-$%k$>F^PKQM>T7wm;+s2!*;6q-Si8<-bZKUmMd za}9NfRcM1N=tHnJX`OO`^+9?CXGcxIJiz`SW}nJ>pmo8~><5T^KrT@22lNGnKadNU z6ZA(H?1K(qPe2!Hfz|=qv}(o-U}H1@`M>z*EQhPFp#ImOT>oPi$OU!T30|BNnB%{F zfb52JKQw(KPemWlsra|{XZ)wG{Gv5L_D_DD9-vuVpSAesO?ZW0;$PnH>~xO*{_K02 ztuA}}7Qnea1NZ+G+Wrfb*jMit|K|Qb{pnBb`!7BI_~Rw`TvccKyLlaIe+`)RZy7!y z&xRNHZy5aF_4f&XFLo?`w^?33&J*xB?4)?mLT?k_jjUq*htg1X$Ax%3yQ>&f+31iwGL zcO7xw7{56%EZ*-pwmsc>e0#ducP4bW8Y`k;! z$eMs!fc-(v58ERcdj`wB0rG&+L&*vB0pSNKe1ZBvKkI~@qBhvmS|B=LNATaS0{=}L zTi?BkbDT5&>!*74E(QM=r?bvFJFbh2e`-)^)v8sAx?Y?SLrjSp;F;pTbHM0OFYwrJ zO_?%fuGi^-Vm`gL{>Pi~GQWJ!#CgvB^#P~hpLwgL?ECrxy&1nD_kSUCezC7v_h;s( z>SsUuS>XT8Z+>%W+xG2M9Xoc6&$)hshOz6%eV>i#3v({?RQwnGKga(mX$kmW3;wrk zwD*5~+Dpve$L#+;W&n3H-(#aldxoo%_>kdF;JB=bqd4q`Pk|e8QdK4+@STpSa0f zfxmfFPM~HGdclg&4GSNz5FaqV>;q;;-;wzUobQo5C!8EJ&Wj<=oRR;dLA|5~NDc72Y~?g zx#xDxFV5(d`&Z(>$o~u6S89CWAJqBn>8s%UVt)zPU$)RbJ~e)HKJ@)<8`bvZ`>W9G z){?Ws^Wp4)`Uc-T>C`zvp0j;GRS2!Pg#658ZbpJ^Z?(VGnUzX%F(d z2YAc__Z~`b;5qt<@Co>X*WSJ--GeWD6X(b$X8iw4{Hp=55BPgir%s*Ebz6&n z-h@~9mGOTj4p5DMeDi8J*H^&)@8JF~n)7Gg&&*cT{(kU-AEbZyhkpqCKm5iwmgvvo ztjP9({|4xP;@@7N#*K;pR}=r)4O!sd{J&L={x_PK%sC&{|5rQrziv6PeoflNIX}C% zZ;0Eim4M&m*?LEwACM2I6C71D zgp0@p)CRIH5I%sOL;D5H1?)#&S=Iv71KmRqT7a{|_yBi^OvDF_w=QU(Fg!qAu-XTZ z6BNAy>}Cr5GcVYkI^Yf0(+fx)kY2!|4}c!eW{pxa;(ue}|20=q|Bd{=CjOs$t}_6y zMh7V70NfX{V8Mcz0~G&UTQLWe>wh`-H~*)$^dcPKMfB+B*dMfz>%JEMuMqq^&Ay48 z=jZsJbAXKhOO`HOx(3em)h~SE3%`T=znHOa+-KIueLp3%|Ehod$A3&;f*U;a@WZ9` z8#bhNW&B@pC38O+|J45758n{{Uqk%2{@;$-zwYe)ap&h)>i^T}|FQpXr91!D6dK?T z>VWb9dwtaW?e$x;+2I?l1m{?coFP1LD2L*eg=*?FsC&7bJRn z#Qp-XzZmSJ^=(*9ZQp#q9QVckO~l;Wz};Qu_r&}+$nW9r#_l&gcr*CD6)fML-Ui;q z`a9lyPkQHD?oCfT{@V1ex89fD{npo=V)vzY@w+E@%sU>tC%yg6ck^6*0{)=v3*--S ziHGhx8u>)<0sVlwA)2AJ1-XE_z!CZk4pK7=AF#FT1IoF8vj~MANFIO|5P1N70q!Mo z-=I54r%rI!QO#aa&nn2X!)gNV4{&dQ`G0@-zq5kw3hT;l0Ox_OZ&PT1t*HTCNB>V# z`n#@S|3^dd&km4!*8eyUK>V`@KCTU}!I%Yi4*+}RPr^U-rmBp6W54)6Q~XcKwIa3s z|F6Ik-M_-tEVjKGZ zJ!577JO4*9`#+hT-*aZF0gwZ%SeQ1F1E>Sa`;Gk@*DQ%SpH)leg8vx>{+acgS3bLU zruttQ{{`;BJUXBKfZA;QgR?yI#s2J~uW#uB>idi6rCsLi4n20(_|*F7$2m-GPwd}L z4sJev-<=1_G2eJE?r$T`8{_Xz?*zwU`rU7PU3&8EuTSrJ=l$uuPdt#`_pS%i```6Y zdjAs@#(nSOckksfPrl;~>D@eDKcTEue1Ax-8o*IN8p@5>=yRi zB6vV(f*Jqx1q`4DDE@nucLj=nafh z#{ZX#f8zh&a+c@TK|GsMV)WyeEQ|Uev$J#k4OJL^?h~zL)7=={I?(55!$@koO<2E5%b~r;M2Hm zoR1g}hQ;&y!S)B<{ZRVgyWg0ede0;2L+^bwefYg^N*{S&jeVH=KE&^ydh(4t?%{ZR zwLj1oJboX3;NIX8asvH8@B#Az^+NtFYJ`OkP#d&&!1_Qr7bxoiYJ$!dIh(X@1@!>- z3=|xo=nr=HXlQ}X38DkUi~w4Ixquv?68|HN|KNXM^Z?NZSjK;s4(tYuSwQe#)&QF| zqQ~3*KjMFVb^ts7a}j3&h<|s0SK|NA@c)SanFHkbpY=cT|Iq(y@&6a$U;RJhKV#n8 ztP9j5|6h*&_eJvm-;kTVkn{eGfA;-U<6mvykwX8gy1qL9zcP0IH9_ZT82ez@30)8T z(-+|W59fb7alVviP7Zbc-x+`@XjF69|Fw87y}gUc_m`o=8~Y30&wjt0`!6cve|{PN zbHTVc7x%N_0_ywOjQf>6{Y!Gr&z``w%V*QiPcJ`O{~mh!4(?eCp2R+JpIG}kG`JhX z``*w7?>J^J(I)DIH9TP7j%Cq1>`apVK^y21bmz#5#r6agIza3Vaeo+nQPbUN?!JJ? z1Jnb{JH)IB692`&eS!7?_j6wu_%CVz;NP7<%mTG;N&Iilc~0~IHNFP?H>{tosDEj? z?9zJd0HFsM4p51I^*_%8Vz+njf9wC&|Iq(V!@t@evFJtXfA*z$oz~+2732R@{l60T z8UJ;u|1Dwe>T~G-zZUrZqt*-?=9*x-f zb4Bcf=6vW&E@dxN{g@AJaa}WNh3@{P2N(@-%m{K(W`O6-V)r+7K{{B-ReMkBvj}M=KKlteT-<&>-ACVWl zpVxm+@B#7yHG<#*)DF-aA5tIG2iyS=?=nZCfh_wLsmgYI(I9s3^|CRB-H~qn!%SH`waP$B&59qESYJgqc3kLqLrw_Po zE6#Gdjx!yaQUkCD_^Jl@`peM($N|^^U=C23|GP67jv2eat^a5J?@alBMgPyY!T;=9 z{J&!C`$X}dbATNCGw$>4MR2nP?EU;S`2Uso&v8F$n z#PG+{Cy4Rl`7_{MtUvRKcc;&N@;&MEpL$RF!l&MwzWAB)QpL{Ys z6Tabz^eKGCCqMG`^z>8s0eHlRpL`^JfWPzJcgh9O2hgp8(FV&rAbdcf z2i#09aP)xl1gq#3v@W<9UIPc99~uMTh-bs*1# zlm|K!a+Nhe>cws70q9ab`=`(V;P31JTsW7rznBG(@2?^5Tl2H#zhdD`@N4X6xBu)( z;D16H|KqB$o-yyO`Tjh7!vZ+MBKib!k5AP4=<(UOhIyV%%<69^=cmSh)8VaA-+%2L z*7uI4H{O3sdMt8%^*#B%IsU`s?~kUB60ha<#(HD?GfzJmco*wm`t1ADm!J7y`pV~? zN?-llhtjiO_;7mm^V)~fS26GR_g{YI1Nesbr!U|aK94_m=Hu_?wcZ*2ew{Ne8BS1 z0ntL-AsT(b#lE1Lo}e=RqXt;)4YLPmDEJ>p4{$$sg>`inn7x2%fN+3zkpnaZ|Bb*u zbHL1hUdaxi2Jk)3nW6@8!AWNVGk==w7eUjDojtUw2l^RMJApcU}`rOf&ix}UMX(Eil_=F$IajF;n|YV-NsB4U5c z@uT^zK=WG-?_VFh-yR?4_;%adr`B&zpEW*u_g+uF|L`5@ZPfGLMGfyg?|6WC{4o6d zP3dFg_r`qr{WDL$JFqK`#kBbT8hHQu7eAW5@uiQYZ+!XV>6>49y0EW4oxbsvkH`J~ z{_9`-Nce`Y;3Isl9O3hycu(XCpC&gjFEA%iFH~EMeBdpo^8j-JHNm@X-oYHfme2#8 zA3CthzJMj{B`I?Nb`Gu~2Uy7-!6kFaho}SC4-|Vs@_b;qAL!&A;_MC`^^`N&6}JV|L24MSK0ql%mLdc%6_?+|8?&d`DNfA>{p3-eSqfo z?|Rp}UV>l#iG3ko1pkw`zPv_j>wmlnujGpziv8K6>bbwh|1;tL0`jb>>|A+2yFdO3 z{QpVpXZ)M{m*aonUv7X#6{Ri5@@ZSJcqA0XFrj?^R-WeSq!*;9LN9I=K&|SrhgE(FbV!k8=R{{Lkk+aCZP?{Cgfa`{m*+Q0M;0 zFXJrGGvS}u@)G^!zvue;?3giQ#&gZq;{TNZzx*7q-y8pXLmB&(_|H2)F5tYiN$9Ol zkdOV0`v0Gd`I^|*nEg}#FVX*3rC<0EIomDlb8@c}uN%3ZyPw0@2XgU+bvX;-0yMx& z(iP|d?uqv7xK8W>=h#5n*P<6w*>FM@2-RN_E6t{klMalpZPwV zKjJ=hyvX%GTwq^(%JskU%u~ehkEE{?>jU>+`()t#d*A$Q`u?|`Nq_sT&!y+S{dw#Q z>2JUN`Ksr>NR|HJ)XWAD84&bQEe^+S4q{z$w(k+E;=&-kbRzw}-F!pAwg^`5)$<{Zy{ zfq(tH^_~mK11@I%vo1ZL=75*N1Fj$^Y|J?TE!hcP>;PuR#6bIi#Xq(D858LRo`%kk z4?yo*YU~g0kLJ&wuWIb;0|NhOeG&WR05vur>@RRHUex=F*za85X7+gO*o@{!>_2Sm zKWeRSf8_j+kn=xojqfe!{M6yzhZg@Ku}?kk6CW;eeRVtezP$ZwUohr>JYu=AUQCN` zasGGT{bH5&gTMJw)epb-!c@;J>x6v2XrPNG=MVx=>@U|Se!4O&krf^?`{x#00*)c%w55Kr~w%NJs+eCIY1}R z2}RFwCt!=F?0sX`qxipE{&(>uKIjPwkIuD|P_N`7{3I{+j#$*c0&Y z%$L7!@ZiD2%GatIwmCrU*UNkd-2KP=Whb6iiT@n?^#kX_vqn&xeGu*CyYvA4KI32P zYn=B*{QqI;@BjYqOW)&XYPVJR=rm%)@W4N>5x3`_buQSK``5+JPnUsz_dnYM&3h5BN7~d9Kbo@=m%m)fI9;{V`u?$5Nd$cbHU)hTK`x7BmR$s z{|_Gw{@D!{JpgEceV7C8$s9oU&h426A_oBf9XKDb4LLyIpFZFM|Cgt_b?d?VUtPon zasaQ5Aw!2Gdd8|_zvsuv1JwO8?(?nMKePY8*s^8ITl@CyJAmhVjn?9yH{q3h!Tr0G z_kY*n`I-NVf8SGY>%g;yu%GcBbp2)~jBVE+(|4ZzbYS0@PpnTrAhv^X>}yp&{kw0ZpZ)Ng>1RLs zR{HxNeXE33{r!*Qdw%BrAATeK^ao$(v0wfF+B*+0tIBH)3q$W^C^IlKOk)NZdhccE zMXHF3NU>u9R1hgDV6U-(N+cGr5nJpHHHt=%L`6|R5fu?@Vy+Qk_WAGot-b#Ih&+|X$Ol8<-f2)4$TW4%@c=h;a4={T`ecsQ#W%wU4a)d))!qGc&!v5B+TTKH< z+F!!GVgMbz<{sRO7VQr9(X7y38h%F-|6GKh`VjWf|7*q1%m1tYr|X%B|0;d^_U(l} z+xgh&b!dNo$$PY4HXy`)h=1mP1N48S0{|!Jcd@ni-V3MuC}w@rT;)n$@1;Ea&sxR< zuU)e`^?&f+FqQm2-4(zAYE#t0bbvGmG;EOOQHNR$a58#;Q;r-T*#NN5te-S~ryNPo z4{V#S2mc=X*6;JNKVP46Bt334`=`_IKQ6B6LBIbL@q9JgH`8kVn&-X#s`HuU6UXN& z^Zoa!)=$0f3Htj_KSG`ViF@6PPlNsEN@M;I`||sW_g^yX3d6$wj;)&v_aDChhWm)~ z@dt0ZT_3!aa5mfLKHj0Pzs~n=;%7Dz3v3`3c-iuYw^bKV9N=>S>H)F=&(R-z`q9-+ zoI%wB)ED$Iz$&;wH(#eZfVhF`1;7bnK46LJfb;^z2{t|;v%%60K3@7jM^Z1*JdpDL zDE>bH|LB7q;QztD=>ba%kXgVk9W@J7g#T|D#sB%X3rsrCrT)M60r)Rh{t!oc3I1=l z{h!j1-LYeby{6u5{eS7na!RlPs{Q{7?cd+Q7hFc&KyjOX%TJ->;v!~c{ch-)L?FTT$Siv7j)(|q4)=<}b6j@QD|sFR;X&3}>g z`&Qj_8NOe=zU5)uPoM9phu6UCQIAh`ey~riPx-$0`)_;f6YmSV!u|)_ek)%Ou7&x{ z?vtHwyWJnXW9QS4-gV*O;}5rR-s0<<-AC8~#REPT*!K1-)CFD&YXbBMlnZQliJBnw zMsbVQJ#m+NN<9HM!#(6!a9Iv1g^bIoFMT5$N}I4E><4^{ZP#YYd4wUA8ioL z1N~k2@7$JI;MRtJX*y^pa2~TgnF;xSV*IarqH?7QZuIC;*85ZLzisw+a&?kIFA zzw|77YF~Jmn%v`2t*_MYze~&?`u*2_fPKR_*#Gq7cio;{TixfMY>&=1_Zc7W;q#w< zw8iWMaX^e*LA8MxH34ddFRMoo+x*&dv9Koi1og&;-Am6!wE%Gg;0OKc-j(h?dX)E2 z2ax{2suh|UmTtfT>jhrAbQZkEnZ^gy&Jpo}XR*6vCjS3~gW=!z|Iq>&Zu7q(|2Llh zm-YV%_}6=>nvQn6YuAhI0Hgkwi2w5C%fSJ!@5YTA>r^k&-XF#P(viWx*gjBdg68`* z^8^0(5rck?w$#1s{x||H$*kzU)jgZ^|6GK>^?^29bvRkI_&Hs}MBnf6Uy=G?4%d4$ zHGu1QKR)pI=e;@r|MLCsF$XC7O`X6kg989Z!}h`PyIl%1->KC5LM@2e^#9lm03Wa^ zJK}S*;Q*rp(y2om*Q+P|5;#C3h7M#GAp5(E$P>&p%`R zy)CcP(@V(rci{Jx=ZAhDzm9+Z^uu=yIq(%mJuS=Kj9&qd)&(gl=c4=be-X7bf@OJgqqB|;C`R{zwj^LucN$QF#x%n%>n7X zlb-7_=sJnt#qYJ55Apvs_ahE)J@|hO;Q&h`GTZ}{Z}FozfW`Cx)dM(`9AG?sz=;#k z^*mfQU^JN539&D|FVFQqzy_%IAJ6?Y=K}tv>xHJ@Dd_jlIgMQ!()Cf@f61Bd>Pr_| z?Eeeues|t_Ept6LG0R8ZubQ9w{M7s9`wxu!*IAEG{XNzC-lM*+eBWaJ9buj?zpps| zvybKTJ@(&=efjD8fj`)S;Pc(@1$;f&^YJ^O4cHmlfDg9+)@%U3KX~WOmjdd60eqog z6M2BR!!N-PdX9d8=8ZH@toh-!Yp!EHDa-+EHsETw0C0k>27pe8^n;}nCS3sOf=ClU z+JF}SPm~Tida29+j2o$)!PNiI1N8oXNO8C~xLEwZp5d<09^k)gmv-!MY3n)=|F{Xx6BsM2MDkWCHVgD z;gx-d?%y_Y|MQs{XdU@2fA8u(PCkCx6_D-J_0#b-z&}?|i~wFUk3atSQ?bRrrS`X% z_-sG8FY);IHbD6Q;)^dF-(xwQc-`3Q5dRe`x(4EbaW9AmuG4_I&@?pS#1m7$s68A& z%>fVSORsrQKg$8ejvk685c_;45C=zQ5%dX}7h<;eC~4a&77z!3 z-91x$?jP^_!S{vpbA~ki)c0e)|B|Kf{nYcn?gHcb-F2H{|ET6quV3>#R`XM@??v%> zpLB2F``?1gr+)p`&E))BHh}%ttkxI$eR6$#zQ_I-#QWy+KYib@|J9xy!B=}eu=6Fa zzrY?44@5R#Yh(j92lNPn9oT^P-rW#vee0EA%Ns8SZ*F=n*!1dCv5oM96a%~f?^wK` z#~->Cn{_j`>jpFfu0}85Qg@4S0YW~YY5*6pYh;Od0B6Ai0{_|<5XZmjf5JaHX@-9^ zK!pF1!vCP2)c#86e+LlzO9Q+YGr--!e%H?6ze8Jl|JpFi*`h^(ldflGMi%dHI`6aa zpLziP)5!}44;tjypul`?kj_j#FvH=y~@vAOS9lez!;9opPpXPtyXzat^%Lc?_0KFFezx?t` z>)||b|NU;sk|mBkt%m>d%zV{T{4ZZX+@Ff?SN~X$b|5^``s09o;a?}jf4IZL<9{lBzZur|qxMJN&uV|<{^y)U z?T^0SrR4tCTyY+Jzl*Hyf6pB^5c_Mk=Qf+`QLjyX_b~4dIX-XF=NC^$d_MF2s`abh zM~z>#zObf8u5W%n#6JGsf%TYS|MQ)}*U|avvz@_LpM4N~xyQ!=+XLzZ!6zTS9ndcb zcFG29do9@hPM8P0MIP|G4bQ}0d*un^2&)(HEHeU6zz6shy+G*>-3=eeY5?#7RRg%r z^MT<4N;`bfkK&)}I2QbCFM1sR^!~vAkm6APOY{HQ0b<-A@ZSyWckR^HG@RO_<4{!8 z5o>^R0HITA*S8)d8h9 zP2Fif{lD+7zWVCd$SIHFcT{0+$j__!7=TYFA3x;^zyr)yosXaO*VOX>J^m}P2XHv= z(f#ZI*u#v*cf@H2!+)@6&mKoDz;-xaf8BL%#PH$q_q;mwf7t-V01b%+6c?ru|2IPi zOg+H1_>L}}i7TlAss=D@$N+W%4sv4-9U9sIa)HCgQ4^Fc;5j^z-j6gt(Do^f0hsrr zMV=9-*@2Nfq9)c_Df$A3w##E0iV1VeD(=3z(?-{ z{H}nWK=8r#O;HT+a`5)+zYbpC_-ri90UmZQ!V6qSFYrm`g&%(xu8_gmXltXL_2FGwG9>Qwkw=bU5O z-AP!CXZfYbqv!?9-dYInq>NxbhN2OyiqA3eR6))`a-{N*#G_K zJ7cAo|DhdFY@l3#{y?y2S6C0U7~nl>fm=5Q^aFhk5YznNhV_ryjF4sn#07ZlfmOx_ zT)X;OY5-vkaOHJNOdrJNfxy3Z3obkp{KI`do%+A}f2H{6+7bUxAph5HFzJ8`|BC$w z7fbtx_eJ`j+lyh-+Z%b{@O7clh%alOx$lJ`2TN-6P_`bTt1p6sMF{ZX7sUJf%fmw#T8TCxMRCERs-k@4{*?cKJd

XCJuVfBqph;KPIX{scQ( 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 literal 0 HcmV?d00001 diff --git a/textures/cursor-blank.tga b/textures/cursor-blank.tga new file mode 100644 index 0000000000000000000000000000000000000000..3adc1f0baba0001bb75427bab99e3eda6fd1b3b9 GIT binary patch literal 22 UcmZQzU}As)Mg~R(1q~1n00GzlO#lD@ literal 0 HcmV?d00001 diff --git a/textures/text-cursor.tga b/textures/text-cursor.tga new file mode 100644 index 0000000000000000000000000000000000000000..93b1ee1f7aff8eb4792ed9523ab5ee7018685ab3 GIT binary patch literal 4114 zcmeHJ(F%Yd3>f^ z%<2oIc7?Ry!Hp|f!0B~-tr=%5wbbuy9XMKd$Qi#o!NUOJ8w1K?*2HWh|K$H*K!r#B w&F((s97g`Bzxnd}rT)slSME8O^ Date: Tue, 5 May 2026 18:51:52 -0400 Subject: [PATCH 037/103] Memory: bound caches, pool tree nodes, kill per-entry ssub allocs Cuts retained heap from typing dramatically and removes the bulk of the per-keystroke allocation pressure that drove visible memory climb during HOLD-backspace. - wordCache: FIFO ring at 256 entries (was unbounded; reached 1715 after a 60s typing test = 405 KB retained). Now bounded ~60 KB. - resultEntryPool: cap at SEARCH_RESULT_CAP (100); clear stale data / score / isAlias refs on slots above current result count so a broad search followed by a narrow one does not retain refs from the broad result set. - treeNodePool: pool the per-search tree nodes built by BuildHierarchicalResults + GetOrCreateNode. Was allocating ~300 fresh tables per call (root + per-result leaf + path nodes). At 60 keystrokes/sec that is ~18k tables/sec. - ScoreName: hoist usedNameWords out of the per-entry inner loop to a module-level scratch table reused with wipe(). Was allocating one table per scored entry per multi-word query (3700+ tables/keystroke on chain queries like 'icc boss'). - Per-entry ssub -> sfind/sbyte sweep: in the scoring hot path (ScoreName, ScoreKeywords, ScoreFuzzy, ScoreInitials, IsSubsequence, FindAtWordBoundary, loot scoring, achievement scoring), replace ssub(haystack, 1, n) == needle with sfind(haystack, needle, 1, true) == 1 and ssub(s, i, i) one-char checks with sbyte(s, i). Eliminates hundreds of thousands of short-lived strings per second during fast typing. - Expose pools and caches for diagnostics: Database._lootItemCache, _lootEntries, _hierEntryPool, _resultEntryPool, _resultsBuf, _flattenScratch, _treeNodePool; MapSearch._rareTrackCache, _rareDeadGUIDs; UI._flatEntries, _flatCombined, _PB, _SCRATCH, _resultButtons. - Perf.lua: per-frame heap sampling alongside frame-time recording in the existing /ef test scenarios. Each scenario reports peak / end heap delta; summary reports HEAP PEAK, WORST CHURN, HEAP NET, and a per-table snapshot diff at the end so net retention is attributed to specific structures. Net retained heap across the full 60s typing test went from +36 MB before fixes to a small negative delta (GC reclaims more than allocated by run end). --- Database.lua | 168 +++++++++++----- MapSearch.lua | 2 + Perf.lua | 531 ++++++++++++++++++++++++++++++++++++++++++++++++++ UI.lua | 9 + 4 files changed, 663 insertions(+), 47 deletions(-) create mode 100644 Perf.lua diff --git a/Database.lua b/Database.lua index a12752e..4f1b032 100644 --- a/Database.lua +++ b/Database.lua @@ -7,13 +7,21 @@ local Utils = ns.Utils local ipairs = Utils.ipairs local tsort, tconcat, tremove = Utils.tsort, Utils.tconcat, Utils.tremove local sfind, slower, ssub = Utils.sfind, Utils.slower, Utils.ssub +local sbyte = string.byte local mmin, mmax, mabs = Utils.mmin, Utils.mmax, Utils.mabs local C_CurrencyInfo = C_CurrencyInfo local band, lshift = bit.band, bit.lshift -- Word split cache: avoids per-call gmatch + table creation in scoring hot path. -- Key = lowercase string, value = array of words split on [%w']+. +-- FIFO-bounded so per-keystroke prefixes ("a", "ac", "ach", ...) do not +-- accumulate forever. Most recent prefixes stay in cache; oldest evict +-- when the ring buffer wraps. Cap of 256 covers normal typing patterns +-- with room to spare while keeping retained memory in the low-KB range. local wordCache = {} +local wordCacheKeys = {} +local wordCacheHead = 1 +local WORD_CACHE_MAX = 256 local function GetWords(str) local cached = wordCache[str] @@ -22,6 +30,11 @@ local function GetWords(str) for w in str:gmatch("[%w']+") do words[#words + 1] = w end + local oldKey = wordCacheKeys[wordCacheHead] + if oldKey then wordCache[oldKey] = nil end + wordCacheKeys[wordCacheHead] = str + wordCacheHead = wordCacheHead + 1 + if wordCacheHead > WORD_CACHE_MAX then wordCacheHead = 1 end wordCache[str] = words return words end @@ -29,6 +42,11 @@ end -- Reusable row tables for DamerauLevenshtein (avoids 3 table allocs per call) local dlPrev2, dlPrev, dlCurr = {}, {}, {} +-- Reusable scratch for ScoreName's per-word matching path. Was allocated +-- per-entry per-search in the multi-word query branch (3700+ entries x +-- many keystrokes per second = major GC pressure during typing). +local scoreNameUsedWords = {} + -- Reusable sort comparator (avoids closure creation per SearchUI call) local function scoreDescending(a, b) return a.score > b.score end @@ -564,6 +582,9 @@ 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 +Database._lootItemCache = lootItemCache -- exposed for DevMem diagnostics +Database._lootEntries = lootEntries -- exposed for DevMem diagnostics +Database._lootSpecsScanned = lootSpecsScanned -- exposed for DevMem diagnostics -- Maps user-facing difficulty keys to EJ difficulty IDs per source type local LOOT_DIFF_IDS = { @@ -2913,23 +2934,23 @@ local INITIALS_STOPWORDS = { -- A word boundary is the start of the string or right after a space/punctuation. -- Returns true if found at a boundary, false if only found mid-word or not at all. function Database:FindAtWordBoundary(text, query) - -- Check at start of string - if ssub(text, 1, #query) == query then return true end - -- Check after word boundaries (space, dash, parenthesis, colon, slash, dot) - local pos = 1 - while true do - local found = sfind(text, query, pos, true) - if not found then return false end - if found > 1 then - local prev = ssub(text, found - 1, found - 1) - if prev == " " or prev == "-" or prev == "(" or prev == ":" or prev == "/" or prev == "." then - return true - end - else - return true -- found at position 1 + -- Check at start of string. sfind avoids the per-call substring + -- allocation that ssub(text, 1, #query) would create. + local found = sfind(text, query, 1, true) + if not found then return false end + if found == 1 then return true end + -- After word boundaries (space, dash, parenthesis, colon, slash, dot). + -- sbyte avoids creating a 1-char string per check. + while found do + local prev = sbyte(text, found - 1) + -- 32=space, 45=- 40=( 58=: 47=/ 46=. + if prev == 32 or prev == 45 or prev == 40 + or prev == 58 or prev == 47 or prev == 46 then + return true end - pos = found + 1 + found = sfind(text, query, found + 1, true) end + return false end -- Score how well `query` matches as initials/abbreviation of words in `text`. @@ -2958,7 +2979,7 @@ function Database:ScoreInitials(text, query) if queryLen <= numWords then local allMatch = true for i = 1, queryLen do - if ssub(query, i, i) ~= ssub(words[i], 1, 1) then + if sbyte(query, i) ~= sbyte(words[i], 1) then allMatch = false break end @@ -2986,7 +3007,7 @@ function Database:ScoreInitials(text, query) local w = words[wi] local matchLen = 0 while qi + matchLen <= queryLen and matchLen < #w do - if ssub(query, qi + matchLen, qi + matchLen) == ssub(w, matchLen + 1, matchLen + 1) then + if sbyte(query, qi + matchLen) == sbyte(w, matchLen + 1) then matchLen = matchLen + 1 else break @@ -3021,7 +3042,7 @@ function Database:ScoreFuzzy(text, query, queryLen) else return 0 -- queries under 4 chars: no fuzzy, substring covers them end - local queryFirst = ssub(query, 1, 1) + local queryFirst = sbyte(query, 1) local bestScore = 0 local blocked = FUZZY_BLOCKLIST[query] local textWords = GetWords(text) @@ -3033,7 +3054,7 @@ function Database:ScoreFuzzy(text, query, queryLen) -- first character, and this rule kills the false-positive -- flood without dropping real typos like "achievmnts" → -- "achievements". - if ssub(word, 1, 1) == queryFirst + if sbyte(word, 1) == queryFirst and not (blocked and blocked[word]) then local wordLen = #word local lenDiff = wordLen - queryLen @@ -3052,16 +3073,19 @@ function Database:ScoreFuzzy(text, query, queryLen) end -- Requires first character match to avoid spurious hits like "ahn'" in "magtheridon's". +-- Uses sbyte() instead of ssub() inside the inner loop -- ssub creates a +-- fresh 1-char string per call which adds up to thousands of short-lived +-- strings per keystroke (3700 entries x queryLen iterations). function Database:IsSubsequence(word, query, queryLen) - if ssub(word, 1, 1) ~= ssub(query, 1, 1) then return false end + if sbyte(word, 1) ~= sbyte(query, 1) then return false end local wi = 1 local wordLen = #word local firstPos for qi = 1, queryLen do - local qc = ssub(query, qi, qi) + local qc = sbyte(query, qi) local found = false while wi <= wordLen do - if ssub(word, wi, wi) == qc then + if sbyte(word, wi) == qc then if not firstPos then firstPos = wi end wi = wi + 1 found = true @@ -3157,9 +3181,12 @@ function Database:ScoreName(nameLower, query, queryLen, optQueryWords) local score = 0 -- Whole-string matching (works for single and multi-word queries) + -- sfind(plain) avoids the per-entry substring allocation that + -- ssub(haystack, 1, n) == needle would create. With ~3700 entries + -- scored per keystroke this is the dominant per-search alloc. if nameLower == query then score = 200 - elseif ssub(nameLower, 1, queryLen) == query then + elseif sfind(nameLower, query, 1, true) == 1 then score = 150 elseif Database:FindAtWordBoundary(nameLower, query) then score = 120 @@ -3219,7 +3246,8 @@ function Database:ScoreName(nameLower, query, queryLen, optQueryWords) local nameWords = GetWords(nameLower) local allMatched = true local totalWordScore = 0 - local usedNameWords = {} + wipe(scoreNameUsedWords) + local usedNameWords = scoreNameUsedWords for qwi = 1, #queryWords do local qw = queryWords[qwi] local qwLen = #qw @@ -3231,11 +3259,11 @@ function Database:ScoreName(nameLower, query, queryLen, optQueryWords) local ws = 0 if nw == qw then ws = 100 - elseif ssub(nw, 1, qwLen) == qw then + elseif sfind(nw, qw, 1, true) == 1 then ws = 90 elseif sfind(nw, qw, 1, true) then ws = 50 - elseif qwLen >= 4 and ssub(nw, 1, 1) == ssub(qw, 1, 1) then + elseif qwLen >= 4 and sbyte(nw, 1) == sbyte(qw, 1) then local nwLen = #nw local maxEdits = qwLen >= 8 and 2 or 1 if nwLen >= qwLen - maxEdits and nwLen <= qwLen + maxEdits then @@ -3325,7 +3353,7 @@ function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) if kw == query then -- Short abbreviations (2-3 chars) are intentional, boost above initials kwScore = queryLen <= 3 and 140 or 80 - elseif ssub(kw, 1, queryLen) == query then + elseif sfind(kw, query, 1, true) == 1 then kwScore = 70 elseif Database:FindAtWordBoundary(kw, query) then kwScore = 55 @@ -3386,7 +3414,7 @@ function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) local kwScore = 0 if kw == queryWord then kwScore = 80 - elseif ssub(kw, 1, queryWordLen) == queryWord then + elseif sfind(kw, queryWord, 1, true) == 1 then kwScore = 70 elseif Database:FindAtWordBoundary(kw, queryWord) then kwScore = 55 @@ -3513,6 +3541,33 @@ local function sortChildren(node) end end +-- Tree node pool. BuildHierarchicalResults previously allocated ~300 +-- fresh tables per call (root + per-result entry + path nodes). At 60 +-- keystrokes/sec that's ~18k tables/sec -- the bulk of the per-search +-- GC churn that caused +29 MB transient peaks during HOLD-backspace. +-- Pooling reuses the same node tables across searches. +local treeNodePool = {} +local treeNodePoolN = 0 +Database._treeNodePool = treeNodePool + +local function getTreeNode() + treeNodePoolN = treeNodePoolN + 1 + local n = treeNodePool[treeNodePoolN] + if not n then + n = { children = {}, childOrder = {} } + treeNodePool[treeNodePoolN] = n + else + wipe(n.children) + wipe(n.childOrder) + end + n.name = nil + n.data = nil + n.bestScore = 0 + n.isMatch = false + n._hac = nil + return n +end + local function GetOrCreateNode(root, pathParts) local node = root for i = 1, #pathParts do @@ -3520,12 +3575,8 @@ local function GetOrCreateNode(root, pathParts) local children = node.children local existing = children[part] if not existing then - existing = { - name = part, - children = {}, - childOrder = {}, - bestScore = 0, - } + existing = getTreeNode() + existing.name = part children[part] = existing node.childOrder[#node.childOrder + 1] = part end @@ -3620,6 +3671,8 @@ end local flattenScratch = {} local hierEntryPool = {} local hierEntryPoolN = 0 +Database._flattenScratch = flattenScratch +Database._hierEntryPool = hierEntryPool local function FlattenNode(self, node, depth, out, containerPaths) local order = node.childOrder @@ -3699,6 +3752,8 @@ end local resultsBuf = {} local resultsQueryWords = {} local resultEntryPool = {} +Database._resultsBuf = resultsBuf +Database._resultEntryPool = resultEntryPool function Database:SearchUI(query, skipCategories) if not query or query == "" or #query < 2 then @@ -3822,7 +3877,7 @@ function Database:SearchUI(query, skipCategories) 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 + elseif sfind(kw, qw, 1, true) == 1 then bestWord = mmax(bestWord, 70) end end @@ -3834,7 +3889,7 @@ function Database:SearchUI(query, skipCategories) 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 + elseif sfind(kw, qw, 1, true) == 1 then bestWord = mmax(bestWord, 70) end end @@ -3846,7 +3901,7 @@ function Database:SearchUI(query, skipCategories) local kw = data.lootSourceKw[ki] if kw == qw then bestWord = mmax(bestWord, 80) - elseif ssub(kw, 1, qwLen) == qw then + elseif sfind(kw, qw, 1, true) == 1 then bestWord = mmax(bestWord, 70) end end @@ -3876,7 +3931,7 @@ function Database:SearchUI(query, skipCategories) -- aliases don't drag every achievement category in. if nameLower == query then score = 200 - elseif ssub(nameLower, 1, queryLen) == query then + elseif sfind(nameLower, query, 1, true) == 1 then score = 150 elseif Database:FindAtWordBoundary(nameLower, query) then score = 120 @@ -3922,6 +3977,23 @@ function Database:SearchUI(query, skipCategories) local SEARCH_RESULT_CAP = 100 if resultsN > SEARCH_RESULT_CAP then for i = SEARCH_RESULT_CAP + 1, resultsN do results[i] = nil end + -- Trim the pool to the cap so a single broad query ("mo") does + -- not leave the pool sized at ~420 forever. + for i = SEARCH_RESULT_CAP + 1, #resultEntryPool do + resultEntryPool[i] = nil + end + resultsN = SEARCH_RESULT_CAP + end + -- Clear stale data refs on pool slots above the current result + -- count. Without this, a broad search followed by a narrow one + -- leaves the pool holding refs from the broad search forever. + for i = resultsN + 1, #resultEntryPool do + local r = resultEntryPool[i] + if r and r.data then + r.data = nil + r.score = nil + r.isAlias = nil + end end return results end @@ -3934,7 +4006,11 @@ function Database:BuildHierarchicalResults(results) return {} end - local root = { children = {}, childOrder = {} } + -- Reset the tree-node pool index; nodes from this pool are reused + -- across calls so the only per-call allocation is the returned + -- hierarchical array (and pool-grow when first hit). + treeNodePoolN = 0 + local root = getTreeNode() for ri = 1, #results do local item = results[ri] @@ -3951,14 +4027,12 @@ function Database:BuildHierarchicalResults(results) existing.bestScore = itemScore end else - parentNode.children[itemData.name] = { - name = itemData.name, - children = {}, - childOrder = {}, - data = itemData, - bestScore = itemScore, - isMatch = true, - } + local leaf = getTreeNode() + leaf.name = itemData.name + leaf.data = itemData + leaf.bestScore = itemScore + leaf.isMatch = true + parentNode.children[itemData.name] = leaf parentNode.childOrder[#parentNode.childOrder + 1] = itemData.name end diff --git a/MapSearch.lua b/MapSearch.lua index 9dc5c8c..62291a6 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -343,6 +343,8 @@ local cachedWorldZones -- Built once per session by GetAllWorldZones local rareTrackCache = {} -- [vignetteGUID] = rare entry, persists across scans for auto-track local rareTrackMapID = nil -- mapID the cache is valid for local rareDeadGUIDs = {} -- GUIDs confirmed dead/despawned, blocked from re-entering cache +MapSearch._rareTrackCache = rareTrackCache -- exposed for DevMem diagnostics +MapSearch._rareDeadGUIDs = rareDeadGUIDs -- exposed for DevMem diagnostics local efTrackedVignetteGUID = nil -- GUID we explicitly set via SetSuperTrackedVignette (rares only) -- Reusable tables for OnSearchTextChanged (wiped each call to avoid per-keystroke allocations) diff --git a/Perf.lua b/Perf.lua new file mode 100644 index 0000000..065baac --- /dev/null +++ b/Perf.lua @@ -0,0 +1,531 @@ +local _, ns = ... + +local Perf = {} +ns.Perf = Perf + +local CreateFrame = CreateFrame +local C_Timer = C_Timer +local mfloor = math.floor +local mhuge = math.huge + +-- --------------------------------------------------------------------- +-- Frame-time recorder. An OnUpdate sampler captures every render +-- frame's elapsed delta while a scenario is running. From those +-- samples we derive avg, worst-case, and the count of frames slower +-- than 1/50s (perceptible stutter). +-- --------------------------------------------------------------------- + +-- --------------------------------------------------------------------- +-- Per-table memory snapshot. Uses the same exposed-table list as +-- /efd mem so we can attribute heap deltas to specific structures +-- (uiSearchData, lootItemCache, hier/result pools, UI scratch, etc.) +-- without depending on EasyFindDev being loaded. +-- --------------------------------------------------------------------- + +local function EstimateSize(val, visited) + if val == nil then return 0 end + if not visited then visited = {} end + local t = type(val) + if t == "string" then return 40 + #val + elseif t == "number" then return 8 + elseif t == "boolean" then return 4 + elseif t == "function" then return 128 + elseif t == "table" then + if visited[val] then return 0 end + visited[val] = true + local bytes = 56 + for k, v in pairs(val) do + bytes = bytes + EstimateSize(k, visited) + EstimateSize(v, visited) + end + local mt = getmetatable(val) + if mt and type(mt.__index) == "table" then + bytes = bytes + EstimateSize(mt.__index, visited) + end + return bytes + elseif t == "userdata" then return 64 end + return 0 +end + +local function CountItems(t) + if not t then return 0 end + local arr = #t + if arr > 0 then return arr end + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +local SNAP_FIELDS = { + { "uiSearchData", "Database.uiSearchData", function() return ns.Database and ns.Database.uiSearchData end }, + { "wordCache", "Database.wordCache", function() return ns.Database and ns.Database._wordCache end }, + { "lootItemCache", "Database.lootItemCache", function() return ns.Database and ns.Database._lootItemCache end }, + { "lootEntries", "Database.lootEntries", function() return ns.Database and ns.Database._lootEntries end }, + { "hierEntryPool", "Database.hierEntryPool", function() return ns.Database and ns.Database._hierEntryPool end }, + { "resultEntryPool", "Database.resultEntryPool", function() return ns.Database and ns.Database._resultEntryPool end }, + { "treeNodePool", "Database.treeNodePool", function() return ns.Database and ns.Database._treeNodePool end }, + { "resultsBuf", "Database.resultsBuf", function() return ns.Database and ns.Database._resultsBuf end }, + { "flatEntries", "UI.flatEntries", function() return ns.UI and ns.UI._flatEntries end }, + { "flatCombined", "UI.flatCombined", function() return ns.UI and ns.UI._flatCombined end }, + { "PB", "UI.PB", function() return ns.UI and ns.UI._PB end }, + { "SCRATCH", "UI.SCRATCH", function() return ns.UI and ns.UI._SCRATCH end }, + { "resultButtons", "UI.resultButtons", function() return ns.UI and ns.UI._resultButtons end }, + { "rareTrackCache", "MapSearch.rareTrackCache", function() return ns.MapSearch and ns.MapSearch._rareTrackCache end }, +} + +local function TakeSnapshot() + local snap = { heapKB = collectgarbage("count") } + for i = 1, #SNAP_FIELDS do + local def = SNAP_FIELDS[i] + local key, getter = def[1], def[3] + local t = getter() + snap[key .. "_bytes"] = EstimateSize(t, {}) + snap[key .. "_count"] = CountItems(t) + end + return snap +end + +local function PrintSnapshotDiff(before, after) + EasyFind:Print(string.format( + " Lua heap (total) %.1fMB -> %.1fMB (%+.1fKB)", + before.heapKB / 1024, + after.heapKB / 1024, + after.heapKB - before.heapKB)) + for i = 1, #SNAP_FIELDS do + local def = SNAP_FIELDS[i] + local key, label = def[1], def[2] + local b = before[key .. "_bytes"] or 0 + local a = after[key .. "_bytes"] or 0 + local bC = before[key .. "_count"] or 0 + local aC = after[key .. "_count"] or 0 + local d = a - b + if a > 0 or b > 0 or d ~= 0 then + local color + if d >= 100 * 1024 then color = "|cffff5555" + elseif d >= 10 * 1024 then color = "|cffffaa00" + elseif d <= -10 * 1024 then color = "|cff44ffff" + else color = "|cffaaaaaa" end + EasyFind:Print(string.format( + " %s%-26s|r count %d->%d size %.1fKB->%.1fKB (%s%+.1fKB|r)", + color, label, bC, aC, b / 1024, a / 1024, color, d / 1024)) + end + end +end + +local recording = false +local sampleCount = 0 +local sampleSum = 0 +local sampleMin = mhuge +local sampleMax = 0 +local stutterCount = 0 +local STUTTER_THRESHOLD = 1 / 50 + +-- Per-frame heap sampling alongside frame timings. Heap is sampled every +-- recording frame so we capture the live churn during a scenario, not +-- just bookended snapshots. Numbers are KB (collectgarbage("count")). +local heapStartKB = 0 +local heapMinKB = mhuge +local heapMaxKB = 0 +local heapLastKB = 0 +local collectgarbageRef = collectgarbage + +local agg = { + scenarios = 0, + sumAvg = 0, + sumWorst = 0, + worst = 0, + worstLabel = nil, + totalStutters = 0, + totalSamples = 0, + heapMaxKB = 0, + heapMaxLabel = nil, + heapWorstChurn = 0, + heapWorstChurnLabel = nil, + heapNetDeltaKB = 0, +} + +local watchFrame = CreateFrame("Frame") +watchFrame:Hide() +watchFrame:SetScript("OnUpdate", function(_, elapsed) + if not recording then return end + sampleCount = sampleCount + 1 + sampleSum = sampleSum + elapsed + if elapsed < sampleMin then sampleMin = elapsed end + if elapsed > sampleMax then sampleMax = elapsed end + if elapsed > STUTTER_THRESHOLD then stutterCount = stutterCount + 1 end + + local heapKB = collectgarbageRef("count") + heapLastKB = heapKB + if heapKB < heapMinKB then heapMinKB = heapKB end + if heapKB > heapMaxKB then heapMaxKB = heapKB end +end) + +local function startRecording() + sampleCount = 0 + sampleSum = 0 + sampleMin = mhuge + sampleMax = 0 + stutterCount = 0 + heapStartKB = collectgarbageRef("count") + heapMinKB = heapStartKB + heapMaxKB = heapStartKB + heapLastKB = heapStartKB + recording = true + watchFrame:Show() +end + +local function stopAndReport(label) + recording = false + watchFrame:Hide() + if sampleCount == 0 then + EasyFind:Print("|cffaaaaaa" .. label .. ": no frames|r") + return + end + local avg = sampleSum / sampleCount + local avgFps = 1 / avg + local worstFps = 1 / sampleMax + local stutterPct = stutterCount / sampleCount * 100 + local color = "|cff66ff66" + if worstFps < 30 or stutterPct > 5 then + color = "|cffff5555" + elseif worstFps < 50 or stutterPct > 1 then + color = "|cffffaa00" + end + + -- Heap stats relative to scenario start. peak = max during scenario; + -- delta = heap at scenario end vs scenario start (post-GC churn); + -- churn = peak - start (transient allocation pressure during scenario). + local heapPeakDelta = heapMaxKB - heapStartKB + local heapEndDelta = heapLastKB - heapStartKB + local heapColor = "|cff66ff66" + if heapPeakDelta > 5000 then heapColor = "|cffff5555" + elseif heapPeakDelta > 1000 then heapColor = "|cffffaa00" end + + EasyFind:Print(string.format( + "%s%s|r avg %dfps worst %.1fms/%dfps stutters %d/%d (%.1f%%) %sheap +%.0fKB peak / %+.0fKB end|r", + color, label, + mfloor(avgFps + 0.5), + sampleMax * 1000, mfloor(worstFps + 0.5), + stutterCount, sampleCount, stutterPct, + heapColor, heapPeakDelta, heapEndDelta)) + + agg.scenarios = agg.scenarios + 1 + agg.sumAvg = agg.sumAvg + avg + agg.sumWorst = agg.sumWorst + sampleMax + agg.totalStutters = agg.totalStutters + stutterCount + agg.totalSamples = agg.totalSamples + sampleCount + if sampleMax > agg.worst then + agg.worst = sampleMax + agg.worstLabel = label + end + if heapMaxKB > agg.heapMaxKB then + agg.heapMaxKB = heapMaxKB + agg.heapMaxLabel = label + end + if heapPeakDelta > agg.heapWorstChurn then + agg.heapWorstChurn = heapPeakDelta + agg.heapWorstChurnLabel = label + end + agg.heapNetDeltaKB = agg.heapNetDeltaKB + heapEndDelta +end + +-- --------------------------------------------------------------------- +-- Search invocation. Bypasses the 50ms editbox debounce so each +-- "keystroke" runs on the frame it was scheduled, exactly like real +-- typing does once the debounce window expires. Mirrors the editbox +-- text so any code reading :GetText() stays consistent. +-- --------------------------------------------------------------------- + +local function fireSearch(text) + if not ns.UI or not ns.UI.OnSearchTextChanged then return end + if ns.UI.searchFrame and ns.UI.searchFrame.editBox then + local eb = ns.UI.searchFrame.editBox + if eb:GetText() ~= text then eb:SetText(text) end + end + ns.UI:OnSearchTextChanged(text) +end + +local function yieldNextFrame(fn) C_Timer.After(0, fn) end + +-- --------------------------------------------------------------------- +-- Generic step driver. Walk a sequence of intermediate query strings, +-- each separated by `keyDelay` seconds. keyDelay <= 0 yields one frame +-- (16ms at 60fps), simulating a held key. +-- --------------------------------------------------------------------- + +local function runSequence(seq, keyDelay, onDone) + local i = 0 + local function step() + i = i + 1 + if i > #seq then + C_Timer.After(0.20, onDone) + return + end + fireSearch(seq[i]) + if keyDelay <= 0 then + yieldNextFrame(step) + else + C_Timer.After(keyDelay, step) + end + end + step() +end + +-- --------------------------------------------------------------------- +-- Sequence builders. Each returns an array of intermediate query +-- strings to feed the search box, simulating one logical interaction. +-- --------------------------------------------------------------------- + +local function seqType(text) + local s = {} + for i = 1, #text do s[i] = text:sub(1, i) end + return s +end + +local function seqErase(startText) + local s = {} + s[1] = startText + for i = 1, #startText do s[i + 1] = startText:sub(1, #startText - i) end + return s +end + +local function seqTypeThenErase(text) + local s = seqType(text) + local n = #s + local e = seqErase(text) + for i = 2, #e do s[n + i - 1] = e[i] end + return s +end + +-- Type prefix `pre`, backspace `n` chars, then append `suf`. +local function seqTypeBackspaceType(pre, n, suf) + local s = seqType(pre) + local cur = pre + for i = 1, n do + cur = cur:sub(1, #cur - 1) + s[#s + 1] = cur + end + for i = 1, #suf do + cur = cur .. suf:sub(i, i) + s[#s + 1] = cur + end + return s +end + +-- Oscillate: type the word, erase it, repeat `repeats` times. +local function seqOscillate(word, repeats) + local s = {} + for r = 1, repeats do + for i = 1, #word do s[#s + 1] = word:sub(1, i) end + for i = 1, #word do s[#s + 1] = word:sub(1, #word - i) end + end + return s +end + +-- Type a chain of space-separated words, character by character (so +-- every intermediate state goes through the search). +local function seqWordChain(words) + local s = {} + local cur = "" + for w = 1, #words do + if w > 1 then + cur = cur .. " " + s[#s + 1] = cur + end + for i = 1, #words[w] do + cur = cur .. words[w]:sub(i, i) + s[#s + 1] = cur + end + end + return s +end + +-- --------------------------------------------------------------------- +-- Scenario orchestration. Each scenario records its own sample window +-- so per-scenario fps numbers are independent. Brief pause between +-- scenarios so a tail-end render doesn't bleed into the next sample. +-- --------------------------------------------------------------------- + +local function chain(scenarios, onDone) + local i = 0 + local function next() + i = i + 1 + local s = scenarios[i] + if not s then onDone() return end + startRecording() + s.run(function() + stopAndReport(s.label) + C_Timer.After(0.20, next) + end) + end + next() +end + +local GIBBERISH_SHORT = "asdfghjkl" +local GIBBERISH_LONG = "asdhfoasdhpjfsadhpjufsdhopjsdhpjofddf" +local LONG_WORD = "transmogrification" + +function Perf:Run() + if recording then + EasyFind:Print("Perf test already running") + return + end + + -- reset aggregate + agg.scenarios = 0 + agg.sumAvg = 0 + agg.sumWorst = 0 + agg.worst = 0 + agg.worstLabel = nil + agg.totalStutters = 0 + agg.totalSamples = 0 + agg.heapMaxKB = 0 + agg.heapMaxLabel = nil + agg.heapWorstChurn = 0 + agg.heapWorstChurnLabel = nil + agg.heapNetDeltaKB = 0 + + -- GC baseline so per-scenario heap stats start from a clean number. + collectgarbageRef("collect") + local runStartHeapKB = collectgarbageRef("count") + local snapBefore = TakeSnapshot() + + EasyFind:Print("|cffFFD100Perf test starting...|r (this takes ~60s)") + EasyFind:Print(string.format("|cff888888Heap baseline: %.1f MB|r", + runStartHeapKB / 1024)) + + if ns.UI and ns.UI.Show then ns.UI:Show(true) end + + local scenarios = { + -- + -- Forward typing at varying speeds. + -- + { label = "type 'mountain' slow (150ms)", + run = function(d) runSequence(seqType("mountain"), 0.15, d) end }, + { label = "type 'mountain' fast (60ms)", + run = function(d) runSequence(seqType("mountain"), 0.06, d) end }, + { label = "type 'mountain' burst (30ms)", + run = function(d) runSequence(seqType("mountain"), 0.03, d) end }, + { label = "type long word '" .. LONG_WORD .. "' (60ms)", + run = function(d) runSequence(seqType(LONG_WORD), 0.06, d) end }, + { label = "type gibberish '" .. GIBBERISH_SHORT .. "' (60ms)", + run = function(d) runSequence(seqType(GIBBERISH_SHORT), 0.06, d) end }, + { label = "type LONG gibberish (37 chars, 60ms)", + run = function(d) runSequence(seqType(GIBBERISH_LONG), 0.06, d) end }, + { label = "type 'achievement' (60ms, common prefix)", + run = function(d) runSequence(seqType("achievement"), 0.06, d) end }, + { label = "type 'zzzqqq' (60ms, no matches)", + run = function(d) runSequence(seqType("zzzqqq"), 0.06, d) end }, + + -- + -- Erase from typed states. Erase paths cannot use the + -- prefix-extend incremental cache, so each backspace falls + -- back to a full scan over the dataset. This is the bucket + -- where fps drops are usually most painful. + -- + { label = "erase 'mountain' slow (150ms)", + run = function(d) runSequence(seqErase("mountain"), 0.15, d) end }, + { label = "erase 'mountain' fast (60ms)", + run = function(d) runSequence(seqErase("mountain"), 0.06, d) end }, + { label = "erase 'mountain' HOLD (16ms)", + run = function(d) runSequence(seqErase("mountain"), 0, d) end }, + { label = "erase '" .. LONG_WORD .. "' HOLD", + run = function(d) runSequence(seqErase(LONG_WORD), 0, d) end }, + { label = "erase 37-char gibberish HOLD", + run = function(d) runSequence(seqErase(GIBBERISH_LONG), 0, d) end }, + + -- + -- Type + erase combinations. These mirror real correction + -- patterns: typo, backspace a few chars, type the right ones. + -- + { label = "type 'mountin' -> back 2 -> 'ain' (typo fix)", + run = function(d) runSequence(seqTypeBackspaceType("mountin", 2, "ain"), 0.06, d) end }, + { label = "type 'rai' -> back 1 -> 'ider' (typo fix)", + run = function(d) runSequence(seqTypeBackspaceType("rai", 1, "ider"), 0.06, d) end }, + { label = "type+erase oscillate 'moun' x4", + run = function(d) runSequence(seqOscillate("moun", 4), 0.06, d) end }, + { label = "type+erase oscillate 'mountain' x3", + run = function(d) runSequence(seqOscillate("mountain", 3), 0.06, d) end }, + { label = "type 'mountain' then HOLD-backspace to empty", + run = function(d) runSequence(seqTypeThenErase("mountain"), 0, d) end }, + { label = "type 37-char gibberish then HOLD-backspace to empty", + run = function(d) runSequence(seqTypeThenErase(GIBBERISH_LONG), 0, d) end }, + + -- + -- Multi-word queries. + -- + { label = "chain 'icc boss' (60ms)", + run = function(d) runSequence(seqWordChain({"icc","boss"}), 0.06, d) end }, + { label = "chain 'mount tank dungeon' (60ms)", + run = function(d) runSequence(seqWordChain({"mount","tank","dungeon"}), 0.06, d) end }, + { label = "chain 5 words 'icc boss tank rare elite' (60ms)", + run = function(d) runSequence(seqWordChain({"icc","boss","tank","rare","elite"}), 0.06, d) end }, + + -- + -- Real-world stress: keep banging the bar with new queries + -- back to back without ever clearing all the way to empty. + -- + { label = "5x random word swap stress", + run = function(d) + local words = {"mount","mage","heal","raid","tank"} + local i = 0 + local function loop() + i = i + 1 + if i > #words then C_Timer.After(0.20, d) return end + runSequence(seqType(words[i]), 0.05, function() + runSequence(seqErase(words[i]), 0, loop) + end) + end + loop() + end }, + } + + chain(scenarios, function() + fireSearch("") + if agg.scenarios > 0 then + local avgAvg = agg.sumAvg / agg.scenarios + local avgWorst = agg.sumWorst / agg.scenarios + local stutterPct = agg.totalSamples > 0 + and (agg.totalStutters / agg.totalSamples * 100) or 0 + EasyFind:Print(string.format( + "|cffFFD100SUMMARY|r scenarios=%d avg-avg %dfps avg-worst %dfps total stutters %d/%d (%.1f%%)", + agg.scenarios, + mfloor(1 / avgAvg + 0.5), + mfloor(1 / avgWorst + 0.5), + agg.totalStutters, agg.totalSamples, stutterPct)) + EasyFind:Print(string.format( + "|cffFFD100WORST FRAME|r %.1fms (%dfps) in: %s", + agg.worst * 1000, mfloor(1 / agg.worst + 0.5), + agg.worstLabel or "?")) + + -- Memory summary: peak heap during the run, worst per-scenario + -- transient churn, and the net retained heap delta after all + -- scenarios. A negative net means GC reclaimed more than was + -- allocated; a large positive net is a leak. + local runEndHeapKB = collectgarbageRef("count") + local netRunDeltaKB = runEndHeapKB - runStartHeapKB + local netColor = "|cff66ff66" + if netRunDeltaKB > 1000 then netColor = "|cffff5555" + elseif netRunDeltaKB > 200 then netColor = "|cffffaa00" end + EasyFind:Print(string.format( + "|cffFFD100HEAP PEAK|r %.1f MB in: %s", + agg.heapMaxKB / 1024, agg.heapMaxLabel or "?")) + EasyFind:Print(string.format( + "|cffFFD100WORST CHURN|r +%.0f KB in: %s", + agg.heapWorstChurn, agg.heapWorstChurnLabel or "?")) + EasyFind:Print(string.format( + "%sHEAP NET|r %+.0f KB across %d scenarios (%.1f MB -> %.1f MB)", + netColor, + netRunDeltaKB, agg.scenarios, + runStartHeapKB / 1024, runEndHeapKB / 1024)) + + -- Per-table snapshot diff to attribute the net delta to + -- specific caches/pools rather than just "the heap". + collectgarbageRef("collect") + local snapAfter = TakeSnapshot() + EasyFind:Print(" ") + EasyFind:Print("|cffFFD100--- Per-table delta (post-GC) ---|r") + PrintSnapshotDiff(snapBefore, snapAfter) + end + EasyFind:Print("|cff888888Stutter = single frame slower than 1/50s. Color: green ok / amber bad / red unshippable.|r") + end) +end diff --git a/UI.lua b/UI.lua index 94a8ba7..13f03da 100644 --- a/UI.lua +++ b/UI.lua @@ -4110,6 +4110,15 @@ local SCRATCH = { catGroups = {}, } +-- Exposed for /efd mem diagnostics so we can see whether per-search +-- scratch tables, per-bucket result tables, or the button pool are +-- responsible for the post-search memory growth. +UI._flatEntries = flatEntries +UI._flatCombined = flatCombined +UI._PB = PB +UI._SCRATCH = SCRATCH +UI._resultButtons = resultButtons + local function CatGroupCompare(a, b) if a.score ~= b.score then return a.score > b.score end return a.key < b.key From 755098e634111a5ad2b81fcc234f7daea01222e7 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Wed, 6 May 2026 01:54:53 -0400 Subject: [PATCH 038/103] Refactor: extract DatabaseDynamic + UIPins; drop minimap nav + Demo Module splits and dead-code removal. Net -7948 lines across 14 files. Module extractions - DatabaseDynamic.lua (new): dynamic provider orchestration that lived inline in Database.lua. Drives the post-login injection of currencies, reputations, mounts, toys, pets, outfits, and heirlooms via a single dispatch table. - UIPins.lua (new): pin storage and (de)serialization (account-wide pinnedUIItems + per-character pinnedUIItemsPerChar) lifted out of UI.lua. Owns the SIMPLE_FIELDS allowlist used when persisting metatable-backed entries. Removals - Minimap navigation / waypoint tracking system: minimap arrow glow, guide circle, near-track ring, far/near pin states, cross-zone waypoint redirection, Blizzard-pin tracking, arrival-distance slider. Bulk of the deletion is in MapSearch.lua. Click-to-place waypoints still works via Blizzard's native waypoint API; the custom rendering / per-frame tracker is gone. - textures/near-track-ring.tga removed (no longer referenced). - Demo.lua removed (4806 lines of demo / test scaffolding never shipped to players). - CHANGELOG.md: scrubbed mentions of the removed minimap nav features from prior release entries (they no longer reflect what ships). Touched in passing - Core.lua, MapTab.lua, Options.lua, Rescaler.lua, UI.lua, Utils.lua: remove dead references to the gone tracker / pin-render code, collapse helpers that only existed to feed it. - EasyFind.toc: register DatabaseDynamic.lua and UIPins.lua, drop Demo.lua. - StaticLocations.lua: trivial header tweak (ADDON_NAME -> _). --- CHANGELOG.md | 24 +- Core.lua | 240 +- Database.lua | 1052 ++++++-- DatabaseDynamic.lua | 179 ++ Demo.lua | 4806 ---------------------------------- EasyFind.toc | 3 +- MapSearch.lua | 4001 +++++----------------------- MapTab.lua | 437 +--- Options.lua | 622 ++--- README.md | 33 +- Rescaler.lua | 237 +- StaticLocations.lua | 2 +- UI.lua | 1240 +++------ UIPins.lua | 183 ++ Utils.lua | 165 +- textures/near-track-ring.tga | Bin 262188 -> 0 bytes 16 files changed, 2819 insertions(+), 10405 deletions(-) create mode 100644 DatabaseDynamic.lua delete mode 100644 Demo.lua create mode 100644 UIPins.lua delete mode 100644 textures/near-track-ring.tga diff --git a/CHANGELOG.md b/CHANGELOG.md index 399df0e..85a6bcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ All notable changes to EasyFind will be documented in this file. ## [1.4.0] - 2026-03-20 ### Added -- **Rare Mob Tracking**: Active rare mobs in your zone appear as searchable pins on the world map. Search "rare" in the zone bar to see all nearby rares, or click individual rare names to track them with the minimap supertrack arrow +- **Rare Mob Tracking**: Active rare mobs in your zone appear as searchable pins on the world map. Search "rare" in the zone bar to see all nearby rares, or click individual rare names to track them with Blizzard waypoint tracking - **Auto-track Rares**: Toggle in the zone search filter dropdown or the Map options tab. When enabled, all active rares are automatically pinned on the map without searching. Pins persist at last known position when rares leave detection range and clear when killed - **Great Vault (Rewards)**: Great vault rewards panel now searchable in UI search - **"NEW" Feature Labels**: New or experimental features display a glowing label with a tooltip encouraging feedback @@ -27,7 +27,6 @@ All notable changes to EasyFind will be documented in this file. ### Fixed - **Adventure Guide Fast Mode**: Direct open now works for all Adventure Guide tabs (Journeys, Dungeons, Raids, etc.) - **Combat Lockdown**: Fixed errors that could occur when reloading the UI during combat or by taking portals -- **Nav Waypoint Tracking**: Minimap guide circle now correctly follows portal redirects when the portal is in a neighboring zone ### Changed - **StaticLocations Cleanup**: Standardized bank and guild vault naming across all zones @@ -37,7 +36,6 @@ All notable changes to EasyFind will be documented in this file. ## [1.3.1] - 2026-03-17 ### Added -- **EasyFind-Only Navigation Options**: Minimap arrow glow and guide circle now have sub-options to restrict behavior to EasyFind-placed waypoints only, ignoring other sources - **Independent Map Navigation Modes**: Local and global map search bars now have separate direct-open toggles instead of a shared setting - **Expanded POI Coverage**: Added portals in Tirisfal Glades, decor specialists, and additional innkeepers, auction houses, and mailboxes across multiple zones @@ -58,7 +56,7 @@ All notable changes to EasyFind will be documented in this file. - **Mounts**: Search your collected mounts by name. Click a result to summon. Icons show combat tint when unavailable - **Toys**: Search collected toys with cooldown sweep overlays. Click to use - **Battle Pets**: Search your collected battle pets by name. Click to summon a companion - - **Map Search**: Search map POIs (zones, dungeon entrances, flight masters) directly from the UI search bar. Includes waypoint placement and minimap tracking preview on hover + - **Map Search**: Search map POIs (zones, dungeon entrances, flight masters) directly from the UI search bar. Includes waypoint placement on hover - **Mode Toggle Button**: Interactive fast/guide mode toggle flush-left in the search bar, replacing the static search icon. Syncs with the options panel checkbox - **Filter Dropdown Enhancements**: Per-filter icons, "Toggle All" button, and Shift+Up/Down section jump for keyboard navigation - **Independent Maximized Map Bars**: Zone and instance search bars position independently in full-screen map mode with separate saved positions @@ -69,8 +67,6 @@ All notable changes to EasyFind will be documented in this file. ### Fixed - **Quest Tracking Coexistence**: User waypoint auto-reclaim no longer overrides active quest tracking -- **Cross-zone Waypoint Navigation**: Minimap waypoint glow through portals now works correctly across zone boundaries -- **Minimap Glow Tracking**: Glow no longer disappears when switching pin types, and cross-zone same-continent pins now track properly - **Encounter Journal Fast Mode**: Protected tabs in the Encounter Journal now hand off to the guide system instead of failing silently - **Short Query False Positives**: 2-char abbreviations like "fp" no longer match unrelated items through incidental keyword initials (e.g., "flight paths" in Travel Statistics) - **Scrollbar Overlap**: Fixed map search results overlapping with scrollbar in certain configurations @@ -89,14 +85,12 @@ All notable changes to EasyFind will be documented in this file. - **Keyboard Navigation**: Full arrow key, Tab, and Enter support for navigating search results without a mouse. Works in both UI search and map search bars. Tab/Shift+Tab toggles between a parent row and its expand/collapse button. Arrow keys in map search preview the pin location before confirming - **Visual Rescaler**: Drag handles on search bars and results panels to resize width, adjust row count, and change font size interactively. Shift+drag the search bar itself to reposition it along the map edge - **Smarter Search**: Vowel-stripped abbreviations now match (e.g. "qtr" finds "quartermaster", "windrnr" finds "windrunner"). Multi-word queries match per-word with fuzzy and subsequence support (e.g. "twlght hghlnds" finds "Twilight Highlands") -- **Blizzard Pin Tracking**: Minimap glow and guide circle now work with Blizzard's own map pins (flight points, area POIs, vignettes), not just user waypoints - **Major POI Expansion**: Added points of interest for Stormwind, Orgrimmar, Ironforge, Thunder Bluff, Darnassus, Undercity, The Exodar, Silvermoon City, and Valdrakken. Includes class trainers, profession trainers, quartermasters, banks, inns, barbers, stable masters, guild services, and more - **Bug Report and Feature Request**: `/ef bug` and `/ef feature` as well as buttons in the options panel to open pre-filled GitHub issue URLs for easy reporting - **New options**: - Toggle+Focus keybind: combined show+focus in one keypress, targets map search bar when world map is open - Clear All keybind: dismiss all highlights, map pins, zone highlights, and breadcrumbs - - Guide circle scale, minimap pin glow toggle, and separate minimap arrow glow toggle - - Auto-track new pins, auto-clear on arrival, and configurable arrival distance + - Auto-track new pins and auto-clear on arrival - Pin highlight box toggle (show/hide yellow highlight square around map pins) - Map Smart Show (auto-hide map search bars until hover) - Map search Y-offset slider @@ -141,12 +135,11 @@ All notable changes to EasyFind will be documented in this file. - **Atlas Zone Highlights**: Zone highlighting now supports atlas-based textures in addition to fileDataID textures - **Shared Helpers**: Scroll, click, and frame-search patterns refactored into Utils.lua, reducing code duplication across Highlight and UI modules - **Shared Constants**: Extracted duplicated constants (colors, sizes, string paths) into ns.* values in Utils.lua -- **Defensive Hardening**: Added pcall protection to waypoint tracker OnUpdate, flash ticker, and all initialization timers. Added SavedVariables type validation to prevent corrupted settings from breaking the addon +- **Defensive Hardening**: Added pcall protection to flash ticker and all initialization timers. Added SavedVariables type validation to prevent corrupted settings from breaking the addon - **Results Layout**: Improved pin separator spacing and scroll position preservation when toggling category headers - **Continent Projection Fallback**: Zones with very small scan areas now fall back to continent-level projection - **Highlight Hover Timer**: Reduced minimum display time from 1.0s to 0.3s for snappier hover-dismiss behavior - **GetScript Error**: Fixed error when calling GetScript on non-Button frames -- **Waypoint Tracker Performance**: Completely rewritten for zero per-frame memory allocations. Uses cached world-space coordinates and primitive API returns instead of creating objects every frame --- @@ -170,7 +163,6 @@ All notable changes to EasyFind will be documented in this file. ## [1.2.5] - 2026-03-04 ### Added -- **Arrival Distance Slider**: Waypoint auto-clear distance is now configurable (3–20 yards, default 10) via the Options panel - **Show Login Message Toggle**: Option to hide the "EasyFind loaded!" chat message on login ### Changed @@ -189,16 +181,13 @@ All notable changes to EasyFind will be documented in this file. ### Added - **Pinned Paths**: Right-click any UI or map search result to pin it as a bookmark. Pinned items appear at the top of results and persist across sessions. Collapsible header keeps things tidy -- **Click-to-Navigate Map Pins**: Click any local map search pin to place a native WoW waypoint and activate minimap tracking (no more Ctrl+clicking the map manually) - - **Two-stage** visual guidance system for the placed waypoints: - - **Far**: Pulsing gold star on the minimap perimeter over the standard Blizz arrow when waypoint is outside minimap range - - **Near**: Rotating gold ring with directional arrowhead when waypoint enters minimap range; ring smoothly shrinks as you approach to avoid map pin going inside circle +- **Click-to-Navigate Map Pins**: Click any local map search pin to place and track a native WoW waypoint - Waypoint pin is automatically removed when the game reports "Reached Destination" ### Changed - **Map Pin** - **Default Map Pin Size**: Map pin icons and highlight boxes reduced to ~50% of previous size for less visual clutter on the world map - - **Map Pin Hover Behavior**: Local search pins now show a tooltip ("Click to track on minimap") instead of auto-dismissing on hover; global search pins retain the original hover-to-dismiss behavior + - **Map Pin Hover Behavior**: Local search pins now show a waypoint tooltip instead of auto-dismissing on hover; global search pins retain the original hover-to-dismiss behavior - Can still remove local search pins by either right clicking them, hitting the clear button on the search bar, or with /ef clear - **Blinking Map Pins Option**: Map pins now solid by default, but there is a new toggle in Options panel to enable/disable the map pin pulse animations @@ -359,4 +348,3 @@ All notable changes to EasyFind will be documented in this file. - Achievement and Statistics navigation - Draggable search bar with scale options - Slash command `/ef` to toggle UI search - diff --git a/Core.lua b/Core.lua index bbb4761..a63d818 100644 --- a/Core.lua +++ b/Core.lua @@ -34,58 +34,36 @@ local DB_DEFAULTS = { iconScale = 0.8, nativePinScale = 1.5, -- Multiplier applied to a Blizzard map pin while EasyFind is glowing it uiSearchScale = 1.0, - mapSearchScale = 1.0, - mapSearchWidth = 0.88, uiSearchWidth = 1.54, -- 0.88 * 1.75: results dropdown matches bar width now uiResultsScale = 1.0, uiResultsWidth = 350, - mapResultsScale = 1.0, - mapResultsWidth = 300, searchBarOpacity = 0.75, -- ns.DEFAULT_OPACITY fontSize = 0.9, -- UI search font size multiplier (0.5-2.0) - mapFontSize = 0.9, -- Map search font size multiplier (0.5-2.0) uiSearchPosition = nil, -- {point, relPoint, x, y} - mapSearchPosition = nil, -- x offset from map left edge - globalSearchPosition = nil, -- x offset from map right edge (windowed) - mapSearchPositionMax = nil, -- x offset from map left edge (maximized) - globalSearchPositionMax = nil, -- x offset from map right edge (maximized) - mapSearchYOffset = 0, -- y offset for search bars relative to map bottom - hideSearchBarsMaximized = true, -- Hide search bars when map is full screen (opt-in fullscreen search) - localMapDirectOpen = true, -- Zone bar left-click: direct navigation (breadcrumb/guide reserved for right-click Guide) - globalMapDirectOpen = true, -- Global bar left-click: direct navigation (breadcrumb/guide reserved for right-click Guide) + localMapDirectOpen = true, + globalMapDirectOpen = true, autoHide = true, -- Raycast-style: bar starts hidden; bind opens, click-out hides smartShow = false, -- Hide search bar until mouse hovers nearby (legacy alternate to autoHide) lockPosition = false, -- Disable drag-to-move on the search bar tutorialDone = false, -- True once the user has finished the onboarding wizard - mapSmartShow = false, -- Hide map search bars until mouse hovers nearby resultsTheme = "Modern", -- legacy; only "Modern" ships right now font = "Default", -- "Default" (Friz Quadrata) or "Inter" indicatorStyle = "EasyFind Arrow", -- Indicator texture style indicatorColor = "Yellow", -- Indicator color preset uiResultsHeight = 280, -- Visible height of UI search results panel in pixels - mapResultsHeight = 168, -- Visible height of map search results panel in pixels showTruncationMessage = true, -- Show "more results available" message when truncated hardResultsCap = false, -- Hard cap on results (no "more results" message) staticOpacity = true, -- Keep opacity constant while moving (default-on with toggle/autoHide UX) 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 showLoginMessage = true, -- Show "EasyFind loaded!" message on login blinkingPins = false, -- Pulse map pins and highlights in sync with indicator bob mapPinHighlight = true, -- Show yellow highlight box around map pins - arrivalDistance = 10, -- Yards - auto-clear waypoint when player is this close - minimapArrowGlow = false, -- Pulsing glow on minimap perimeter arrow - glowOnlyEasyFind = false, -- Only show glow for waypoints placed by EasyFind - minimapGuideCircle = false,-- Near-track ring + arrow around player on minimap - circleOnlyEasyFind = false, -- Only show guide circle for EasyFind waypoints - guideCircleScale = 1.0, -- Scale multiplier for guide circle ring+arrow - minimapPinGlow = false, -- Pulsing glow on map pin when guide circle shrinks onto it autoPinClear = true, -- Auto-clear map pin when player arrives autoTrackPins = true, -- Auto super-track newly placed map pins uiResultsAbove = false, -- Show UI search results above the search bar - mapResultsAbove = false, -- Show map search results above the search bar panelOpacity = 0.9, -- Options panel background opacity showMinimapButton = true, -- Show toggle button on minimap minimapButtonAngle = 200, -- Position angle (degrees) around minimap edge @@ -101,9 +79,9 @@ local DB_DEFAULTS = { services = true, rares = true, }, - mapTabFilters = { -- MapTab (unified) category filters. Applied after - zones = true, -- BuildResults to gate each bucket independently - instances = true, -- of the global/local DBs. + mapTabFilters = { + zones = true, + instances = true, flightpath = false, -- Off by default: zone maps are dense with flight masters travel = true, -- Portals, ships, zeppelins, trams (separate from flight paths) services = true, @@ -145,7 +123,6 @@ local DB_DEFAULTS = { appearanceSetPvE = true, -- Show PvE sets (Dungeon/Raid) appearanceSetPvP = true, -- Show PvP sets uiMapSearchLocal = true, -- Map search in UI bar: true = local zone only, false = global - uiHideHeaders = true, -- Flat results list: no category headers, path shown as subtext per row aliases = {}, -- User-defined search aliases: { [aliasText] = { kind, id, name } } uiSearchHistory = {}, -- Shell-style search history (most recent at index 1, capped at uiSearchHistoryLimit) uiSearchHistoryLimit = 500, -- Bash HISTSIZE default @@ -170,9 +147,6 @@ local DB_MIGRATIONS = { if not db.uiResultsHeight then db.uiResultsHeight = db.uiMaxResults and (db.uiMaxResults * 28) or 280 end - if not db.mapResultsHeight then - db.mapResultsHeight = db.mapMaxResults and (db.mapMaxResults * 26 + 12) or 168 - end db.uiMaxResults = nil db.mapMaxResults = nil end, @@ -188,12 +162,7 @@ local DB_MIGRATIONS = { db.uiSearchWidth = 1.54 end end, - -- [5] = Flip stale localMapDirectOpen / globalMapDirectOpen defaults. - -- The "Make Fast Mode default" commit changed both defaults from false - -- to true but didn't migrate existing saves. Users carried over - -- false → SelectResult bucketed every zone click into the multi-click - -- teach path (HighlightZoneOnMap), making clicks feel like they - -- needed two presses to register. + -- [5] = Restore direct map navigation defaults. [5] = function(db) if db.localMapDirectOpen == false then db.localMapDirectOpen = true end if db.globalMapDirectOpen == false then db.globalMapDirectOpen = true end @@ -207,7 +176,7 @@ local DB_MIGRATIONS = { db.mapTabFilters.flightpath = false end end, - -- [8] = Theme rename: "Classic" deprecated, "Retail" renamed to + -- [8] = Theme rename: "Classic" and "Retail" renamed to -- "Modern" (the new default), and "Retail" reused for the parchment -- variant. Existing saves on the old "Retail" or "Classic" values -- get pointed at "Modern" so nothing changes for them visually until @@ -438,6 +407,42 @@ end local SafeAfter = Utils.SafeAfter +local function MarkDynamicCategoryDirty(key) + if ns.Database and ns.Database.MarkDynamicCategoryDirty then + ns.Database:MarkDynamicCategoryDirty(key) + end + if ns.UI and ns.UI.RebuildOpenResults then + ns.UI:RebuildOpenResults() + end +end + +local function InstallTransmogClassFilterHook() + if not C_TransmogSets or not C_TransmogSets.SetTransmogSetsClassFilter + or EasyFind._tmogClassHooked then + return + end + EasyFind._tmogClassHooked = true + hooksecurefunc(C_TransmogSets, "SetTransmogSetsClassFilter", function(classID) + if EasyFind._tmogClassHookSuppress then return end + if not classID then return end + local db = EasyFind.db + if not db then return end + local _, _, playerClassID = UnitClass("player") + local newVal + if classID == playerClassID then + newVal = nil + else + newVal = { classID = classID } + end + local oldID = type(db.appearanceSetClass) == "table" + and db.appearanceSetClass.classID or db.appearanceSetClass + local newID = type(newVal) == "table" and newVal.classID or newVal + if oldID == newID then return end + db.appearanceSetClass = newVal + MarkDynamicCategoryDirty("transmogSets") + end) +end + local function OnPlayerLogin() SafeAfter(0, function() local function SafeInit(mod, name) @@ -447,112 +452,35 @@ local function OnPlayerLogin() EasyFind:Print("|cffff4444" .. name .. " failed to initialize: " .. tostring(err) .. "|r") end end + if EasyFind.db.enableMapSearch ~= false then + SafeInit(ns.MapSearch, "MapSearch") + end + if EasyFind.db.enableUISearch ~= false and ns.Database and ns.Database.LoadCoreDynamicSearchData then + local ok, err = xpcall(ns.Database.LoadCoreDynamicSearchData, ErrorHandler, ns.Database) + if not ok then + EasyFind:Print("|cffff4444Search data failed to initialize: " .. tostring(err) .. "|r") + end + end if EasyFind.db.enableUISearch ~= false then SafeInit(ns.UI, "UI") SafeInit(ns.Highlight, "Highlight") + if ns.Database and ns.Database.WarmSearchHotPath then + SafeAfter(0, function() ns.Database:WarmSearchHotPath() end) + end + if ns.UI and ns.UI.WarmSearchHotPath then + SafeAfter(0.05, function() ns.UI:WarmSearchHotPath() end) + end end - if EasyFind.db.enableMapSearch ~= false then - SafeInit(ns.MapSearch, "MapSearch") + if EasyFind.db.enableMapSearch ~= false and ns.MapSearch and ns.MapSearch.WarmUISearchCaches then + SafeAfter(0.10, function() ns.MapSearch:WarmUISearchCaches() end) 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") + if ns.Options and ns.Options.RegisterWithBlizzardOptions then + local ok, err = xpcall(ns.Options.RegisterWithBlizzardOptions, ErrorHandler, ns.Options) + if not ok then + EasyFind:Print("|cffff4444Options registration failed: " .. tostring(err) .. "|r") + end 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). - -- Mounts/toys run on subsequent frames, then GC reclaims init temporaries. - SafeAfter(2, function() - if not ns.Database then return end - ns.Database:PopulateDynamicCurrencies() - ns.Database:PopulateDynamicReputations() - SafeAfter(0, function() - ns.Database:PopulateDynamicMounts() - SafeAfter(0, function() - ns.Database:PopulateDynamicToys() - SafeAfter(0, function() - ns.Database:PopulateDynamicPets() - SafeAfter(0, function() - ns.Database:PopulateDynamicOutfits() - if ns.Database.PopulateDynamicMacros then - ns.Database:PopulateDynamicMacros() - end - if ns.Database.PopulateDynamicAbilities then - ns.Database:PopulateDynamicAbilities() - end - if ns.Database.PopulateDynamicBags then - ns.Database:PopulateDynamicBags() - end - if ns.Database.PopulateDynamicHeirlooms then - ns.Database:PopulateDynamicHeirlooms() - end - if ns.Database.PopulateDynamicTitles then - ns.Database:PopulateDynamicTitles() - end - if ns.Database.PopulateDynamicGearSets then - ns.Database:PopulateDynamicGearSets() - end - if ns.Database.PopulateDynamicBosses then - -- Defer one frame: scanning every dungeon - -- and raid boss across all expansion tiers - -- can take a noticeable beat, so let the - -- bag/ability passes settle first. - SafeAfter(0, function() - ns.Database:PopulateDynamicBosses() - end) - end - SafeAfter(0, function() - ns.Database:SyncTransmogSetFiltersFromUI() - ns.Database:PopulateDynamicTransmogSets() - - -- Hook Blizzard's class filter setter so any later - -- change (including the engine restoring the saved - -- value after Blizzard_Collections loads) triggers - -- a resync + repopulate. The login sync above can - -- run before the wardrobe state is fully restored, - -- which is why the dropdown-open sync used to be - -- the only thing that picked up the correct class. - if C_TransmogSets and C_TransmogSets.SetTransmogSetsClassFilter - and not EasyFind._tmogClassHooked then - EasyFind._tmogClassHooked = true - hooksecurefunc(C_TransmogSets, "SetTransmogSetsClassFilter", function(classID) - -- Skip when Populate itself called the setter, to avoid - -- infinite recursion through the hook. - if EasyFind._tmogClassHookSuppress then return end - if not classID or not ns.Database then return end - local db = EasyFind.db - if not db then return end - local _, _, playerClassID = UnitClass("player") - local newVal - if classID == playerClassID then - newVal = nil - else - newVal = { classID = classID } - end - -- Compare before vs after: avoid a repopulate if the - -- value didn't actually change. - local oldID = type(db.appearanceSetClass) == "table" - and db.appearanceSetClass.classID or db.appearanceSetClass - local newID = type(newVal) == "table" and newVal.classID or newVal - if oldID == newID then return end - db.appearanceSetClass = newVal - if ns.Database.PopulateDynamicTransmogSets then - ns.Database:PopulateDynamicTransmogSets() - end - end) - end - end) - end) - end) - end) - end) + InstallTransmogClassFilterHook() end) -- Minimap button (delayed slightly so Minimap frame is ready) @@ -611,54 +539,36 @@ eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2) 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 - if ns.UI and ns.UI.SyncOutfitPins then - ns.UI:SyncOutfitPins() + MarkDynamicCategoryDirty("outfits") + if ns.UIPins and ns.UIPins.SyncOutfits then + ns.UIPins.SyncOutfits() end end) elseif event == "TRANSMOG_COLLECTION_UPDATED" then - -- Fires once the transmog collection (including the saved class - -- filter) is fully loaded. Re-sync + repopulate so we pick up the - -- correct class without waiting for the user to open our dropdown. if ns.Database and ns.Database.SyncTransmogSetFiltersFromUI then ns.Database:SyncTransmogSetFiltersFromUI() - if ns.Database.PopulateDynamicTransmogSets then - ns.Database:PopulateDynamicTransmogSets() - end + MarkDynamicCategoryDirty("transmogSets") + InstallTransmogClassFilterHook() end elseif event == "UPDATE_MACROS" then - -- Fires when the player creates, edits, or deletes a macro. Re-scan - -- so the search index reflects the change without /reload. - if ns.Database and ns.Database.PopulateDynamicMacros then - ns.Database:PopulateDynamicMacros() - end + MarkDynamicCategoryDirty("macros") elseif event == "SPELLS_CHANGED" then - -- Throttle: many SPELLS_CHANGED events can fire in rapid - -- succession on login or when learning a row of talents. if spellRefreshTimer then spellRefreshTimer:Cancel() end spellRefreshTimer = C_Timer.NewTimer(1.0, function() spellRefreshTimer = nil - if ns.Database and ns.Database.PopulateDynamicAbilities then - ns.Database:PopulateDynamicAbilities() - end + MarkDynamicCategoryDirty("abilities") end) elseif event == "BAG_UPDATE_DELAYED" then if bagRefreshTimer then bagRefreshTimer:Cancel() end bagRefreshTimer = C_Timer.NewTimer(0.5, function() bagRefreshTimer = nil - if ns.Database and ns.Database.PopulateDynamicBags then - ns.Database:PopulateDynamicBags() - end + MarkDynamicCategoryDirty("bags") end) elseif event == "EQUIPMENT_SETS_CHANGED" then if gearSetRefreshTimer then gearSetRefreshTimer:Cancel() end gearSetRefreshTimer = C_Timer.NewTimer(0.3, function() gearSetRefreshTimer = nil - if ns.Database and ns.Database.PopulateDynamicGearSets then - ns.Database:PopulateDynamicGearSets() - end + MarkDynamicCategoryDirty("gearSets") end) elseif event == "PLAYER_LOGOUT" then -- Strip runtime-only fields before SavedVariables serialization diff --git a/Database.lua b/Database.lua index 4f1b032..6d25a80 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, tremove = Utils.tsort, Utils.tconcat, Utils.tremove +local tsort, tconcat = Utils.tsort, Utils.tconcat local sfind, slower, ssub = Utils.sfind, Utils.slower, Utils.ssub local sbyte = string.byte local mmin, mmax, mabs = Utils.mmin, Utils.mmax, Utils.mabs @@ -56,6 +56,38 @@ Database._wordCache = wordCache -- exposed for DevMem diagnostics -- Track which currencyIDs are already in the static database local knownCurrencyIDs = {} +local function RemoveEntriesByCategory(category) + local writeIdx = 0 + for i = 1, #uiSearchData do + local entry = uiSearchData[i] + if entry.category ~= category then + writeIdx = writeIdx + 1 + uiSearchData[writeIdx] = entry + end + end + for i = #uiSearchData, writeIdx + 1, -1 do + uiSearchData[i] = nil + end +end + +local function RemoveEntriesWithField(field) + local writeIdx = 0 + for i = 1, #uiSearchData do + local entry = uiSearchData[i] + if not entry[field] then + writeIdx = writeIdx + 1 + uiSearchData[writeIdx] = entry + end + end + for i = #uiSearchData, writeIdx + 1, -1 do + uiSearchData[i] = nil + end +end + +function Database:_RemoveEntriesByCategory(category) + RemoveEntriesByCategory(category) +end + function Database:Initialize() self:BuildUIDatabase() end @@ -65,6 +97,9 @@ end function Database:PopulateDynamicCurrencies() if not C_CurrencyInfo or not C_CurrencyInfo.GetCurrencyListSize then return end + RemoveEntriesByCategory("Currency") + wipe(knownCurrencyIDs) + -- Expand all collapsed headers so we can see every currency -- Track which ones we expand so we can collapse them back afterward local headersWeExpanded = {} @@ -254,6 +289,8 @@ end function Database:PopulateDynamicReputations() if not C_Reputation or not C_Reputation.GetNumFactions then return end + RemoveEntriesByCategory("Reputation") + -- Expand all collapsed headers so we can see every faction local headersWeExpanded = {} for pass = 1, 50 do @@ -552,6 +589,73 @@ local STAT_KEYWORD_MAP = { ITEM_MOD_STRENGTH_SHORT = {"str", "strength"}, } +local heavySearchWordLookup +local function AddHeavySearchWord(word) + if not word or word == "" then return end + word = slower(word) + heavySearchWordLookup[word] = true + if #word > 2 and ssub(word, #word, #word) ~= "s" then + heavySearchWordLookup[word .. "s"] = true + end +end + +local function AddHeavySearchWords(words) + for i = 1, #words do + for word in words[i]:gmatch("%S+") do + AddHeavySearchWord(word) + end + end +end + +local function GetHeavySearchWordLookup() + if heavySearchWordLookup then return heavySearchWordLookup end + heavySearchWordLookup = {} + AddHeavySearchWords({ + "loot", "item", "gear", "armor", "boss", "bosses", + "dungeon", "raid", "weapon", + }) + for equipLoc, words in _G.pairs(SLOT_KEYWORDS) do + AddHeavySearchWord(_G[equipLoc]) + AddHeavySearchWords(words) + end + for statKey, words in _G.pairs(STAT_KEYWORD_MAP) do + AddHeavySearchWord(_G[statKey]) + AddHeavySearchWords(words) + end + return heavySearchWordLookup +end + +local statSearchWordLookup +local function IsLootStatSearchWord(word) + if not statSearchWordLookup then + statSearchWordLookup = {} + for statKey, words in _G.pairs(STAT_KEYWORD_MAP) do + local label = _G[statKey] + if label then + for part in slower(label):gmatch("%S+") do + statSearchWordLookup[part] = true + end + end + for i = 1, #words do + for part in words[i]:gmatch("%S+") do + statSearchWordLookup[part] = true + end + end + end + end + return statSearchWordLookup[word] or false +end + +function Database:QueryNeedsHeavySearchData(text) + if not text then return false end + local lookup = GetHeavySearchWordLookup() + for word in slower(text):gmatch("%S+") do + word = word:gsub("^%p+", ""):gsub("%p+$", "") + if lookup[word] then return true end + end + return false +end + -- Slot display names for result text local SLOT_DISPLAY = { INVTYPE_HEAD = "Head", INVTYPE_NECK = "Neck", INVTYPE_SHOULDER = "Shoulder", @@ -580,6 +684,7 @@ end local lootEntries = {} -- track injected entries for re-population local lootScanGeneration = 0 -- cancel stale scans when re-populating +local bossScanGeneration = 0 local lootItemCache = {} -- itemID -> entry (persists across spec/diff toggles) local lootSpecsScanned = {} -- ["classID-specID"] = true Database._lootItemCache = lootItemCache -- exposed for DevMem diagnostics @@ -776,10 +881,12 @@ end function Database:EnrichLootStats(entry) 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(link) + local stats = link and GetItemStatsFn(link) + if not stats and entry.itemID then + stats = GetItemStatsFn("item:" .. entry.itemID) + end if not stats then return end local statKw = entry.lootStatKw or {} for statKey, searchWords in pairs(STAT_KEYWORD_MAP) do @@ -802,6 +909,8 @@ local outfitEntries = {} -- track injected entries for re-population function Database:PopulateDynamicMounts() if not C_MountJournal or not C_MountJournal.GetMountIDs then return end + RemoveEntriesByCategory("Mount") + local mountIDs = C_MountJournal.GetMountIDs() if not mountIDs then return end @@ -825,6 +934,8 @@ end function Database:PopulateDynamicToys() if not C_ToyBox then return end + RemoveEntriesByCategory("Toy") + local GetToyInfo = C_ToyBox.GetToyInfo local GetNumFilteredToys = C_ToyBox.GetNumFilteredToys local GetToyFromIndex = C_ToyBox.GetToyFromIndex @@ -871,6 +982,8 @@ end function Database:PopulateDynamicPets() if not C_PetJournal or not C_PetJournal.GetNumPets then return end + RemoveEntriesByCategory("Pet") + -- Save current filter state local savedCollected = C_PetJournal.IsFilterChecked and C_PetJournal.IsFilterChecked(LE_PET_JOURNAL_FILTER_COLLECTED) local savedNotCollected = C_PetJournal.IsFilterChecked and C_PetJournal.IsFilterChecked(LE_PET_JOURNAL_FILTER_NOT_COLLECTED) @@ -919,11 +1032,7 @@ end function Database:PopulateDynamicHeirlooms() if not C_Heirloom or not C_Heirloom.GetHeirloomItemIDs then return end - for i = #uiSearchData, 1, -1 do - if uiSearchData[i].category == "Heirloom" then - tremove(uiSearchData, i) - end - end + RemoveEntriesByCategory("Heirloom") local ids = C_Heirloom.GetHeirloomItemIDs() if type(ids) ~= "table" then return end @@ -964,11 +1073,7 @@ function Database:PopulateDynamicTitles() local isKnown = IsTitleKnown if not getNum or not getName or not isKnown then return end - for i = #uiSearchData, 1, -1 do - if uiSearchData[i].category == "Title" then - tremove(uiSearchData, i) - end - end + RemoveEntriesByCategory("Title") local total = getNum() if not total or total <= 0 then return end @@ -997,11 +1102,7 @@ end function Database:PopulateDynamicGearSets() if not C_EquipmentSet or not C_EquipmentSet.GetEquipmentSetIDs then return end - for i = #uiSearchData, 1, -1 do - if uiSearchData[i].category == "Gear Set" then - tremove(uiSearchData, i) - end - end + RemoveEntriesByCategory("Gear Set") local ids = C_EquipmentSet.GetEquipmentSetIDs() if type(ids) ~= "table" then return end @@ -1029,11 +1130,7 @@ 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 + RemoveEntriesByCategory("Outfit") wipe(outfitEntries) local outfits = C_TransmogOutfitInfo.GetOutfitsInfo() @@ -1091,11 +1188,7 @@ function Database:PopulateDynamicTransmogSets() if not C_TransmogSets or not C_TransmogSets.GetAllSets then return end -- Remove previous entries (handles mid-session filter changes) - for i = #uiSearchData, 1, -1 do - if uiSearchData[i].transmogSetID then - tremove(uiSearchData, i) - end - end + RemoveEntriesWithField("transmogSetID") -- Invalidate incremental search cache. Without this, a query that was -- typed before the repopulate (e.g. "cauldron" searched with Druid sets -- active) would still reuse prevCandidates on the next extension and @@ -1247,14 +1340,7 @@ function Database:FindEmptyActionSlot() end end --- Called after PLAYER_LOGIN. Scans the Encounter Journal for current-tier loot --- 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 function BuildLootSpecPairs(scanAllSpecs) local specPairs = {} if scanAllSpecs then for classIdx = 1, GetNumClasses() do @@ -1269,7 +1355,6 @@ function Database:PopulateDynamicLoot(scanAllSpecs) end end else - -- Build from lootFilter (single selection) local lootFilter = EasyFind.db.lootFilter if not lootFilter then local _, _, cid = UnitClass("player") @@ -1279,7 +1364,6 @@ function Database:PopulateDynamicLoot(scanAllSpecs) 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 @@ -1302,8 +1386,10 @@ function Database:PopulateDynamicLoot(scanAllSpecs) end end end + return specPairs +end - -- Find specs that haven't been scanned yet +local function GetLootSpecsToScan(specPairs) local needScan = {} for _, sp in ipairs(specPairs) do local key = sp.classID .. "-" .. sp.specID @@ -1311,6 +1397,106 @@ function Database:PopulateDynamicLoot(scanAllSpecs) needScan[#needScan + 1] = sp end end + return needScan +end + +local function GetLootDiffPairs(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 function CacheLootInfo(database, lootInfo, inst, encName, encID, diff, sp, spKey, GetItemInfoInstantFn) + local itemID = lootInfo.itemID + if not itemID then return end + local cached = lootItemCache[itemID] + if cached then + 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 + + 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 + return + end + + local itemName = lootInfo.name + if not itemName or itemName == "" then return end + local _, _, _, equipLoc, instIcon = GetItemInfoInstantFn(itemID) + 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 itemLinks = {} + if lootInfo.link then itemLinks[diff.key] = lootInfo.link end + local entry = setmetatable({ + name = itemName, + nameLower = slower(itemName), + icon = lootInfo.icon or instIcon, + 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 lootInfo.link then + database:EnrichLootStats(entry) + end + lootItemCache[itemID] = entry +end + +function Database:CancelDynamicScans() + lootScanGeneration = lootScanGeneration + 1 + bossScanGeneration = bossScanGeneration + 1 +end + +-- Called after PLAYER_LOGIN. Scans the Encounter Journal for current-tier loot +-- 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 + + local specPairs = BuildLootSpecPairs(scanAllSpecs) + local needScan = GetLootSpecsToScan(specPairs) -- All selected specs already cached: just rebuild from cache (instant) if #needScan == 0 then @@ -1371,22 +1557,10 @@ function Database:PopulateDynamicLoot(scanAllSpecs) local GetItemInfoInstant = GetItemInfoInstant -- Build list of {diffKey, diffID} pairs per source type - 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 - - -- 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) + local diffPairs = GetLootDiffPairs(inst.isRaid) local encIdx = 1 while true do @@ -1412,80 +1586,7 @@ function Database:PopulateDynamicLoot(scanAllSpecs) 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 + CacheLootInfo(Database, lootInfo, inst, encName, encID, diff, sp, spKey, GetItemInfoInstant) li = li + 1 end end @@ -1501,7 +1602,193 @@ function Database:PopulateDynamicLoot(scanAllSpecs) lootSpecsScanned[sp.classID .. "-" .. sp.specID] = true end RebuildLootSearchData() - collectgarbage("collect") + if C_Timer and C_Timer.After then + C_Timer.After(0, function() + collectgarbage("step", 200) + end) + else + collectgarbage("step", 200) + end +end + +function Database:PopulateDynamicLootAsync(done, scanAllSpecs) + if InCombatLockdown() then done(false); return end + if not C_Timer or not C_Timer.After then + self:PopulateDynamicLoot(scanAllSpecs) + done(true) + return + end + + local specPairs = BuildLootSpecPairs(scanAllSpecs) + local needScan = GetLootSpecsToScan(specPairs) + if #needScan == 0 then + RebuildLootSearchData() + done(true) + return + end + + if not EncounterJournal then + EncounterJournal_LoadUI() + end + + 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_GetLootInfoByIndex = EJ("GetLootInfoByIndex") + + if not EJ_GetCurrentTier or not EJ_GetInstanceByIndex or not EJ_GetLootInfoByIndex then + done(false) + return + end + + lootScanGeneration = lootScanGeneration + 1 + local myGen = lootScanGeneration + local ejFrame = _G["EncounterJournal"] + local savedOnEvent + if ejFrame then + savedOnEvent = ejFrame:GetScript("OnEvent") + ejFrame:SetScript("OnEvent", nil) + end + local savedTier = EJ_GetCurrentTier and EJ_GetCurrentTier() + + 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) + collectInstances(true) + + local state = { + instIdx = 1, + encIdx = 1, + diffIdx = 1, + specIdx = 1, + lootIdx = 1, + diffPairs = nil, + encName = nil, + encID = nil, + prepared = false, + } + local GetItemInfoInstantFn = GetItemInfoInstant + local budgetMs = 4 + + local function finish(changed, err) + if savedTier and EJ_SelectTier then EJ_SelectTier(savedTier) end + if ejFrame and savedOnEvent then ejFrame:SetScript("OnEvent", savedOnEvent) end + if changed then + for _, sp in ipairs(needScan) do + lootSpecsScanned[sp.classID .. "-" .. sp.specID] = true + end + RebuildLootSearchData() + collectgarbage("step", 200) + end + done(changed, err) + end + + local function step() + if myGen ~= lootScanGeneration then + finish(false, "cancelled") + return + end + + local start = debugprofilestop and debugprofilestop() or 0 + while true do + local inst = instances[state.instIdx] + if not inst then + finish(true) + return + end + + if not state.diffPairs then + EJ_SelectInstance(inst.id) + state.diffPairs = GetLootDiffPairs(inst.isRaid) + state.encIdx = 1 + state.diffIdx = 1 + state.specIdx = 1 + state.lootIdx = 1 + state.prepared = false + end + + if not state.encName then + local encName, _, encID = EJ_GetEncounterInfoByIndex(state.encIdx) + if not encName then + state.instIdx = state.instIdx + 1 + state.diffPairs = nil + state.encName = nil + state.encID = nil + state.prepared = false + else + state.encName = encName + state.encID = encID + end + elseif not state.diffPairs[state.diffIdx] then + state.encIdx = state.encIdx + 1 + state.diffIdx = 1 + state.specIdx = 1 + state.lootIdx = 1 + state.encName = nil + state.encID = nil + state.prepared = false + elseif not needScan[state.specIdx] then + state.diffIdx = state.diffIdx + 1 + state.specIdx = 1 + state.lootIdx = 1 + state.prepared = false + else + local diff = state.diffPairs[state.diffIdx] + local sp = needScan[state.specIdx] + local spKey = sp.classID .. "-" .. sp.specID + if not state.prepared then + EJ_SelectInstance(inst.id) + EJ_SelectEncounter(state.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 + state.lootIdx = 1 + state.prepared = true + end + + local processed = 0 + while processed < 8 do + local lootInfo = EJ_GetLootInfoByIndex(state.lootIdx) + if not lootInfo or not lootInfo.name then + state.specIdx = state.specIdx + 1 + state.lootIdx = 1 + state.prepared = false + break + end + CacheLootInfo(self, lootInfo, inst, state.encName, state.encID, diff, sp, spKey, GetItemInfoInstantFn) + state.lootIdx = state.lootIdx + 1 + processed = processed + 1 + end + end + + if debugprofilestop and (debugprofilestop() - start) >= budgetMs then + C_Timer.After(0, function() + local ok, err = xpcall(step, Utils.ErrorHandler) + if not ok then finish(false, err) end + end) + return + end + end + end + + C_Timer.After(0, function() + local ok, err = xpcall(step, Utils.ErrorHandler) + if not ok then finish(false, err) end + end) end -- Macro search: scans the player's account-wide and per-character macros @@ -1512,11 +1799,7 @@ end function Database:PopulateDynamicMacros() if not GetNumMacros or not GetMacroInfo then return end - for i = #uiSearchData, 1, -1 do - if uiSearchData[i].category == "Macro" then - tremove(uiSearchData, i) - end - end + RemoveEntriesByCategory("Macro") if self.ResetSearchCache then self:ResetSearchCache() end local numGlobal, numPerChar = GetNumMacros() @@ -1575,11 +1858,7 @@ end -- registration since this addon targets Midnight 12.0+. function Database:PopulateDynamicAbilities() -- Strip prior pass so /reload-equivalent rebuilds don't double up. - for i = #uiSearchData, 1, -1 do - if uiSearchData[i].category == "Ability" then - tremove(uiSearchData, i) - end - end + RemoveEntriesByCategory("Ability") if self.ResetSearchCache then self:ResetSearchCache() end local SBOOK = C_SpellBook @@ -1681,17 +1960,55 @@ local INSTANCE_ABBRS = { } ns.INSTANCE_ABBRS = INSTANCE_ABBRS +local function AddBossEntry(tier, isRaid, instID, instName, encName, encID, getCreatureInfo) + local nameLower = slower(encName) + local instLower = slower(instName or "") + local kw = { instLower, "boss", "bosses" } + local abbrs = INSTANCE_ABBRS[instLower] + if abbrs then + for ai = 1, #abbrs do + kw[#kw + 1] = abbrs[ai] + end + end + + local icon + if getCreatureInfo then + local ok, _, _, _, _, iconImage = pcall(getCreatureInfo, 1, encID) + if ok and iconImage and iconImage ~= 0 then + icon = iconImage + end + end + + uiSearchData[#uiSearchData + 1] = { + name = encName, + nameLower = nameLower, + keywords = kw, + keywordsLower = kw, + category = "Boss", + icon = icon, + encounterID = encID, + instanceID = instID, + instanceName = instName, + instanceNameLower = instLower, + isRaidBoss = isRaid, + ejTier = tier, + path = { isRaid and "Raid" or "Dungeon", instName }, + steps = { + { buttonFrame = "EJMicroButton" }, + { waitForFrame = "EncounterJournal", ejTier = tier, ejTabIsRaid = isRaid }, + { waitForFrame = "EncounterJournal", ejInstance = instName, ejInstanceID = instID }, + { waitForFrame = "EncounterJournal", ejBoss = encName, ejEncounterID = encID }, + }, + } +end + -- Inject one entry per dungeon/raid boss across every expansion tier. -- Click navigates the Encounter Journal to that boss. Icon is the -- boss's first creature portrait (EJ_GetCreatureInfo[5]) so results -- look like the EJ's own boss list. function Database:PopulateDynamicBosses() -- Strip prior pass so re-runs don't double up. - for i = #uiSearchData, 1, -1 do - if uiSearchData[i].category == "Boss" then - tremove(uiSearchData, i) - end - end + RemoveEntriesByCategory("Boss") if self.ResetSearchCache then self:ResetSearchCache() end if not EncounterJournal then @@ -1735,54 +2052,7 @@ function Database:PopulateDynamicBosses() local encName, _, encID = getEncounterByIdx(encIdx) if not encName then break end - local nameLower = slower(encName) - local instLower = slower(instName or "") - -- Keywords intentionally exclude generic "raid"/"dungeon" - -- so typing those alone doesn't flood with every boss. - -- The instance name + "boss" let " boss" queries - -- match (e.g. "icc boss" -> all Icecrown Citadel bosses). - -- SearchUI further gates instance-keyword matches behind - -- the presence of "boss" in the query, so plain "icc" - -- still won't flood with bosses either. - local kw = { instLower, "boss", "bosses" } - local abbrs = INSTANCE_ABBRS[instLower] - if abbrs then - for ai = 1, #abbrs do - kw[#kw + 1] = abbrs[ai] - end - end - - -- Pull the first creature's portrait icon if EJ exposes it. - local icon - if getCreatureInfo then - local ok, _, _, _, _, iconImage = pcall(getCreatureInfo, 1, encID) - if ok and iconImage and iconImage ~= 0 then - icon = iconImage - end - end - - uiSearchData[#uiSearchData + 1] = { - name = encName, - nameLower = nameLower, - keywords = kw, - keywordsLower = kw, - category = "Boss", - icon = icon, - encounterID = encID, - instanceID = instID, - instanceName = instName, - instanceNameLower = instLower, - isRaidBoss = isRaid, - ejTier = tier, - path = { isRaid and "Raid" or "Dungeon", instName }, - steps = { - { buttonFrame = "EJMicroButton" }, - { waitForFrame = "EncounterJournal", ejTier = tier, ejTabIsRaid = isRaid }, - { waitForFrame = "EncounterJournal", ejInstance = instName, ejInstanceID = instID }, - { waitForFrame = "EncounterJournal", ejBoss = encName, ejEncounterID = encID }, - }, - } - + AddBossEntry(tier, isRaid, instID, instName, encName, encID, getCreatureInfo) encIdx = encIdx + 1 end @@ -1797,6 +2067,117 @@ function Database:PopulateDynamicBosses() if ejFrame and savedOnEvent then ejFrame:SetScript("OnEvent", savedOnEvent) end end +function Database:PopulateDynamicBossesAsync(done) + if not C_Timer or not C_Timer.After then + self:PopulateDynamicBosses() + done(true) + return + end + + RemoveEntriesByCategory("Boss") + if self.ResetSearchCache then self:ResetSearchCache() end + + if not EncounterJournal then + EncounterJournal_LoadUI() + end + + local getNumTiers = EJ("GetNumTiers") or _G.EJ_GetNumTiers + local getCurrentTier = EJ("GetCurrentTier") or _G.EJ_GetCurrentTier + local selectTier = EJ("SelectTier") or _G.EJ_SelectTier + local getInstanceByIdx = EJ("GetInstanceByIndex") + local selectInstance = EJ("SelectInstance") + local getEncounterByIdx = EJ("GetEncounterInfoByIndex") + local getCreatureInfo = EJ("GetCreatureInfo") or _G.EJ_GetCreatureInfo + if not getInstanceByIdx or not getEncounterByIdx or not selectTier then + done(false) + return + end + + bossScanGeneration = bossScanGeneration + 1 + local myGen = bossScanGeneration + local ejFrame = _G["EncounterJournal"] + local savedOnEvent + if ejFrame then + savedOnEvent = ejFrame:GetScript("OnEvent") + ejFrame:SetScript("OnEvent", nil) + end + + local savedTier = getCurrentTier and getCurrentTier() + local numTiers = (getNumTiers and getNumTiers()) or 10 + local state = { tier = 1, raidIdx = 1, instIdx = 1, encIdx = 1, instID = nil, instName = nil } + local raidModes = { false, true } + local budgetMs = 4 + + local function finish(changed, err) + if savedTier and selectTier then selectTier(savedTier) end + if ejFrame and savedOnEvent then ejFrame:SetScript("OnEvent", savedOnEvent) end + done(changed, err) + end + + local function step() + if myGen ~= bossScanGeneration then + finish(false, "cancelled") + return + end + + local start = debugprofilestop and debugprofilestop() or 0 + while state.tier <= numTiers do + selectTier(state.tier) + local isRaid = raidModes[state.raidIdx] + if isRaid == nil then + state.tier = state.tier + 1 + state.raidIdx = 1 + state.instIdx = 1 + state.encIdx = 1 + state.instID = nil + state.instName = nil + elseif not state.instID then + local instID, instName = getInstanceByIdx(state.instIdx, isRaid) + if not instID then + state.raidIdx = state.raidIdx + 1 + state.instIdx = 1 + state.encIdx = 1 + else + state.instID = instID + state.instName = instName + state.encIdx = 1 + if selectInstance then selectInstance(instID) end + end + else + local processed = 0 + while processed < 10 do + local encName, _, encID = getEncounterByIdx(state.encIdx) + if not encName then + state.instIdx = state.instIdx + 1 + state.instID = nil + state.instName = nil + state.encIdx = 1 + break + end + AddBossEntry(state.tier, isRaid, state.instID, state.instName, encName, encID, getCreatureInfo) + state.encIdx = state.encIdx + 1 + processed = processed + 1 + end + end + + if debugprofilestop and (debugprofilestop() - start) >= budgetMs then + C_Timer.After(0, function() + local ok, err = xpcall(step, Utils.ErrorHandler) + if not ok then finish(false, err) end + end) + return + end + end + + finish(true) + end + + C_Timer.After(0, function() + local ok, err = xpcall(step, Utils.ErrorHandler) + if not ok then finish(false, err) end + end) +end + -- Inject one entry per unique item carried in the player's bags. The -- entry stores the first occupied location so guide mode can highlight -- the right slot; drag-to-pickup uses the item ID to put the item on @@ -1807,11 +2188,7 @@ function Database:PopulateDynamicBags() local getItemInfo = (CONT and CONT.GetContainerItemInfo) or GetContainerItemInfo if not getNumSlots or not getItemInfo then return end - for i = #uiSearchData, 1, -1 do - if uiSearchData[i].category == "Bag" then - tremove(uiSearchData, i) - end - end + RemoveEntriesByCategory("Bag") if self.ResetSearchCache then self:ResetSearchCache() end local itemMap = {} @@ -1874,6 +2251,20 @@ function Database:PopulateDynamicBags() end end +function Database:_ResetDynamicProviderCaches() + wipe(knownCurrencyIDs) + wipe(lootEntries) + wipe(lootItemCache) + wipe(lootSpecsScanned) + wipe(outfitEntries) +end + +function Database:_ResetHeavyProviderCaches() + wipe(lootEntries) + wipe(lootItemCache) + wipe(lootSpecsScanned) +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. @@ -3112,8 +3503,11 @@ function Database:DamerauLevenshtein(s1, s2, len1, len2) for i = 1, len1 do curr[0] = i local minInRow = i + local c1 = sbyte(s1, i) + local c1Prev = i > 1 and sbyte(s1, i - 1) or nil for j = 1, len2 do - local cost = (ssub(s1, i, i) == ssub(s2, j, j)) and 0 or 1 + local c2 = sbyte(s2, j) + local cost = (c1 == c2) and 0 or 1 curr[j] = mmin( prev[j] + 1, -- deletion curr[j - 1] + 1, -- insertion @@ -3121,8 +3515,8 @@ function Database:DamerauLevenshtein(s1, s2, len1, len2) ) -- Transposition if i > 1 and j > 1 - and ssub(s1, i, i) == ssub(s2, j - 1, j - 1) - and ssub(s1, i - 1, i - 1) == ssub(s2, j, j) then + and c1 == sbyte(s2, j - 1) + and c1Prev == c2 then curr[j] = mmin(curr[j], prev2[j - 2] + cost) end if curr[j] < minInRow then minInRow = curr[j] end @@ -3450,77 +3844,165 @@ end -- Incremental search state: when the user extends the previous query (e.g. "mou" → "moun"), -- only re-score entries that matched before instead of the full dataset. +local function ScoreSingleFieldWord(fieldWord, queryWord, queryWordLen) + if fieldWord == queryWord then return 100 end + if sfind(fieldWord, queryWord, 1, true) == 1 then return 90 end + if fieldWord .. "s" == queryWord or queryWord .. "s" == fieldWord then return 82 end + if sfind(fieldWord, queryWord, 1, true) then return 50 end + if queryWordLen >= 3 then + local fieldLen = #fieldWord + if fieldLen > queryWordLen and queryWordLen <= 8 + and queryWordLen / fieldLen >= 0.45 + and Database:IsSubsequence(fieldWord, queryWord, queryWordLen) then + return 55 + end + end + if queryWordLen >= 4 and sbyte(fieldWord, 1) == sbyte(queryWord, 1) then + local fieldLen = #fieldWord + local maxEdits = queryWordLen >= 8 and 2 or 1 + if fieldLen >= queryWordLen - maxEdits and fieldLen <= queryWordLen + maxEdits then + local dist = Database:DamerauLevenshtein(fieldWord, queryWord, fieldLen, queryWordLen) + if dist <= maxEdits then return mmax(45, 85 - dist * 20) end + end + end + return 0 +end + +local function ScoreFieldWords(words, queryWord, queryWordLen) + local best = 0 + for i = 1, #words do + local score = ScoreSingleFieldWord(words[i], queryWord, queryWordLen) + if score > best then best = score end + end + return best +end + +function Database:ScoreEntryFields(data, queryWords) + if not queryWords or #queryWords < 2 then return 0 end + local total = 0 + local matched = 0 + local nameWords = GetWords(data.nameLower or "") + local keywordsLower = data.keywordsLower + + for qi = 1, #queryWords do + local qw = queryWords[qi] + local qwLen = #qw + if qwLen >= 2 or (qwLen == 1 and qi == #queryWords and matched > 0) then + local best = ScoreFieldWords(nameWords, qw, qwLen) + if keywordsLower then + for ki = 1, #keywordsLower do + local kw = keywordsLower[ki] + local kwScore = ScoreSingleFieldWord(kw, qw, qwLen) + if kwScore < 90 then + kwScore = mmax(kwScore, ScoreFieldWords(GetWords(kw), qw, qwLen)) + end + if kwScore > best then best = kwScore end + end + end + if best == 0 then return 0 end + total = total + best + matched = matched + 1 + end + end + + if matched < 2 then return 0 end + return total + matched * 5 +end + local prevQuery = "" local prevSkipKey = "" local prevCandidates = {} --- --------------------------------------------------------------------- --- Inverted prefix indexes. Without these, every keystroke that isn't --- a forward extension of the previous query (every backspace, every --- fresh query, every typo correction) falls back to a full scan over --- ~10K entries — held backspace stutters every frame. --- --- We keep two indexes side-by-side: --- prefix2Index["mo"] = { entries with any name/keyword word starting "mo" } --- prefix1Index["m"] = { entries with any name/keyword word starting "m" } --- --- Lookup tries 2-char first (smallest bucket, ~50-300 entries). --- Falls back to 1-char if the 2-char bucket is empty (covers rare --- prefixes and entries whose words are < 2 chars). Falls back to a --- full scan as a final safety net (should never happen in practice). --- --- Built at the end of init and rebuilt any time uiSearchData mutates --- (ResetSearchCache invalidates the indexes; next SearchUI rebuilds). --- --------------------------------------------------------------------- -local prefix1Index = nil -local prefix2Index = nil +local prefixIndex = {} +local prefixIndexSeen = {} +local prefixIndexReady = false +local prefixCandidateBuf = {} +local prefixCandidateSeen = {} +Database._prefixIndex = prefixIndex + +local function AddPrefixIndexEntry(entry, prefix) + if prefixIndexSeen[prefix] == entry then return end + prefixIndexSeen[prefix] = entry + local bucket = prefixIndex[prefix] + if not bucket then + bucket = {} + prefixIndex[prefix] = bucket + end + bucket[#bucket + 1] = entry +end -local function indexAddWord(seen1, seen2, entry, word) - if not word or word == "" then return end - local c1 = ssub(word, 1, 1) - if not seen1[c1] then - seen1[c1] = true - local b1 = prefix1Index[c1] - if not b1 then b1 = {}; prefix1Index[c1] = b1 end - b1[#b1 + 1] = entry - end - if #word >= 2 then - local c2 = ssub(word, 1, 2) - if not seen2[c2] then - seen2[c2] = true - local b2 = prefix2Index[c2] - if not b2 then b2 = {}; prefix2Index[c2] = b2 end - b2[#b2 + 1] = entry - end +local function IndexPrefixText(entry, text) + if not text then return end + for word in text:gmatch("%S+") do + local len = #word + if len >= 1 then AddPrefixIndexEntry(entry, ssub(word, 1, 1)) end + if len >= 2 then AddPrefixIndexEntry(entry, ssub(word, 1, 2)) end end end -local function rebuildPrefixIndexes() - prefix1Index = {} - prefix2Index = {} - local seen1, seen2 = {}, {} - for ei = 1, #uiSearchData do - local entry = uiSearchData[ei] - for k in pairs(seen1) do seen1[k] = nil end - for k in pairs(seen2) do seen2[k] = nil end - local nameLower = entry.nameLower - if nameLower then - for word in nameLower:gmatch("%S+") do - indexAddWord(seen1, seen2, entry, word) - end +local function IndexPrefixList(entry, list) + if not list then return end + for i = 1, #list do + local text = list[i] + if type(text) == "string" then IndexPrefixText(entry, text) end + end +end + +function Database:BuildSearchPrefixIndex() + wipe(prefixIndex) + wipe(prefixIndexSeen) + for i = 1, #uiSearchData do + local entry = uiSearchData[i] + IndexPrefixText(entry, entry.nameLower) + IndexPrefixList(entry, entry.keywordsLower) + IndexPrefixList(entry, entry.lootSlotKw) + IndexPrefixList(entry, entry.lootStatKw) + IndexPrefixList(entry, entry.lootSourceKw) + end + wipe(prefixIndexSeen) + prefixIndexReady = true +end + +function Database:WarmSearchHotPath() + if not prefixIndexReady then + self:BuildSearchPrefixIndex() + end +end + +local function ClearPrefixBuckets() + wipe(prefixIndex) + prefixIndexReady = false +end + +local function GetPrefixBucket(prefix) + return prefixIndex[prefix] or false +end + +local function AddPrefixCandidateBucket(bucket) + if not bucket then return end + for i = 1, #bucket do + local entry = bucket[i] + if not prefixCandidateSeen[entry] then + prefixCandidateSeen[entry] = true + prefixCandidateBuf[#prefixCandidateBuf + 1] = entry end - local kwl = entry.keywordsLower - if kwl then - for ki = 1, #kwl do - local kw = kwl[ki] - if type(kw) == "string" then - for word in kw:gmatch("%S+") do - indexAddWord(seen1, seen2, entry, word) - end - end - end + end +end + +local function GetMultiTokenPrefixCandidates(queryWords) + wipe(prefixCandidateBuf) + wipe(prefixCandidateSeen) + for i = 1, #queryWords do + local word = queryWords[i] + local len = #word + if len >= 2 then + AddPrefixCandidateBucket(GetPrefixBucket(ssub(word, 1, 2))) + elseif len == 1 then + AddPrefixCandidateBucket(GetPrefixBucket(word)) end end + wipe(prefixCandidateSeen) + return #prefixCandidateBuf > 0 and prefixCandidateBuf or nil end local sortChildrenNode @@ -3740,13 +4222,18 @@ local function GetContainerPathSet() end function Database:ResetSearchCache() + if self._dynamicBatchLoading then + self._dynamicBatchChanged = true + return + end prevQuery = "" prevSkipKey = "" wipe(prevCandidates) containerPathCache = nil nameLookup = nil - prefix1Index = nil - prefix2Index = nil + local hadPrefixIndex = prefixIndexReady + ClearPrefixBuckets() + if hadPrefixIndex then self:BuildSearchPrefixIndex() end end local resultsBuf = {} @@ -3755,6 +4242,17 @@ local resultEntryPool = {} Database._resultsBuf = resultsBuf Database._resultEntryPool = resultEntryPool +function Database:TrimSearchMemory() + self:UnloadDynamicSearchData() + wipe(resultsBuf) + wipe(resultsQueryWords) + wipe(resultEntryPool) + wipe(wordCache) + wipe(wordCacheKeys) + wordCacheHead = 1 + ClearPrefixBuckets() +end + function Database:SearchUI(query, skipCategories) if not query or query == "" or #query < 2 then prevQuery = "" @@ -3789,11 +4287,15 @@ function Database:SearchUI(query, skipCategories) -- up in some achievement keyword. Gate them behind the user -- typing "ach"/"achievement"/etc. or a strong name match. local achQueryWord = false + local lootStatQueryWord = false for qi = 1, #queryWords do local qw = queryWords[qi] if qw == "boss" or qw == "bosses" then bossQueryWord = true end + if IsLootStatSearchWord(qw) then + lootStatQueryWord = true + end if ssub(qw, 1, 3) == "ach" or qw == "stat" or qw == "stats" or qw == "statistic" or qw == "statistics" then achQueryWord = true @@ -3818,18 +4320,20 @@ function Database:SearchUI(query, skipCategories) -- candidates only. (Cheapest path.) searchSet = prevCandidates else - -- Fresh query / backspace / typo correction: look up the - -- 2-char prefix bucket (smallest), fall back to 1-char (covers - -- single-char-word entries and rare prefixes), and only as a - -- last resort go full-scan. In practice the 2-char hit covers - -- ~all real queries. - if not prefix2Index then rebuildPrefixIndexes() end - local key2 = ssub(query, 1, 2) - searchSet = prefix2Index[key2] - if not searchSet then - searchSet = prefix1Index[ssub(query, 1, 1)] + if prefixIndexReady then + if #queryWords >= 2 then + searchSet = GetMultiTokenPrefixCandidates(queryWords) + else + local key2 = ssub(query, 1, 2) + searchSet = GetPrefixBucket(key2) + if not searchSet then + searchSet = GetPrefixBucket(ssub(query, 1, 1)) + end + end + else + searchSet = uiSearchData end - if not searchSet then + if not searchSet or #searchSet == 0 then wipe(resultsBuf) prevQuery = query prevSkipKey = skipKey @@ -3850,6 +4354,9 @@ function Database:SearchUI(query, skipCategories) local nameLower = data.nameLower local score if data.lootEntry then + if lootStatQueryWord and not data._statsEnriched then + Database:EnrichLootStats(data) + end -- 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. @@ -3941,6 +4448,9 @@ function Database:SearchUI(query, skipCategories) else score = Database:ScoreName(nameLower, query, queryLen, queryWords) score = score + Database:ScoreKeywords(data.keywordsLower, query, queryLen, queryWords) + if #queryWords >= 2 then + score = mmax(score, Database:ScoreEntryFields(data, queryWords)) + end end end diff --git a/DatabaseDynamic.lua b/DatabaseDynamic.lua new file mode 100644 index 0000000..14a9596 --- /dev/null +++ b/DatabaseDynamic.lua @@ -0,0 +1,179 @@ +local _, ns = ... + +local Database = ns.Database +local Utils = ns.Utils + +if not Database then return end + +local dynamicProviders = { + { key = "currencies", category = "Currency", fn = "PopulateDynamicCurrencies" }, + { key = "reputations", category = "Reputation", fn = "PopulateDynamicReputations" }, + { key = "mounts", category = "Mount", fn = "PopulateDynamicMounts" }, + { key = "toys", category = "Toy", fn = "PopulateDynamicToys" }, + { key = "pets", category = "Pet", fn = "PopulateDynamicPets" }, + { key = "outfits", category = "Outfit", fn = "PopulateDynamicOutfits" }, + { key = "heirlooms", category = "Heirloom", fn = "PopulateDynamicHeirlooms" }, + { key = "titles", category = "Title", fn = "PopulateDynamicTitles" }, + { key = "gearSets", category = "Gear Set", fn = "PopulateDynamicGearSets" }, + { key = "macros", category = "Macro", fn = "PopulateDynamicMacros" }, + { key = "abilities", category = "Ability", fn = "PopulateDynamicAbilities" }, + { key = "bags", category = "Bag", fn = "PopulateDynamicBags" }, + { key = "transmogSets", category = "Appearance Set", fn = "PopulateDynamicTransmogSets", pre = "SyncTransmogSetFiltersFromUI" }, + { key = "loot", category = "Loot", fn = "PopulateDynamicLoot", asyncFn = "PopulateDynamicLootAsync" }, + { key = "bosses", category = "Boss", fn = "PopulateDynamicBosses", asyncFn = "PopulateDynamicBossesAsync" }, +} + +local dynamicProviderByKey = {} +for i = 1, #dynamicProviders do + local provider = dynamicProviders[i] + provider.loaded = false + provider.dirty = true + dynamicProviderByKey[provider.key] = provider +end + +local function FinishDynamicProvider(database, provider, ok, err, changed, onDone) + if err == "cancelled" then + provider.loaded = false + provider.dirty = true + onDone(false) + return + end + if not ok then + provider.loaded = false + provider.dirty = false + if EasyFind and EasyFind.Print then + EasyFind:Print("|cffff4444" .. provider.key .. " search data failed: " .. tostring(err) .. "|r") + end + onDone(false) + return + end + + provider.loaded = true + provider.dirty = false + if changed ~= false and database.ResetSearchCache then + if database._dynamicBatchLoading then + database._dynamicBatchChanged = true + else + database:ResetSearchCache() + end + end + onDone(changed ~= false) +end + +local function RunDynamicProvider(database, provider, onDone) + if provider.loaded and not provider.dirty then onDone(false); return end + local pre = provider.pre and database[provider.pre] + if pre then pre(database) end + + local asyncFn = provider.asyncFn and database[provider.asyncFn] + if asyncFn then + local ok, err = xpcall(asyncFn, Utils.ErrorHandler, database, function(changed, asyncErr) + FinishDynamicProvider(database, provider, not asyncErr, asyncErr, changed, onDone) + end) + if not ok then + FinishDynamicProvider(database, provider, false, err, false, onDone) + end + return + end + + local fn = database[provider.fn] + if not fn then onDone(false); return end + + local ok, err = xpcall(fn, Utils.ErrorHandler, database) + FinishDynamicProvider(database, provider, ok, err, true, onDone) +end + +function Database:CancelDynamicWarmup() + if self.CancelDynamicScans then self:CancelDynamicScans() end +end + +function Database:MarkDynamicCategoryDirty(key) + local provider = dynamicProviderByKey[key] + if not provider then return end + provider.dirty = true + if provider.loaded and self.ResetSearchCache then self:ResetSearchCache() end +end + +function Database:IsDynamicProviderLoaded(key) + local provider = dynamicProviderByKey[key] + return provider and provider.loaded and not provider.dirty or false +end + +function Database:RefreshDynamicCategory(key) + local provider = dynamicProviderByKey[key] + if not provider then return false end + provider.dirty = true + local changed = false + RunDynamicProvider(self, provider, function(providerChanged) + changed = providerChanged + end) + return changed +end + +function Database:LoadCoreDynamicSearchData() + if self._loadingCoreDynamic then return false end + self._loadingCoreDynamic = true + self._dynamicBatchLoading = true + self._dynamicBatchChanged = false + + for i = 1, #dynamicProviders do + local provider = dynamicProviders[i] + if not provider.asyncFn then + RunDynamicProvider(self, provider, function() end) + end + end + + local changed = self._dynamicBatchChanged + self._dynamicBatchLoading = false + self._dynamicBatchChanged = false + self._loadingCoreDynamic = false + + if changed and self.ResetSearchCache then self:ResetSearchCache() end + return changed +end + +function Database:LoadHeavyDynamicSearchData(onDone) + if self._loadingHeavyDynamic then return false end + self._loadingHeavyDynamic = true + + local index = 1 + local function step() + while index <= #dynamicProviders do + local provider = dynamicProviders[index] + index = index + 1 + if provider.asyncFn then + RunDynamicProvider(self, provider, function() + if C_Timer and C_Timer.After then + C_Timer.After(0.05, step) + else + step() + end + end) + return + end + end + self._loadingHeavyDynamic = false + if onDone then onDone() end + end + + step() + return true +end + +function Database:UnloadDynamicSearchData(includeCore) + self:CancelDynamicWarmup() + for i = 1, #dynamicProviders do + local provider = dynamicProviders[i] + if includeCore or provider.asyncFn then + if provider.category then self:_RemoveEntriesByCategory(provider.category) end + provider.loaded = false + provider.dirty = true + end + end + if includeCore then + self:_ResetDynamicProviderCaches() + elseif self._ResetHeavyProviderCaches then + self:_ResetHeavyProviderCaches() + end + if self.ResetSearchCache then self:ResetSearchCache() end +end diff --git a/Demo.lua b/Demo.lua deleted file mode 100644 index 00a264e..0000000 --- a/Demo.lua +++ /dev/null @@ -1,4806 +0,0 @@ -local _, ns = ... - -local Demo = {} -ns.Demo = Demo - -local Utils = ns.Utils -local sfind, slower = Utils.sfind, Utils.slower -local select, ipairs, pairs = Utils.select, Utils.ipairs, Utils.pairs -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 -local TOOLTIP_BORDER = ns.TOOLTIP_BORDER -local DEFAULT_OPACITY = ns.DEFAULT_OPACITY -local DARK_PANEL_BG = ns.DARK_PANEL_BG - -local CreateFrame = CreateFrame -local C_Timer = C_Timer -local UIParent = UIParent -local GameTooltip = GameTooltip -local GameTooltip_Hide = GameTooltip_Hide -local IsShiftKeyDown = IsShiftKeyDown -local GetCursorPosition = GetCursorPosition -local InCombatLockdown = InCombatLockdown -local HideUIPanel = HideUIPanel -local wipe = wipe - --- Find the "Spellbook" tab entry in the search database. The demo --- bypasses fuzzy search ranking by passing this entry straight to --- SelectResult, so we know exactly what gets opened regardless of --- what ranks first for "sp". -local function FindSpellbookEntry() - if not (ns.Database and ns.Database.uiSearchData) then return nil end - for _, entry in ipairs(ns.Database.uiSearchData) do - if entry.name == "Spellbook" and entry.category == "Talents" then - return entry - end - end - return nil -end - --- Run the EasyFind setup demo. ctx fields: --- searchFrame, resultsFrame, resultButtons - main UI references --- finishSetup - callback that closes the setup tutorial UI -function Demo.Start(ctx) - local searchFrame = ctx.searchFrame - local resultsFrame = ctx.resultsFrame - local resultButtons = ctx.resultButtons - local FinishSetup = ctx.finishSetup - local UI = ns.UI - - -- Combat-safe abort: opening Collections and animating around the - -- screen can taint or fail under combat lockdown. - if InCombatLockdown() then - FinishSetup() - return - end - - local spellbookEntry = FindSpellbookEntry() - if not spellbookEntry then - FinishSetup() - return - end - - -- Lock the search bar dragging during the demo - searchFrame:SetScript("OnDragStart", nil) - - -- Force-load Blizzard_PlayerSpells so PlayerSpellsFrame and its - -- tabs exist as targets for the mock cursor and Highlight system. - if C_AddOns and C_AddOns.LoadAddOn then - pcall(C_AddOns.LoadAddOn, "Blizzard_PlayerSpells") - end - - -- Demo registry. Each entry has: - -- title - shown at the top of the demo panel - -- sections - list of { header, section, firstStep, lastStep } - -- stepDefs - list of { text, section } describing each step row - -- run - list of function(done) that animates each step - -- setupAfter - list of function() that snaps the game state to - -- the end of step i without animation (used by - -- Prev/Next/jumpToStep) - -- run and setupAfter for the default UI Search demo are populated - -- further down, after the helper functions they reference exist. - -- Other demos start empty and get filled in as we build them. - local DEMOS = { - uiSearch = { - title = "UI Search", - sections = { - { header = "Search and open a panel", section = 1, firstStep = 1, lastStep = 2 }, - }, - stepDefs = { - { text = 'Start typing "Spellbook"', section = 1 }, -- 1 - { text = "Click the Spellbook result", section = 1 }, -- 2 - }, - lockFrames = { "PlayerSpellsFrame" }, - run = {}, - setupAfter = {}, - }, - guide = { - title = "Guide Mode", - sections = { - { header = "Right-click a result to get a guided walkthrough", section = 1, firstStep = 1, lastStep = 4 }, - }, - stepDefs = { - { text = 'Start typing "Valorstones"', section = 1 }, -- 1 - { text = "Right-click the Valorstones row", section = 1 }, -- 2 - { text = "Click Guide in the menu", section = 1 }, -- 3 - { text = "Follow the step-by-step guide", section = 1 }, -- 4 - }, - lockFrames = { "CharacterFrame" }, - run = {}, - setupAfter = {}, - }, - mapSearchZone = { - title = "Zone/Instance Map Search", - sections = {}, - stepDefs = {}, - lockFrames = { "WorldMapFrame" }, - run = {}, - setupAfter = {}, - }, - mapSearchCurrent = { - title = "Current Zone Map Search", - sections = {}, - stepDefs = {}, - lockFrames = { "WorldMapFrame" }, - run = {}, - setupAfter = {}, - }, - mapSearchUI = { - title = "Map search through UI bar", - sections = { - { header = "Local Map POI search: Flight Master", section = 1, firstStep = 1, lastStep = 5 }, - { header = "Global zone search: Eastern Plaguelands", section = 2, firstStep = 6, lastStep = 9 }, - }, - stepDefs = { - { text = "Open the filter menu", section = 1 }, -- 1 - { text = "Enable Map Search filter", section = 1 }, -- 2 - { text = 'Confirm "Local" is selected', section = 1 }, -- 3 - { text = 'Start typing "Flight Master"', section = 1 }, -- 4 - { text = "Click the Flight Master result", section = 1 }, -- 5 - { text = "Open the filter menu", section = 2 }, -- 6 - { text = 'Switch to "Global"', section = 2 }, -- 7 - { text = 'Start typing "Eastern Plaguelands"', section = 2 }, -- 8 - { text = "Click the Eastern Plaguelands result", section = 2 }, -- 9 - }, - lockFrames = { "WorldMapFrame" }, - run = {}, - setupAfter = {}, - }, - outfits = { title = "Outfits", sections = {}, stepDefs = {}, lockFrames = {}, run = {}, setupAfter = {} }, - appearanceSets = { title = "Appearance Sets", sections = {}, stepDefs = {}, lockFrames = {}, run = {}, setupAfter = {} }, - loot = { title = "Loot", sections = {}, stepDefs = {}, lockFrames = {}, run = {}, setupAfter = {} }, - mounts = { title = "Mounts", sections = {}, stepDefs = {}, lockFrames = {}, run = {}, setupAfter = {} }, - } - -- Working refs reassigned by loadDemo. Existing state-machine code - -- captures these as upvalues, so swapping them here propagates. - local currentDemo = DEMOS.uiSearch - local currentDemoKey = "uiSearch" - local DEMO_STEPS = currentDemo.stepDefs - local DEMO_SECTIONS = currentDemo.sections - local demoSteps -- assigned after the run table is populated - local setupAfterStep -- assigned after the setupAfter table is populated - local loadDemo -- forward decl; defined after setupAfter is populated - local refreshDemoMenuActive -- forward decl; defined where the menu items are built - local active -- forward decl; assigned later as the demo "is open" flag - - -- Playback speed multiplier set by the speed flyout. Declared - -- early so the scroll animation OnUpdate (defined a few lines - -- below with the demo frame) can capture it as an upvalue. - -- tickFrame's OnUpdate and cursor handlers also close over - -- this so the whole demo runs at the chosen rate. - local playbackSpeed = 1.0 - - demoFrame = CreateFrame("Frame", nil, UIParent, "BackdropTemplate") - -- Fixed height: the step list scrolls inside a ScrollFrame - -- anchored below the title separator. Demos that are longer - -- than the visible area scroll the active step into view; the - -- rest of the list stays clipped above/below like a crawl. - demoFrame:SetSize(280, 290) - -- Shift down from center so the panel doesn't cover the minimap. - -- User can still drag it anywhere they want. - demoFrame:SetPoint("RIGHT", UIParent, "RIGHT", -32, -60) - demoFrame:SetFrameStrata("FULLSCREEN_DIALOG") - demoFrame:SetIgnoreParentAlpha(true) - -- Make the panel draggable so the user can move it out of the - -- way when it covers the minimap or the demo target window. - demoFrame:SetMovable(true) - demoFrame:EnableMouse(true) - demoFrame:RegisterForDrag("LeftButton") - demoFrame:SetScript("OnDragStart", demoFrame.StartMoving) - demoFrame:SetScript("OnDragStop", demoFrame.StopMovingOrSizing) - demoFrame:SetBackdrop({ - edgeFile = TOOLTIP_BORDER, - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 }, - }) - demoFrame:SetBackdropBorderColor(0.50, 0.48, 0.45, 1.0) - - local demoBgTex = demoFrame:CreateTexture(nil, "BACKGROUND", nil, -1) - demoBgTex:SetPoint("TOPLEFT", 4, -4) - demoBgTex:SetPoint("BOTTOMRIGHT", -4, 4) - demoBgTex:SetAtlas("QuestLog-main-background", false) - demoBgTex:SetAlpha(1.0) - - local title = demoFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge") - title:SetPoint("TOP", 0, -12) - title:SetJustifyH("CENTER") - title:SetSpacing(2) - title:SetText("|cffFFD100" .. currentDemo.title .. "|r") - - local titleSep = demoFrame:CreateTexture(nil, "ARTWORK") - titleSep:SetHeight(1) - titleSep:SetPoint("TOPLEFT", 16, -60) - titleSep:SetPoint("TOPRIGHT", -16, -60) - titleSep:SetColorTexture(0.4, 0.4, 0.4, 0.7) - - -- Scroll container for the step list. Sits directly below the - -- title separator and above the transport-button row. Rows are - -- anchored to stepScrollChild, so when the active step changes - -- we simply SetVerticalScroll() on stepScrollFrame and the rows - -- slide up "under" the title line (clipping is automatic). - -- Mouse wheel lets the user peek back at earlier steps. - local stepScrollFrame = CreateFrame("ScrollFrame", nil, demoFrame) - stepScrollFrame:SetPoint("TOPLEFT", demoFrame, "TOPLEFT", 8, -64) - stepScrollFrame:SetPoint("TOPRIGHT", demoFrame, "TOPRIGHT", -8, -64) - stepScrollFrame:SetPoint("BOTTOM", demoFrame, "BOTTOM", 0, 88) - stepScrollFrame:EnableMouseWheel(true) - local stepScrollChild = CreateFrame("Frame", nil, stepScrollFrame) - stepScrollChild:SetSize(1, 1) -- width follows scrollFrame, height grows - stepScrollFrame:SetScrollChild(stepScrollChild) - -- ScrollFrame + ScrollChild default width behavior: child width - -- can be less than parent. Force the child to match the - -- scroll frame's width so anchoring to TOPLEFT/TOPRIGHT works. - stepScrollFrame:HookScript("OnSizeChanged", function(self, w) - stepScrollChild:SetWidth(w) - end) - stepScrollChild:SetWidth(stepScrollFrame:GetWidth()) - - -- Smooth scroll animation: callers set scrollTarget, an - -- OnUpdate tween eases scrollCurrent toward it. Avoids the - -- jumpy SetVerticalScroll-every-step feel. - local stepScrollMax = 0 - local scrollCurrent = 0 - local scrollTarget = 0 - local scrollAnimFrame = CreateFrame("Frame") - scrollAnimFrame:Hide() - scrollAnimFrame:SetScript("OnUpdate", function(self, dt) - local diff = scrollTarget - scrollCurrent - if math.abs(diff) < 0.3 then - scrollCurrent = scrollTarget - stepScrollFrame:SetVerticalScroll(scrollCurrent) - self:Hide() - return - end - -- Exponential ease scaled by playbackSpeed so the scroll - -- tween finishes faster at higher speeds (matching cursor - -- moves and safeAfter-based pauses). - local step = diff * mmin(dt * 8 * playbackSpeed, 1) - scrollCurrent = scrollCurrent + step - stepScrollFrame:SetVerticalScroll(scrollCurrent) - end) - - local function setScrollTarget(target) - if target < 0 then target = 0 end - if target > stepScrollMax then target = stepScrollMax end - scrollTarget = target - scrollAnimFrame:Show() - end - - stepScrollFrame:SetScript("OnMouseWheel", function(_, delta) - setScrollTarget(scrollTarget - delta * 20) - end) - - -- Step row builder. Pools row buttons and section header buttons - -- so loadDemo can swap demos without leaking frames. Each step row - -- is a Button whose OnClick jumps to that step. Each section - -- header is also a Button whose OnClick jumps to the state right - -- BEFORE that section's first step (so the next Play / Next plays - -- the section from the very beginning, not the end of step 1). - -- jumpToStep / jumpToBeforeStep are forward-declared here and - -- assigned later, after setupAfterStep exists. - local jumpToStep - local jumpToBeforeStep - local highlightOverride -- forward decl; set by step-row clicks - local pendingSectionHighlight -- forward decl; set by step funcs to pre-highlight a section header during transition text - -- Cursor position snapshots: cursorEndPos[i] = { x, y } is the - -- cursor location at the moment step i's done() fired. Used - -- when the user clicks a step row to jump to that step, so - -- the cursor appears exactly where it would be mid-playback - -- instead of resetting to the default starting spot. - local cursorEndPos = {} - local refreshStepList -- forward decl; defined after rebuildStepRows - local stepFS = {} -- indexed by DEMO_STEPS index, used by refreshStepList - local headerFS = {} -- indexed by section number, used by refreshStepList - local stepRowPool = {} -- pool of { btn, fs } reused across demos - local headerPool = {} -- pool of { btn, fs } reused across demos - - local emptyMsg = demoFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal") - emptyMsg:SetPoint("TOP", 0, -100) - emptyMsg:SetTextColor(0.6, 0.6, 0.6, 1) - emptyMsg:SetText("Coming soon...") - emptyMsg:Hide() - - -- Tracks per-step Y offset inside the scroll child so - -- scrollToStep can slide the active step into view. - local stepYOffset = {} -- [stepIdx] = positive pixels from top of scrollChild - local headerYOffset = {} -- [sectionNum] = positive pixels from top of scrollChild - - local function rebuildStepRows() - for _, hd in ipairs(headerPool) do hd.btn:Hide() end - for _, row in ipairs(stepRowPool) do row.btn:Hide() end - wipe(headerFS) - wipe(stepFS) - wipe(stepYOffset) - wipe(headerYOffset) - if not DEMO_SECTIONS or #DEMO_SECTIONS == 0 then - emptyMsg:Show() - stepScrollChild:SetHeight(1) - stepScrollMax = 0 - scrollCurrent = 0 - scrollTarget = 0 - stepScrollFrame:SetVerticalScroll(0) - return - end - emptyMsg:Hide() - local headerIdx, rowIdx = 0, 0 - local y = 4 -- positive offset from top of scrollChild - for sIdx, sect in ipairs(DEMO_SECTIONS) do - -- Visual gap between sections (skip before the first one) - if sIdx > 1 then y = y + 12 end - local hasHeader = sect.header and sect.header ~= "" - if hasHeader then - headerIdx = headerIdx + 1 - local hd = headerPool[headerIdx] - if not hd then - local hbtn = CreateFrame("Button", nil, stepScrollChild) - hbtn:SetHighlightTexture("Interface\\Buttons\\WHITE8x8", "ADD") - local hhl = hbtn:GetHighlightTexture() - hhl:SetVertexColor(1, 0.82, 0, 0.12) - hhl:SetPoint("TOPLEFT", hbtn, "TOPLEFT", -2, 0) - hhl:SetPoint("BOTTOMRIGHT", hbtn, "BOTTOMRIGHT", 2, 0) - local hfs = hbtn:CreateFontString(nil, "OVERLAY", "GameFontNormal") - hfs:SetPoint("LEFT", hbtn, "LEFT", 0, 0) - hd = { btn = hbtn, fs = hfs } - headerPool[headerIdx] = hd - else - hd.btn:SetParent(stepScrollChild) - end - hd.btn:ClearAllPoints() - hd.btn:SetPoint("TOPLEFT", stepScrollChild, "TOPLEFT", 8, -y) - hd.btn:SetPoint("TOPRIGHT", stepScrollChild, "TOPRIGHT", -4, -y) - hd.btn:SetHeight(20) - hd.btn:Show() - hd.fs:SetText(sect.header) - local firstStep = sect.firstStep - hd.btn:SetScript("OnClick", function() - if jumpToBeforeStep then jumpToBeforeStep(firstStep) end - end) - headerFS[sect.section] = hd.fs - headerYOffset[sect.section] = y - y = y + 24 - end - for i = sect.firstStep, sect.lastStep do - local step = DEMO_STEPS[i] - rowIdx = rowIdx + 1 - local row = stepRowPool[rowIdx] - if not row then - local btn = CreateFrame("Button", nil, stepScrollChild) - btn:SetHighlightTexture("Interface\\Buttons\\WHITE8x8", "ADD") - local hl = btn:GetHighlightTexture() - hl:SetVertexColor(1, 0.82, 0, 0.12) - hl:SetPoint("TOPLEFT", btn, "TOPLEFT", -2, 0) - hl:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 2, 0) - local fs = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal") - fs:SetPoint("LEFT", btn, "LEFT", 4, 0) - row = { btn = btn, fs = fs } - stepRowPool[rowIdx] = row - else - row.btn:SetParent(stepScrollChild) - end - row.btn:ClearAllPoints() - row.btn:SetPoint("TOPLEFT", stepScrollChild, "TOPLEFT", 20, -(y + 2)) - row.btn:SetPoint("TOPRIGHT", stepScrollChild, "TOPRIGHT", -4, -(y + 2)) - row.btn:SetHeight(18) - row.btn:Show() - row.fs:SetText(step.text) - stepYOffset[i] = y - local stepIdx = i - row.btn:SetScript("OnClick", function() - if jumpToBeforeStep then - jumpToBeforeStep(stepIdx) - highlightOverride = stepIdx - refreshStepList() - end - end) - stepFS[i] = row.fs - y = y + 20 - end - end - local contentHeight = y + 4 - stepScrollChild:SetHeight(contentHeight) - -- Only allow scrolling when the content actually - -- overflows the viewport. Short demos whose whole step - -- list fits inside stepScrollFrame get stepScrollMax = 0, - -- so setScrollTarget clamps every request back to 0 and - -- the list sits still — no pointless crawl when there's - -- nothing hidden. - local viewportHeight = stepScrollFrame:GetHeight() or 0 - stepScrollMax = contentHeight - viewportHeight - if stepScrollMax < 0 then stepScrollMax = 0 end - scrollCurrent = 0 - scrollTarget = 0 - stepScrollFrame:SetVerticalScroll(0) - end - rebuildStepRows() - - -- Transport controls: [◀ Prev] [Play/Pause] [Next ▶] - -- The arrow buttons use the existing flyout-arrow.tga rotated, - -- and play/pause uses custom TGAs (demo-play.tga, demo-pause.tga) - -- so the glyphs render reliably regardless of font support. - local playBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") - playBtn:SetSize(35, 27) - playBtn:SetPoint("BOTTOM", demoFrame, "BOTTOM", 0, 44) - playBtn:SetText("") - local playIcon = playBtn:CreateTexture(nil, "OVERLAY") - playIcon:SetSize(11, 11) - playIcon:SetPoint("CENTER") - playIcon:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") - playBtn.icon = playIcon - - local prevBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") - prevBtn:SetSize(27, 27) - prevBtn:SetPoint("RIGHT", playBtn, "LEFT", -3, 0) - prevBtn:SetText("") - local prevIcon = prevBtn:CreateTexture(nil, "OVERLAY") - prevIcon:SetSize(10, 10) - prevIcon:SetPoint("CENTER") - prevIcon:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") - prevIcon:SetTexCoord(1, 0, 0, 1) -- horizontal flip for left-pointing arrow - - local nextBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") - nextBtn:SetSize(27, 27) - nextBtn:SetPoint("LEFT", playBtn, "RIGHT", 3, 0) - nextBtn:SetText("") - local nextIcon = nextBtn:CreateTexture(nil, "OVERLAY") - nextIcon:SetSize(10, 10) - nextIcon:SetPoint("CENTER") - nextIcon:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") - - -- Section skip buttons (<<, >>) sit on the far ends of the - -- transport row. Each renders the flyout-arrow texture twice so - -- the icon reads as a "double arrow". Click jumps to the previous - -- / next section header (or very start / end of the demo). - local sectPrevBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") - sectPrevBtn:SetSize(27, 27) - sectPrevBtn:SetPoint("RIGHT", prevBtn, "LEFT", -3, 0) - sectPrevBtn:SetText("") - local sectPrevIcon1 = sectPrevBtn:CreateTexture(nil, "OVERLAY") - sectPrevIcon1:SetSize(8, 8) - sectPrevIcon1:SetPoint("CENTER", -4, 0) - sectPrevIcon1:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") - sectPrevIcon1:SetTexCoord(1, 0, 0, 1) - local sectPrevIcon2 = sectPrevBtn:CreateTexture(nil, "OVERLAY") - sectPrevIcon2:SetSize(8, 8) - sectPrevIcon2:SetPoint("CENTER", 4, 0) - sectPrevIcon2:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") - sectPrevIcon2:SetTexCoord(1, 0, 0, 1) - - local sectNextBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") - sectNextBtn:SetSize(27, 27) - sectNextBtn:SetPoint("LEFT", nextBtn, "RIGHT", 3, 0) - sectNextBtn:SetText("") - local sectNextIcon1 = sectNextBtn:CreateTexture(nil, "OVERLAY") - sectNextIcon1:SetSize(8, 8) - sectNextIcon1:SetPoint("CENTER", -4, 0) - sectNextIcon1:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") - local sectNextIcon2 = sectNextBtn:CreateTexture(nil, "OVERLAY") - sectNextIcon2:SetSize(8, 8) - sectNextIcon2:SetPoint("CENTER", 4, 0) - sectNextIcon2:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") - - -- Tooltip hints so the arrow-only buttons are still discoverable - local function attachTooltip(btn, text) - btn:SetScript("OnEnter", function(self) - GameTooltip:SetOwner(self, "ANCHOR_TOP") - GameTooltip:SetText(text) - GameTooltip:Show() - end) - btn:SetScript("OnLeave", GameTooltip_Hide) - end - -- Replay button: appears when the demo reaches its final step, - -- replacing the Play button. Clicking it resets the demo to its - -- initial state so the player can choose Play or Next again. - local replayBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") - replayBtn:SetSize(35, 27) - replayBtn:SetPoint("CENTER", playBtn, "CENTER", 0, 0) - replayBtn:SetText("") - local replayIcon = replayBtn:CreateTexture(nil, "OVERLAY") - replayIcon:SetSize(14, 14) - replayIcon:SetPoint("CENTER") - replayIcon:SetTexture("Interface\\AddOns\\EasyFind\\demo-replay") - replayBtn:Hide() - - -- Stop button: hard-resets the demo to its initial state, as if - -- the demo window had just been opened. Sits at the very far - -- left of the transport row. - local stopBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") - stopBtn:SetSize(27, 27) - stopBtn:SetPoint("RIGHT", sectPrevBtn, "LEFT", -3, 0) - stopBtn:SetText("") - local stopIcon = stopBtn:CreateTexture(nil, "OVERLAY") - stopIcon:SetSize(9, 9) - stopIcon:SetPoint("CENTER") - stopIcon:SetTexture("Interface\\Buttons\\WHITE8x8") - stopIcon:SetVertexColor(0.95, 0.85, 0.2, 1) - - -- Speed button: opens a flyout with playback-speed multipliers. - -- Sits at the very far right of the transport row. Uses the - -- gold cogwheel TGA generated by gen_cog_icon.py. - local speedBtn = CreateFrame("Button", nil, demoFrame, "UIPanelButtonTemplate") - speedBtn:SetSize(27, 27) - speedBtn:SetPoint("LEFT", sectNextBtn, "RIGHT", 3, 0) - speedBtn:SetText("") - local speedIcon = speedBtn:CreateTexture(nil, "OVERLAY") - speedIcon:SetSize(16, 16) - speedIcon:SetPoint("CENTER") - speedIcon:SetTexture("Interface\\AddOns\\EasyFind\\demo-cog") - - attachTooltip(stopBtn, "Stop / Reset Demo") - attachTooltip(sectPrevBtn, "Previous Section") - attachTooltip(prevBtn, "Previous Step") - attachTooltip(nextBtn, "Next Step") - attachTooltip(sectNextBtn, "Next Section") - attachTooltip(speedBtn, "Playback Speed") - attachTooltip(playBtn, "Play / Pause") - attachTooltip(replayBtn, "Replay Demo") - - -- Top-right close button (matches the Options panel). Replaces - -- the old "Got it" bottom button so the bottom of the panel can - -- pull tighter against the dropdown row. - local closeDemoBtn = CreateFrame("Button", nil, demoFrame, "UIPanelCloseButton") - closeDemoBtn:SetPoint("TOPRIGHT", demoFrame, "TOPRIGHT", -5, -5) - - -- "See more demos" dropdown. Same visual style as the multi-select - -- dropdowns in the EasyFind options panel (Search Bars, EF Map - -- Icons, Minimap, Map Pins): WoW common-dropdown atlas background - -- with a chevron arrow on the right. Sits between the transport - -- row and the Got it button. Opens upward over the step list so - -- it doesn't spill below the demo panel. - local DEMO_LIST = { - { name = "UI Search", key = "uiSearch" }, - { name = "Guide Mode", key = "guide" }, - { name = "Outfits", key = "outfits" }, - { name = "Appearance sets", key = "appearanceSets" }, - { name = "Loot", key = "loot" }, - { name = "Mounts", key = "mounts" }, - } - - local moreBtn = CreateFrame("Button", nil, demoFrame) - moreBtn:SetSize(200, 26) - moreBtn:SetPoint("BOTTOM", demoFrame, "BOTTOM", 0, 12) - local moreBg = moreBtn:CreateTexture(nil, "BACKGROUND") - moreBg:SetAllPoints() - moreBg:SetAtlas("common-dropdown-b-button-hover") - local moreBtnText = moreBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal") - moreBtnText:SetPoint("LEFT", 4, 0) - moreBtnText:SetPoint("RIGHT", -18, 0) - moreBtnText:SetJustifyH("CENTER") - moreBtnText:SetText("See more demos") - local moreArrow = moreBtn:CreateTexture(nil, "OVERLAY") - moreArrow:SetSize(18, 18) - moreArrow:SetPoint("RIGHT", -2, 0) - moreArrow:SetAtlas("common-dropdown-b-arrow-closed") - - local MORE_ROW_H = 24 - local moreFlyout = CreateFrame("Frame", nil, UIParent, "BackdropTemplate") - moreFlyout:SetSize(200, #DEMO_LIST * MORE_ROW_H + 8) - moreFlyout:SetFrameStrata("FULLSCREEN_DIALOG") - moreFlyout:SetFrameLevel(900) - moreFlyout:SetBackdrop({ - bgFile = "Interface\\BUTTONS\\WHITE8X8", - edgeFile = TOOLTIP_BORDER, - tile = true, tileSize = 16, edgeSize = 12, - insets = { left = 2, right = 2, top = 2, bottom = 2 }, - }) - moreFlyout:SetBackdropColor(0.08, 0.08, 0.08, 1) - moreFlyout:Hide() - - moreBtn:SetScript("OnClick", function() - moreFlyout:ClearAllPoints() - -- Open upward so the menu hangs over the step list instead - -- of spilling below the demo panel. - moreFlyout:SetPoint("BOTTOM", moreBtn, "TOP", 0, 2) - local opening = not moreFlyout:IsShown() - moreFlyout:SetShown(opening) - moreArrow:SetAtlas(opening and "common-dropdown-b-arrow-open" or "common-dropdown-b-arrow-closed") - end) - moreFlyout:SetScript("OnShow", function(self) - self:SetScript("OnUpdate", function(s) - if not s:IsMouseOver() and not moreBtn:IsMouseOver() then - if IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton") then - s:Hide() - end - end - end) - end) - moreFlyout:SetScript("OnHide", function(self) - self:SetScript("OnUpdate", nil) - moreArrow:SetAtlas("common-dropdown-b-arrow-closed") - end) - - local moreItems = {} - for i, entry in ipairs(DEMO_LIST) do - local item = CreateFrame("Button", nil, moreFlyout) - item:SetSize(192, 22) - item:SetPoint("TOPLEFT", moreFlyout, "TOPLEFT", 4, -4 - (i - 1) * MORE_ROW_H) - item:SetNormalFontObject("GameFontNormal") - item:SetHighlightFontObject("GameFontHighlight") - item:SetText(entry.name) - local itemHL = item:CreateTexture(nil, "BACKGROUND") - itemHL:SetAllPoints() - itemHL:SetColorTexture(1, 0.82, 0, 0.12) - itemHL:Hide() - item:SetScript("OnEnter", function() itemHL:Show() end) - item:SetScript("OnLeave", function() itemHL:Hide() end) - local entryKey = entry.key - item:SetScript("OnClick", function() - moreFlyout:Hide() - if loadDemo then loadDemo(entryKey) end - end) - moreItems[i] = { btn = item, key = entry.key, name = entry.name } - end - - -- Color the active demo's row gold so the player can see which - -- demo is currently loaded. Inactive rows render in white. - refreshDemoMenuActive = function() - for _, mi in ipairs(moreItems) do - if mi.key == currentDemoKey then - mi.btn:SetText("|cffFFD100" .. mi.name .. "|r") - else - mi.btn:SetText("|cffFFFFFF" .. mi.name .. "|r") - end - end - end - refreshDemoMenuActive() - - -- Playback-speed flyout. Opens above the speed cog button at - -- the far-right of the transport row. Active speed is gold. - local SPEED_OPTIONS = { 0.5, 0.75, 1.0, 1.25, 1.5 } - local SPEED_ROW_H = 22 - local speedFlyout = CreateFrame("Frame", nil, UIParent, "BackdropTemplate") - speedFlyout:SetSize(80, #SPEED_OPTIONS * SPEED_ROW_H + 8) - speedFlyout:SetFrameStrata("FULLSCREEN_DIALOG") - speedFlyout:SetFrameLevel(900) - speedFlyout:SetBackdrop({ - bgFile = "Interface\\BUTTONS\\WHITE8X8", - edgeFile = TOOLTIP_BORDER, - tile = true, tileSize = 16, edgeSize = 12, - insets = { left = 2, right = 2, top = 2, bottom = 2 }, - }) - speedFlyout:SetBackdropColor(0.08, 0.08, 0.08, 1) - speedFlyout:Hide() - - speedBtn:SetScript("OnClick", function() - speedFlyout:ClearAllPoints() - speedFlyout:SetPoint("BOTTOM", speedBtn, "TOP", 0, 4) - speedFlyout:SetShown(not speedFlyout:IsShown()) - end) - speedFlyout:SetScript("OnShow", function(self) - self:SetScript("OnUpdate", function(s) - if not s:IsMouseOver() and not speedBtn:IsMouseOver() then - if IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton") then - s:Hide() - end - end - end) - end) - speedFlyout:SetScript("OnHide", function(self) - self:SetScript("OnUpdate", nil) - end) - - local speedItems = {} - local function refreshSpeedMenu() - for _, mi in ipairs(speedItems) do - if mi.value == playbackSpeed then - mi.btn:SetText("|cffFFD100" .. mi.label .. "|r") - else - mi.btn:SetText("|cffFFFFFF" .. mi.label .. "|r") - end - end - end - for i, mult in ipairs(SPEED_OPTIONS) do - local item = CreateFrame("Button", nil, speedFlyout) - item:SetSize(72, 20) - item:SetPoint("TOPLEFT", speedFlyout, "TOPLEFT", 4, -4 - (i - 1) * SPEED_ROW_H) - item:SetNormalFontObject("GameFontNormal") - item:SetHighlightFontObject("GameFontHighlight") - local label = (mult == math.floor(mult)) and (tostring(math.floor(mult)) .. "x") or (tostring(mult) .. "x") - item:SetText(label) - local itemHL = item:CreateTexture(nil, "BACKGROUND") - itemHL:SetAllPoints() - itemHL:SetColorTexture(1, 0.82, 0, 0.12) - itemHL:Hide() - item:SetScript("OnEnter", function() itemHL:Show() end) - item:SetScript("OnLeave", function() itemHL:Hide() end) - local mySpeed = mult - item:SetScript("OnClick", function() - playbackSpeed = mySpeed - speedFlyout:Hide() - refreshSpeedMenu() - end) - speedItems[i] = { btn = item, value = mult, label = label } - end - refreshSpeedMenu() - - -- State vars used by both setActiveStep() and the state machine. - -- completedUpTo: highest step index that's already run (0 = none) - -- animatingIdx: step currently running (0 = none) - local completedUpTo = 0 - local animatingIdx = 0 - - -- Re-render the step list. Only ONE element is bright gold at a - -- time: a step when animating or mid-section, the section HEADER - -- when idle at a section boundary (e.g., demo just opened or - -- just finished the previous section). The active section's - -- header is white; all non-active headers and non-active steps - -- are gray. - -- Scroll the active step into view inside stepScrollFrame. The - -- target Y offset is chosen so the step sits a little below the - -- top edge (so it looks like it just "crawled up" past the - -- title separator). Routes through setScrollTarget so the - -- scroll animates smoothly instead of snapping. - local function scrollToStep(stepIdx) - if not stepIdx or not stepYOffset[stepIdx] then return end - setScrollTarget(stepYOffset[stepIdx] - 6) - end - - local function scrollToHeader(sectionNum) - if not sectionNum or not headerYOffset[sectionNum] then return end - setScrollTarget(headerYOffset[sectionNum] - 6) - end - - -- Called by a step function to pre-highlight a later section's - -- header while the step is still technically running (e.g. - -- while the "Now let's..." transition text is on screen). The - -- header lights up and scrolls into view immediately. Stays - -- highlighted until the next runStep clears the flag or a - -- cancelInFlight wipes demo state. - local function beginSectionTransition(sectionNum) - pendingSectionHighlight = sectionNum - refreshStepList() - end - - refreshStepList = function() - if not DEMO_STEPS or #DEMO_STEPS == 0 then return end - local activeStep, activeHeader - if pendingSectionHighlight then - -- A step's action has explicitly requested the next - -- section's header be highlighted (e.g. during the - -- "Now let's see what that looks like..." transition - -- text). The header stays gold, no step is active. - activeHeader = pendingSectionHighlight - elseif highlightOverride then - activeStep = highlightOverride - elseif animatingIdx > 0 then - activeStep = animatingIdx - else - local nextIdx = completedUpTo + 1 - if nextIdx <= #DEMO_STEPS then - -- At a section boundary? Highlight the header instead - -- of the first step (the step lights up once Play is - -- hit, not before). - local atBoundary = false - for _, sect in ipairs(DEMO_SECTIONS) do - if nextIdx == sect.firstStep then - atBoundary = true - activeHeader = sect.section - break - end - end - if not atBoundary then - activeStep = nextIdx - end - end - end - local focusSection - if activeStep then - focusSection = DEMO_STEPS[activeStep] and DEMO_STEPS[activeStep].section - elseif activeHeader then - focusSection = activeHeader - end - - for _, sect in ipairs(DEMO_SECTIONS) do - local hfs = headerFS[sect.section] - if hfs then - if sect.section == activeHeader then - hfs:SetTextColor(1.0, 0.82, 0.0, 1.0) -- gold (highlighted) - elseif sect.section == focusSection then - hfs:SetTextColor(1.0, 1.0, 1.0, 1.0) -- white (active section) - else - hfs:SetTextColor(0.5, 0.5, 0.5, 0.85) -- gray - end - end - end - - for j, fs in ipairs(stepFS) do - if j == activeStep then - fs:SetTextColor(1.0, 0.82, 0.0, 1.0) -- gold (current step) - else - fs:SetTextColor(0.5, 0.5, 0.5, 0.85) -- gray - end - end - - -- Slide the active step (or active header at a section - -- boundary) into the visible window. Earlier rows scroll - -- "under" the title separator (clipped by the scroll - -- frame), later rows remain below it until their turn. - if activeStep then - scrollToStep(activeStep) - elseif activeHeader then - scrollToHeader(activeHeader) - end - end - refreshStepList() - - -- Everything below lives inside _runDemo so its locals don't - -- count toward startDemo's MAXVARS=200 budget AND so they - -- aren't upvalues for _runDemo's inner closures (avoids - -- MAXUPVAL=60). _runDemo runs immediately at the bottom of - -- startDemo. - local function _runDemo() - - -- Save originals so the lock toggling system can restore them - -- when the demo isn't actively running. The search bar drag - -- handler and the editbox keyboard state are both temporarily - -- replaced/disabled while a step is animating, then restored - -- whenever the demo is paused, idle, or stopped. - local savedDragStart = searchFrame:GetScript("OnDragStart") - -- Also snapshot the UI bar's Map Search filter toggle and - -- local/global sub-option. The map-search-through-UI-bar demo - -- flips both and must restore the user's actual pre-demo - -- preference on stop/close, otherwise the bar would behave - -- differently after running the demo. - local savedUiMapFilter - if EasyFind.db.uiSearchFilters then - savedUiMapFilter = EasyFind.db.uiSearchFilters.map - end - local savedUiMapSearchLocal = EasyFind.db.uiMapSearchLocal - - -- Snapshot WMF's current mapID so demos that navigate WMF (the - -- zone/instance demo calls SelectResult, which SetMapIDs to the - -- instance entrance) don't leak that state into later demos. - -- WMF may not be loaded yet at this point; openWorldMap will - -- lazy-capture the real pre-demo mapID right after it loads the - -- addon. restoreWmfMapID reverts WMF on every demo switch and - -- on panel close. - local savedWmfMapID - if WorldMapFrame and WorldMapFrame.GetMapID then - savedWmfMapID = WorldMapFrame:GetMapID() - end - local function restoreWmfMapID() - if not savedWmfMapID then return end - if InCombatLockdown() then return end - if not (WorldMapFrame and WorldMapFrame.SetMapID) then return end - if WorldMapFrame:GetMapID() == savedWmfMapID then return end - pcall(WorldMapFrame.SetMapID, WorldMapFrame, savedWmfMapID) - end - - -- The UI filter dropdown has an OnUpdate handler that auto-hides - -- when the user's mouse button is down outside the dropdown. The - -- demo's click blockers eat real clicks but the button-down - -- state still reads true, which causes spurious auto-close - -- during demo playback. Swap in a no-op OnUpdate for the demo's - -- lifetime so the dropdown stays open when the demo wants it - -- open. Suspend/resume is tied to applyRunningLocks/releaseRunningLocks - -- so the dropdown works normally when the demo is idle. - - -- Temporarily force-disable movement fade (staticOpacity = true) - -- so the search bar stays at full alpha even if the player walks - -- around mid-demo. Restored on endDemo. - local savedStaticOpacity = EasyFind.db.staticOpacity - EasyFind.db.staticOpacity = true - searchFrame:SetAlpha(1.0) - - -- Mock cursor that animates between targets. Parented to UIParent - -- so we can position it in any coordinate space via raw pixel - -- offsets and so it floats above the rest of the UI. - local cursor = CreateFrame("Frame", nil, UIParent) - cursor:SetSize(36, 36) - cursor:SetFrameStrata("TOOLTIP") - cursor:SetFrameLevel(10001) - cursor:EnableMouse(false) - local cursorTex = cursor:CreateTexture(nil, "OVERLAY") - cursorTex:SetAllPoints() - -- HD Gauntlet texture in its natural orientation. - cursorTex:SetTexture(4489300) - cursorTex:SetTexCoord(0.0000, 0.2315, 0.0000, 0.4104) - cursor:Hide() - - local rightClickIcon = cursor:CreateTexture(nil, "OVERLAY") - rightClickIcon:SetAtlas("newplayertutorial-icon-mouse-rightbutton") - rightClickIcon:SetSize(48, 48) - rightClickIcon:SetPoint("LEFT", cursor, "RIGHT", 2, 0) - rightClickIcon:Hide() - - active = true - -- stepGen is bumped whenever an in-flight animation is cancelled so - -- Prev/Next can interrupt at any time. Every async helper captures - -- the gen at call time and aborts if it no longer matches. - local stepGen = 0 - -- True-pause support: virtualTime only advances when not paused, and - -- safeAfter timers fire based on virtualTime via tickFrame's - -- OnUpdate. Cursor OnUpdate handlers also early-return when paused - -- so cursor moves and click animations freeze in place. - local paused = false - local virtualTime = 0 - local pendingTimers = {} - local stopBlinkCursor -- forward decl; defined below next to typeText - local updateLockState -- forward decl; defined where the locks are built - local locksSuppressed -- forward decl; defined where the locks are built - local applyRunningLocks -- forward decl; defined where the locks are built - local releaseRunningLocks -- forward decl; defined where the locks are built - local setHoveredRow -- forward decl; defined with result-row hover - local clearButtonHover -- forward decl; defined before moveCursorTo - local resetMapSearchState -- forward decl; defined with map search demo - local closeWorldMap -- forward decl; defined with map search demo - local hideMapCaret -- forward decl; defined with map search demo - - local tickFrame = CreateFrame("Frame") - tickFrame:SetScript("OnUpdate", function(_, dt) - if not active or paused then return end - virtualTime = virtualTime + dt * playbackSpeed - local i = 1 - while i <= #pendingTimers do - local t = pendingTimers[i] - if t.fireAt <= virtualTime then - tremove(pendingTimers, i) - if t.gen == stepGen then - pcall(t.fn) - -- t.fn may have queued more timers (or wiped the - -- queue via cancelInFlight); restart the scan to - -- handle index shifts safely. - i = 1 - end - else - i = i + 1 - end - end - end) - - local function safeAfter(delay, fn) - if not active then return end - tinsert(pendingTimers, { - fireAt = virtualTime + delay, - fn = fn, - gen = stepGen, - }) - end - - -- Restore the user's pre-demo filter state. - local function restoreUserSettings() - -- staticOpacity stays forced true while the demo is open; - -- it's restored in endDemo, not here, so loadDemo doesn't - -- re-enable movement fade between demo switches. - EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} - EasyFind.db.uiSearchFilters.map = savedUiMapFilter - EasyFind.db.uiMapSearchLocal = savedUiMapSearchLocal - -- Refresh the filter dropdown's visual state to match the - -- restored saved values (map checkbox + local/global sub). - local dd = searchFrame.filterDropdown - if dd and dd.checkRows and dd.checkRows.map then - local mr = dd.checkRows.map - if mr.SetChecked then mr:SetChecked(savedUiMapFilter ~= false) end - if mr.updateMapToggle then mr.updateMapToggle() end - end - end - - -- Transition banner shown at the end of Fast Mode, hidden when - -- Guide Mode begins. Anchored near the top-center of the screen - -- on the TOOLTIP strata so it floats above every menu and frame. - -- A fade-in/out animation group softens the transition. - local transitionFrame = ns.TutorialBox.Create(UIParent, "GameFontNormalLarge") - transitionFrame:SetPoint("TOP", UIParent, "TOP", 0, -160) - transitionFrame:SetSize(460, 80) - local transitionFS = transitionFrame.fs - transitionFS:SetText("Now let's take a look at what that process looks like in Guide Mode.") - - transitionFrame:Hide() - - -- Shim so existing Show/Hide callsites targeting transitionText - -- still work even though the actual visible object is the frame. - local transitionText = {} - function transitionText:Show() transitionFrame:Show() end - function transitionText:Hide() transitionFrame:Hide() end - - -- Minimap callout: tutorial-style hint box anchored to the - -- minimap, used to draw attention to minimap changes (e.g. - -- "your target is now tracked"). Box sits to the LEFT of the - -- minimap with its vertical center aligned to the minimap's - -- vertical center, with a 65 px gap on the right for the - -- chevron pointer's full visible sweep (see AttachPointer below). - local minimapCalloutFrame = ns.TutorialBox.Create(UIParent, "GameFontNormalLarge") - minimapCalloutFrame:SetSize(220, 60) - if _G["Minimap"] then - minimapCalloutFrame:SetPoint("RIGHT", _G["Minimap"], "LEFT", -65, 0) - else - minimapCalloutFrame:SetPoint("TOPRIGHT", UIParent, "TOPRIGHT", -240, -80) - end - local minimapCalloutFS = minimapCalloutFrame.fs - minimapCalloutFS:SetText("Note how your target is now tracked!") - minimapCalloutFrame:Hide() - - local minimapCallout = {} - function minimapCallout:SetText(text) - minimapCalloutFS:SetText(text or "") - -- Narrower cap than the map-search callout (which has ~300 px - -- of horizontal room to the right of the results frame). - -- Over the minimap area there's less room and the phrases - -- here are short, so 220 keeps the box compact without - -- triggering awkward 3+ line wraps. - minimapCalloutFrame:SetAutoSized(220) - end - function minimapCallout:Show() minimapCalloutFrame:Show() end - function minimapCallout:Hide() minimapCalloutFrame:Hide() end - - -- Floating narration anchored next to the local map search frame. - -- Used by mapSearchCurrent's "browse what's around" step. Has an - -- attached left-pointing arrow that is only shown via - -- :ShowWithArrow() for the hover-preview step; other callers use - -- plain :Show() and get the box without the arrow. - local mapSearchCallout = {} - mapSearchCallout.frame = ns.TutorialBox.Create(UIParent, "GameFontNormalLarge") - mapSearchCallout.frame:SetSize(280, 60) - mapSearchCallout.frame:Hide() - mapSearchCallout.fs = mapSearchCallout.frame.fs - -- Persistent left-pointing chevron. Travel = gap between the box's - -- left edge and the right edge of the results frame, so the apex - -- lands right on the results. Parented to the tutorial box, so it - -- inherits strata/visibility and hides automatically when the box - -- hides. Created once and toggled via the arrow driver's own - -- Show/Hide (preserving the animation's elapsed state). - -- Cadence copied from modePointer (UI.lua) — the canonical - -- tutorial-arrow recipe. Only `direction` and `travel` should - -- vary per caller; the rest stay as-is across every chevron. - mapSearchCallout.arrow = ns.TutorialBox.AttachPointer(mapSearchCallout.frame, { - direction = "left", - travel = 36, - duration = 1.25, - count = 2, - easing = 0.5, - fadeStart = 0.75, - startOffset = -10, - glow = 0.7, - }) - mapSearchCallout.arrow.frame:Hide() - function mapSearchCallout:SetText(text) - self.fs:SetText(text or "") - self.frame:SetAutoSized(340) - end - function mapSearchCallout:Show() - -- Anchor once per show-session to the results frame (so the - -- gap is right for current scrollbar / mapSearchWidth state), - -- then freeze the position by re-anchoring to UIParent with - -- the absolute coords. Within a session the results frame - -- resizes as the user types different prefixes; without the - -- freeze the callout would jump around with each resize. - -- Hide() resets anchorLocked so the next show recomputes. - if not self.anchorLocked then - local rf = _G["EasyFindMapResultsFrame"] - local lsf = _G["EasyFindMapSearchFrame"] - self.frame:ClearAllPoints() - if rf and rf:IsShown() then - -- 65 px leaves room for the chevron's full ~63 px - -- visible sweep (texSize 64 minus 2 * apexInset of - -- 18.58 = 26.84 visible shape, plus 36 travel) so - -- the apex lands just past the results edge at peak - -- travel without cutting into the result rows. - self.frame:SetPoint("TOPLEFT", rf, "TOPRIGHT", 65, -4) - elseif lsf then - -- Fallback for callsites that run before results open. - self.frame:SetPoint("LEFT", lsf, "RIGHT", 120, -40) - end - -- Show before reading GetLeft/GetTop so the layout pass - -- resolves the anchor to actual screen coords. - self.frame:Show() - local l, t = self.frame:GetLeft(), self.frame:GetTop() - if l and t then - self.frame:ClearAllPoints() - self.frame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", l, t) - self.anchorLocked = true - end - end - self.arrow.frame:Hide() - self.frame:Show() - end - function mapSearchCallout:ShowWithArrow() - self:Show() - self.arrow.frame:Show() - end - -- Cross-fade the old text up and out while the new text rises up - -- from below to take its place. Box size, anchor, and arrow all - -- stay put. Falls through to a plain Show when the callout isn't - -- already visible (no "old text" to scroll away). Same ease/timing - -- family as setScrollTarget's OnUpdate tween, just applied per - -- fontstring. - mapSearchCallout.textAnim = CreateFrame("Frame") - mapSearchCallout.textAnim:Hide() - function mapSearchCallout:SetTextScrolling(newText) - local isShown = self.frame:IsShown() - local oldText = self.fs:GetText() or "" - if not isShown or oldText == "" then - self:SetText(newText) - self:ShowWithArrow() - return - end - if oldText == newText then return end - -- Lazy-create the secondary fontstring the first time we swap. - -- Shares fs's current color/shadow/justify so the transition - -- reads as the same text just changing words. - if not self.fs2 then - local fs2 = self.frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") - fs2:SetJustifyH("CENTER") - fs2:SetJustifyV("MIDDLE") - fs2:SetTextColor(self.fs:GetTextColor()) - fs2:SetShadowColor(0, 0, 0, 1) - fs2:SetShadowOffset(1, -1) - self.fs2 = fs2 - end - -- fs2 carries the OLD text (scrolls up and out). fs takes the - -- NEW text and scrolls up from below into center. - self.fs2:SetWidth(self.fs:GetWidth()) - self.fs2:SetText(oldText) - self.fs2:ClearAllPoints() - self.fs2:SetPoint("CENTER", self.frame, "CENTER", 0, 0) - self.fs2:SetAlpha(1) - self.fs:SetText(newText) - self.fs:ClearAllPoints() - self.fs:SetPoint("CENTER", self.frame, "CENTER", 0, -20) - self.fs:SetAlpha(0) - local duration = 0.35 - local elapsed = 0 - self.textAnim:SetScript("OnUpdate", function(_, dt) - elapsed = elapsed + dt - local t = elapsed / duration - if t > 1 then t = 1 end - local eased = t * t * (3 - 2 * t) - self.fs2:ClearAllPoints() - self.fs2:SetPoint("CENTER", self.frame, "CENTER", 0, 20 * eased) - self.fs2:SetAlpha(1 - eased) - self.fs:ClearAllPoints() - self.fs:SetPoint("CENTER", self.frame, "CENTER", 0, -20 * (1 - eased)) - self.fs:SetAlpha(eased) - if t >= 1 then - self.textAnim:SetScript("OnUpdate", nil) - self.textAnim:Hide() - self.fs:ClearAllPoints() - self.fs:SetPoint("CENTER", self.frame, "CENTER", 0, 0) - self.fs:SetAlpha(1) - self.fs2:SetAlpha(0) - self.fs2:SetText("") - end - end) - self.textAnim:Show() - end - function mapSearchCallout:Hide() - -- Cancel any in-flight scroll so a later Show() starts fresh. - if self.textAnim then - self.textAnim:SetScript("OnUpdate", nil) - self.textAnim:Hide() - end - if self.moveAnim then - self.moveAnim:SetScript("OnUpdate", nil) - self.moveAnim:Hide() - end - if self.fs2 then - self.fs2:SetAlpha(0) - self.fs2:SetText("") - end - -- Drop the locked absolute anchor so the next Show() - -- re-computes against whatever state the results frame is - -- in at that point (scrollbar present/absent, different - -- mapSearchWidth, etc.). - self.anchorLocked = nil - self.fs:ClearAllPoints() - self.fs:SetPoint("CENTER", self.frame, "CENTER", 0, 0) - self.fs:SetAlpha(1) - self.arrow.frame:Hide() - self.frame:Hide() - end - - -- Emitter frames for the two clear-button chevrons. Each emitter - -- sits ~63 px to the LEFT of its clear button at show time; the - -- attached chevron emerges through its right border and its - -- phase-1 apex lands at the button's left edge. Emitters are 1 - -- px so they don't render anything themselves. Cadence copied - -- from modePointer (the canonical tutorial-arrow recipe). - local function makeClearChevron() - local emitter = CreateFrame("Frame", nil, UIParent) - emitter:SetSize(1, 1) - emitter:SetFrameStrata("TOOLTIP") - emitter:Hide() - local chev = ns.TutorialBox.AttachPointer(emitter, { - direction = "right", - travel = 36, - duration = 1.25, - count = 2, - easing = 0.5, - fadeStart = 0.75, - startOffset = -10, - glow = 0.7, - }) - chev.frame:Hide() - return { emitter = emitter, chev = chev } - end - local localClearPointer = makeClearChevron() - local globalClearPointer = makeClearChevron() - - -- Forward decl so endDemo / cancelInFlight / resetDemoGameState - -- can call minimapArrow:Hide() via upvalue. The methods are - -- attached later, once the arrow frame is built alongside the - -- map search demo helpers. - local minimapArrow = {} - function minimapArrow:Show() end - function minimapArrow:Hide() end - - -- Ticker used by the end-of-demo tracking-state narration to - -- poll EasyFindNearTrack:IsShown() until the player crosses - -- into the other mode. Held at this scope so endDemo / - -- cancelInFlight can cancel it if the demo is aborted. - local trackingStateTicker - - local function endDemo() - active = false - paused = false - if searchFrame.filterDropdown then - searchFrame.filterDropdown._demoSuspend = nil - end - wipe(pendingTimers) - tickFrame:SetScript("OnUpdate", nil) - cursor:SetScript("OnUpdate", nil) - cursor:Hide() - stopBlinkCursor() - setHoveredRow(nil) - clearButtonHover() - transitionText:Hide() - mapSearchCallout:Hide() - localClearPointer.chev.frame:Hide() - localClearPointer.emitter:Hide() - globalClearPointer.chev.frame:Hide() - globalClearPointer.emitter:Hide() - if trackingStateTicker then trackingStateTicker:Cancel(); trackingStateTicker = nil end - minimapCallout:Hide() - minimapArrow:Hide() - hideMapCaret() - if ns.MapSearch then ns.MapSearch._demoHoverLock = nil end - -- Wipe any demo-modified search bar state so the bar returns - -- to its clean default (placeholder visible, no stale text). - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - searchFrame.editBox:ClearFocus() - if searchFrame.editBox.placeholder then - searchFrame.editBox.placeholder:Show() - end - if searchFrame.filterDropdown then - if searchFrame.filterDropdown:IsShown() then - searchFrame.filterDropdown:Hide() - end - searchFrame.filterDropdown._demoSuspend = nil - end - if ns.Highlight and ns.Highlight.ClearAll then - pcall(ns.Highlight.ClearAll, ns.Highlight) - end - -- Clean up map search state if a map demo was running. - if resetMapSearchState then pcall(resetMapSearchState) end - -- Revert WMF mapID so the user's map returns to whatever - -- they had open before starting the demo, not wherever a - -- demo step navigated to (e.g. the instance entrance the - -- zone/instance demo jumps to). - restoreWmfMapID() - if closeWorldMap then pcall(closeWorldMap) end - -- Release every blocker, restore drag handler and editbox. - if updateLockState then updateLockState() end - demoFrame:Hide() - restoreUserSettings() - EasyFind.db.staticOpacity = savedStaticOpacity - if not InCombatLockdown() then - local psf = _G["PlayerSpellsFrame"] - if psf and psf.IsShown and psf:IsShown() then - pcall(HideUIPanel, psf) - end - end - FinishSetup() - end - - closeDemoBtn:SetScript("OnClick", endDemo) - - -- Escape closes the demo, but only if the editbox doesn't have - -- focus. If the editbox is focused, the first Esc unfocuses it - -- (WoW's default behavior); the second Esc closes the demo. - demoFrame:EnableKeyboard(true) - demoFrame:SetPropagateKeyboardInput(true) - demoFrame:SetScript("OnKeyDown", function(self, key) - if key == "ESCAPE" then - if searchFrame.editBox:HasFocus() then - searchFrame.editBox:ClearFocus() - self:SetPropagateKeyboardInput(false) - else - self:SetPropagateKeyboardInput(false) - endDemo() - end - else - self:SetPropagateKeyboardInput(true) - end - end) - - -- Compute a frame's center in UIParent's coordinate space, taking - -- effective scale into account so the cursor lines up with the - -- target regardless of where the target lives in the frame tree. - local function centerInUIParent(frame) - if not frame or not frame.GetCenter or not frame:IsShown() then return nil end - local cx, cy = frame:GetCenter() - if not cx then return nil end - local fs = frame:GetEffectiveScale() - local us = UIParent:GetEffectiveScale() - return cx * fs / us, cy * fs / us - end - - -- Simulate hover highlights on result rows as the cursor passes - -- over them. A single texture is re-anchored to the hovered row - -- each frame, bypassing the WoW highlight system (which won't - -- fire because click blockers eat real mouse events). - local rowHoverTex = resultsFrame.scrollChild:CreateTexture(nil, "OVERLAY") - rowHoverTex:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") - rowHoverTex:SetBlendMode("ADD") - rowHoverTex:SetVertexColor(1, 1, 1, 0.6) - rowHoverTex:Hide() - local hoveredResultRow - -- Separate hover overlay for the filter dropdown (its CheckButton - -- rows don't have a highlight texture set). - local dropdownHoverTex - local function getDropdownHoverTex() - if not dropdownHoverTex then - local dd = searchFrame.filterDropdown - if not dd then return nil end - dropdownHoverTex = dd:CreateTexture(nil, "OVERLAY") - dropdownHoverTex:SetColorTexture(1, 1, 1, 0.1) - dropdownHoverTex:Hide() - end - return dropdownHoverTex - end - - setHoveredRow = function(row) - if row == hoveredResultRow then return end - -- Fire OnLeave on the departing map result row so the map - -- preview clears at the exact same frame as the highlight. - local prev = hoveredResultRow - if prev then - if prev.UnlockHighlight then - prev:UnlockHighlight() - end - if prev._demoMapHover then - prev._demoMapHover = nil - if ns.MapSearch then ns.MapSearch._demoHoverLock = nil end - local onLeave = prev:GetScript("OnLeave") - if onLeave then pcall(onLeave, prev) end - end - end - hoveredResultRow = row - rowHoverTex:Hide() - local ddTex = getDropdownHoverTex() - if ddTex then ddTex:Hide() end - if row then - if row.GetHighlightTexture and row:GetHighlightTexture() then - row:LockHighlight() - elseif row._isDropdownRow then - if ddTex then - ddTex:ClearAllPoints() - ddTex:SetAllPoints(row) - ddTex:Show() - end - else - rowHoverTex:ClearAllPoints() - rowHoverTex:SetAllPoints(row) - rowHoverTex:Show() - end - -- Fire OnEnter on map result rows so the map preview - -- appears at the exact same frame as the highlight. - if row._demoMapHover then - if ns.MapSearch then ns.MapSearch._demoHoverLock = true end - local onEnter = row:GetScript("OnEnter") - if onEnter then pcall(onEnter, row) end - end - end - end - - -- Check if cursor is over any result row (UI search OR map search). - local function updateResultRowHover() - if not cursor:IsShown() then - setHoveredRow(nil) - return - end - local cl = cursor:GetLeft() - local ct = cursor:GetTop() - if not cl then setHoveredRow(nil); return end - local cx, cy = cl + 4, ct - 4 - -- Pin popup - local pinPop = _G["EasyFindPinPopup"] - if pinPop and pinPop:IsShown() then - local rl, rr, rt, rb = pinPop:GetLeft(), pinPop:GetRight(), pinPop:GetTop(), pinPop:GetBottom() - if rl and cx >= rl and cx <= rr and cy <= rt and cy >= rb then - setHoveredRow(pinPop) - return - end - end - -- UI search results - if resultsFrame:IsShown() then - for i = 1, #resultButtons do - local row = resultButtons[i] - if row and row:IsShown() then - local rl, rr, rt, rb = row:GetLeft(), row:GetRight(), row:GetTop(), row:GetBottom() - if rl and cx >= rl and cx <= rr and cy <= rt and cy >= rb then - setHoveredRow(row) - return - end - end - end - end - -- Filter dropdown rows (dropdown has custom scaling, so - -- convert the cursor tip to the dropdown's coordinate space) - local dd = searchFrame.filterDropdown - if dd and dd:IsShown() and dd.checkRows then - local ds = dd:GetEffectiveScale() - local cs = cursor:GetEffectiveScale() - local dcx = cx * cs / ds - local dcy = cy * cs / ds - for _, row in pairs(dd.checkRows) do - if row and row:IsShown() then - local rl, rr, rt, rb = row:GetLeft(), row:GetRight(), row:GetTop(), row:GetBottom() - if rl and dcx >= rl and dcx <= rr and dcy <= rt and dcy >= rb then - row._isDropdownRow = true - setHoveredRow(row) - return - end - end - end - end - -- Map search results - local mapResults = _G["EasyFindMapResultsFrame"] - if mapResults and mapResults:IsShown() then - for i = 1, 20 do - local row = _G["EasyFindMapResultButton" .. i] - if row and row:IsShown() and row.data then - local rl, rr, rt, rb = row:GetLeft(), row:GetRight(), row:GetTop(), row:GetBottom() - if rl and cx >= rl and cx <= rr and cy <= rt and cy >= rb then - setHoveredRow(row) - return - end - end - end - end - setHoveredRow(nil) - end - - local function placeCursorAt(x, y) - cursor:ClearAllPoints() - cursor:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", x - 4, y + 4) - updateResultRowHover() - end - - clearButtonHover = function() - GameTooltip_Hide() - end - - local function moveCursorTo(targetFrame, duration, onArrive, offsetX, offsetY) - -- Previous target's hover state (tooltip, button outline, - -- highlight) is cleared below once the cursor has visibly - -- moved off it, not at the start. Clearing at the start - -- would make the tooltip dismiss while the cursor is still - -- sitting on the button it's about to leave. - local isTutorialBox = targetFrame and targetFrame._isTutorialBox - local tx, ty = centerInUIParent(targetFrame) - if not tx then - if onArrive then onArrive() end - return - end - -- Default landing placements (skipped if caller passed explicit - -- offsets). Two cases: - -- * Tutorial boxes: tip at the bottom-border center, sprite - -- extends below the frame so it never covers the text. - -- * Small icon buttons (<=50x50 Button): tip in the lower- - -- right quadrant so the icon below stays visible. - if not offsetX and not offsetY then - if isTutorialBox then - local bottom = targetFrame:GetBottom() - if bottom then - ty = bottom - 4 - end - elseif targetFrame.IsObjectType and targetFrame:IsObjectType("Button") then - local w = targetFrame:GetWidth() or 0 - local h = targetFrame:GetHeight() or 0 - if w > 0 and h > 0 and w <= 50 and h <= 50 then - offsetX = w * 0.3 - offsetY = -h * 0.3 - end - end - end - if offsetX then tx = tx + offsetX end - if offsetY then ty = ty + offsetY end - -- If the cursor isn't visible yet, start it at the middle - -- right of the screen so the first move comes in from a - -- natural resting position. - if not cursor:IsShown() then - local sx = UIParent:GetWidth() * 0.72 - local sy = UIParent:GetHeight() * 0.5 - placeCursorAt(sx, sy) - cursor:Show() - end - local sx = cursor:GetLeft() + 4 - local sy = cursor:GetTop() - 4 - local elapsed = 0 - local myGen = stepGen - local hoverCleared = false - cursor:SetScript("OnUpdate", function(self, dt) - if not active or myGen ~= stepGen then - self:SetScript("OnUpdate", nil) - return - end - if paused then return end - elapsed = elapsed + dt * playbackSpeed - local t = elapsed / duration - if t >= 1 then t = 1 end - local eased = t * t * (3 - 2 * t) -- smoothstep - local nx = sx + (tx - sx) * eased - local ny = sy + (ty - sy) * eased - -- Release the previous target's tooltip/highlight once - -- the cursor has traveled ~20 px from the start — far - -- enough to be clearly off a small icon button. This - -- lets the tooltip linger while the cursor is still - -- visibly on the button it just left. - if not hoverCleared then - local dx, dy = nx - sx, ny - sy - if dx * dx + dy * dy >= 400 then - hoverCleared = true - clearButtonHover() - end - end - placeCursorAt(nx, ny) - if t >= 1 then - self:SetScript("OnUpdate", nil) - -- For a tutorial box, re-anchor the cursor's TOPLEFT - -- to the frame's BOTTOM so it follows if the box - -- resizes later (e.g. SetText grows the height for - -- a longer message). The -4/0 offset puts the tip - -- at bottom-center with no overlap into the frame. - if isTutorialBox and not offsetX and not offsetY then - cursor:ClearAllPoints() - cursor:SetPoint("TOPLEFT", targetFrame, "BOTTOM", -4, 0) - end - if onArrive then pcall(onArrive) end - end - end) - end - - local function clickAnim(onComplete) - -- Quick scale pulse: shrink ~30% then back. ~0.18s total. - local startSize = cursor:GetWidth() or 36 - local minSize = startSize * 0.7 - local elapsed = 0 - local myGen = stepGen - cursor:SetScript("OnUpdate", function(self, dt) - if not active or myGen ~= stepGen then - self:SetScript("OnUpdate", nil) - self:SetSize(36, 36) - return - end - if paused then return end - elapsed = elapsed + dt * playbackSpeed - local total = 0.18 - local half = total / 2 - local sz - if elapsed < half then - sz = startSize + (minSize - startSize) * (elapsed / half) - else - sz = minSize + (startSize - minSize) * ((elapsed - half) / half) - end - self:SetSize(sz, sz) - if elapsed >= total then - self:SetSize(startSize, startSize) - self:SetScript("OnUpdate", nil) - if onComplete then - local ok, err = pcall(onComplete) - if not ok and err then - local handler = geterrorhandler() - if handler then handler(err) end - end - end - end - end) - end - - -- Fake blinking cursor: mimics the "just focused the editbox" - -- visual (placeholder hidden + blinking vertical bar) without - -- actually giving the real editbox keyboard focus. - local blinkCursor = searchFrame.editBox:CreateTexture(nil, "OVERLAY") - blinkCursor:SetTexture("Interface\\Buttons\\WHITE8x8") - blinkCursor:SetVertexColor(1, 1, 1, 1) - blinkCursor:SetWidth(1) - blinkCursor:SetPoint("LEFT", searchFrame.editBox, "LEFT", 2, 0) - blinkCursor:SetPoint("TOP", searchFrame.editBox, "TOP", 0, -3) - blinkCursor:SetPoint("BOTTOM", searchFrame.editBox, "BOTTOM", 0, 3) - blinkCursor:Hide() - local blinkTicker - local function startBlinkCursor() - searchFrame.editBox.placeholder:Hide() - blinkCursor:Show() - blinkCursor:SetAlpha(1) - local visible = true - if blinkTicker then blinkTicker:Cancel() end - blinkTicker = C_Timer.NewTicker(0.5, function() - visible = not visible - blinkCursor:SetAlpha(visible and 1 or 0) - end) - end - -- Assign (don't 'local') so the forward-declared stopBlinkCursor - -- captured by endDemo() resolves to this function. - stopBlinkCursor = function() - if blinkTicker then blinkTicker:Cancel(); blinkTicker = nil end - blinkCursor:Hide() - end - - local function typeText(text, charDelay, onComplete) - -- Set text programmatically via SetText (no SetFocus call) so - -- the editbox visually shows the typed characters and the - -- search runs via OnTextChanged, but the player can't actually - -- type into it mid-demo. - stopBlinkCursor() -- typed text replaces the blink cursor - local editBox = searchFrame.editBox - editBox:SetText("") - local i = 0 - local myGen = stepGen - local function nextChar() - if not active or myGen ~= stepGen then return end - i = i + 1 - if i > #text then - if onComplete then onComplete() end - return - end - editBox:SetText(text:sub(1, i)) - UI:OnSearchTextChanged(editBox:GetText()) - safeAfter(charDelay, nextChar) - end - nextChar() - end - - -- Find a specific result row by entry name (e.g. "Spellbook") so - -- the demo clicks the right one instead of whatever fuzzy search - -- put at the top. - local function findResultRowByName(name) - for i = 1, #resultButtons do - local row = resultButtons[i] - if row and row:IsShown() and row.data and row.data.name == name then - return row - end - end - return nil - end - - local function findFirstResultRow() - for i = 1, #resultButtons do - local row = resultButtons[i] - if row and row:IsShown() and row.data - and not row.data.isPinHeader and not row.data.isHeader then - return row - end - end - return nil - end - - -- Find the first visible result that's a map-search result - -- (mapSearchResult = true). Used by the Map-search-through-UI-bar - -- demo where the target is a flight master POI whose name is - -- " (Flight Master)" so findResultRowByName can't match - -- exactly. - local function findFirstMapSearchResult() - for i = 1, #resultButtons do - local row = resultButtons[i] - if row and row:IsShown() and row.data and row.data.mapSearchResult - and not row.data.isPinHeader and not row.data.isHeader then - return row - end - end - return nil - end - - -------------------------------------------------------------------- - -- Step array: each step is a function(done) that runs its - -- animation and calls done() when finished. The state machine - -- below chains them either automatically (Play All) or waits for - -- the player to click Next Step between each one. - -------------------------------------------------------------------- - local CURSOR_MOVE = 0.8 -- cursor travel time per move - local TYPE_DELAY = 0.3 -- per character (human-paced) - local STEP_PAUSE = 0.2 -- tiny pause at the end of "filler" steps - local SETTLE_PAUSE = 0.8 -- post-click "see what happened" pause for action steps - -- Gaps framing every autoplay step. - -- PRE_ACT_GAP: after a step highlights (and scrolls into - -- view), wait this long before firing the action. This is - -- the "reading time" so the user can absorb the step label - -- before the cursor starts moving. - -- POST_ACT_GAP: after a step's action finishes, keep the - -- step highlighted this long so the result sinks in - -- before the highlight advances. - -- Section-boundary header beats also use POST_ACT_GAP. - local PRE_ACT_GAP = 0.7 - local POST_ACT_GAP = 0.4 - - -- Reset PlayerSpellsFrame to its default tab (Specialization) so - -- the demo always starts from a clean state. - local function resetPlayerSpellsFrame() - local frame = _G["PlayerSpellsFrame"] - if not frame then return end - if frame.TabSystem and frame.TabSystem.tabs and frame.TabSystem.tabs[1] then - local tab = frame.TabSystem.tabs[1] - if tab.Click then pcall(tab.Click, tab) end - else - local fallback = _G["PlayerSpellsFrameTab1"] - if fallback and fallback.Click then pcall(fallback.Click, fallback) end - end - if not InCombatLockdown() and frame.IsShown and frame:IsShown() then - pcall(HideUIPanel, frame) - end - end - - -- Focuses the UI search bar with cursor animation + blink - -- cursor, then types the query. Owns the full "search for X" - -- action so there's no standalone focus step. - local function uis_stepFocusAndType(query, done) - moveCursorTo(searchFrame.editBox, CURSOR_MOVE, function() - clickAnim(function() - startBlinkCursor() - safeAfter(STEP_PAUSE, function() - typeText(query, TYPE_DELAY, function() - safeAfter(STEP_PAUSE, done) - end) - end) - end) - end) - end - - DEMOS.uiSearch.run = { - -- 1: Start typing "sp" (cursor flies to the bar, focuses - -- it, then types — all in this single step). - function(done) - uis_stepFocusAndType("sp", done) - end, - -- 2: Click the Spellbook result, let PlayerSpellsFrame - -- open, then clean up. - function(done) - local target = findResultRowByName("Spellbook") or findFirstResultRow() or searchFrame.editBox - moveCursorTo(target, CURSOR_MOVE, function() - clickAnim(function() - UI:SelectResult(spellbookEntry) - safeAfter(1.5, function() - local psf = _G["PlayerSpellsFrame"] - local closeBtn = psf and (psf.ClosePanelButton or psf.CloseButton) - local function cleanup() - setHoveredRow(nil) - stopBlinkCursor() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if ns.Highlight and ns.Highlight.ClearAll then - pcall(ns.Highlight.ClearAll, ns.Highlight) - end - resetPlayerSpellsFrame() - safeAfter(0.5, function() - cursor:Hide() - done() - end) - end - if closeBtn and closeBtn:IsShown() then - moveCursorTo(closeBtn, CURSOR_MOVE, function() - clickAnim(cleanup) - end) - else - cleanup() - end - end) - end) - end) - end, - } - demoSteps = DEMOS.uiSearch.run - - -------------------------------------------------------------------- - -- Guide Mode demo: right-click a result, pick Guide from the - -- popup, and let the step-by-step highlight guide play. - -------------------------------------------------------------------- - local function guide_cleanup(done) - setHoveredRow(nil) - stopBlinkCursor() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if searchFrame.editBox.placeholder then - searchFrame.editBox.placeholder:Show() - end - if ns.Highlight and ns.Highlight.ClearAll then - pcall(ns.Highlight.ClearAll, ns.Highlight) - end - if not InCombatLockdown() then - local cf = _G["CharacterFrame"] - if cf and cf.IsShown and cf:IsShown() then - pcall(HideUIPanel, cf) - end - local tf = _G["TokenFrame"] - if tf and tf.IsShown and tf:IsShown() then - pcall(HideUIPanel, tf) - end - end - safeAfter(0.4, function() - cursor:Hide() - rightClickIcon:Hide() - done() - end) - end - - DEMOS.guide.run = { - -- 1: Type "Valorstones" - function(done) - uis_stepFocusAndType("Valorstones", done) - end, - -- 2: Move to the Valorstones result row and right-click, - -- which triggers ShowPinPopup with the Guide row. - function(done) - local target = findResultRowByName("Valorstones") or findFirstResultRow() - if not target then - guide_cleanup(done) - return - end - moveCursorTo(target, CURSOR_MOVE, function() - rightClickIcon:Show() - safeAfter(0.5, function() - clickAnim(function() - rightClickIcon:Hide() - local onClick = target:GetScript("OnClick") - if onClick then - pcall(onClick, target, "RightButton", true) - end - safeAfter(SETTLE_PAUSE, done) - end) - end) - end) - end, - -- 3: Click the Guide row in the pin popup. SelectResult - -- with forceGuide=true routes a currency entry through - -- EasyFind:StartGuide. - function(done) - local popup = _G["EasyFindPinPopup"] - local guideRow = popup and popup.guideRow - if not (popup and popup:IsShown() and guideRow and guideRow:IsShown()) then - local target = findResultRowByName("Valorstones") or findFirstResultRow() - if target and target.data then - UI:SelectResult(target.data, true) - end - safeAfter(SETTLE_PAUSE, done) - return - end - moveCursorTo(guideRow, CURSOR_MOVE, function() - clickAnim(function() - local onClick = guideRow:GetScript("OnClick") - if onClick then - pcall(onClick, guideRow) - end - safeAfter(SETTLE_PAUSE, done) - end) - end) - end, - -- 4: Let the highlight ticker run so the user sees the - -- step-by-step guide, then clean up. - function(done) - safeAfter(4.0, function() - guide_cleanup(done) - end) - end, - } - DEMOS.guide.setupAfter = { - function() end, - function() end, - function() end, - function() - if ns.Highlight and ns.Highlight.ClearAll then - pcall(ns.Highlight.ClearAll, ns.Highlight) - end - end, - } - - -------------------------------------------------------------------- - -- Zone/Instance Map Search demo - -------------------------------------------------------------------- - - -- Blinking text caret for map search editboxes. A hidden - -- FontString mirrors the editbox font to measure text width. - local mapCaret = CreateFrame("Frame", nil, UIParent) - mapCaret:SetFrameStrata("TOOLTIP") - mapCaret:SetFrameLevel(10002) - mapCaret:SetSize(1, 1) - mapCaret:Hide() - local mapCaretTex = mapCaret:CreateTexture(nil, "OVERLAY") - mapCaretTex:SetTexture("Interface\\Buttons\\WHITE8x8") - mapCaretTex:SetVertexColor(1, 1, 1, 1) - mapCaretTex:SetWidth(1) - mapCaretTex:SetAllPoints() - local mapCaretMeasure = mapCaret:CreateFontString(nil, "ARTWORK", "GameFontNormal") - mapCaretMeasure:Hide() - local mapCaretTicker - local mapCaretLastEditBox - - local function positionMapCaret(editBox) - mapCaret:ClearAllPoints() - local text = editBox:GetText() or "" - local inL = editBox:GetTextInsets() - local xOff = inL or 2 - if #text > 0 then - if editBox ~= mapCaretLastEditBox then - local ok, fontPath, fontSize, fontFlags = pcall(editBox.GetFont, editBox) - if ok and fontPath and fontSize and fontSize > 0 then - pcall(mapCaretMeasure.SetFont, mapCaretMeasure, fontPath, fontSize, fontFlags) - end - mapCaretLastEditBox = editBox - end - mapCaretMeasure:SetText(text) - xOff = xOff + (mapCaretMeasure:GetStringWidth() or 0) - end - mapCaret:SetPoint("LEFT", editBox, "LEFT", xOff, 0) - mapCaret:SetPoint("TOP", editBox, "TOP", 0, -2) - mapCaret:SetPoint("BOTTOM", editBox, "BOTTOM", 0, 2) - end - - local function showMapCaret(editBox) - positionMapCaret(editBox) - mapCaret:Show() - mapCaretTex:SetAlpha(1) - if mapCaretTicker then mapCaretTicker:Cancel() end - local vis = true - mapCaretTicker = C_Timer.NewTicker(0.5, function() - vis = not vis - mapCaretTex:SetAlpha(vis and 1 or 0) - end) - end - hideMapCaret = function() - if mapCaretTicker then mapCaretTicker:Cancel(); mapCaretTicker = nil end - mapCaret:Hide() - end - local function repositionMapCaret(editBox) - positionMapCaret(editBox) - mapCaretTex:SetAlpha(1) - end - - -- Simulate refocusing then backspacing the current editbox text - -- one character at a time. Shows a blinking caret at the end of - -- the text for a beat, then deletes character by character. - local function backspaceMapText(editBox, charDelay, onComplete) - local gsf = _G["EasyFindMapGlobalSearchFrame"] - local isGlobal = gsf and gsf.editBox == editBox - local runFn - if isGlobal and ns.MapSearch and ns.MapSearch.RunGlobalSearch then - runFn = function(t) ns.MapSearch:RunGlobalSearch(t) end - elseif ns.MapSearch and ns.MapSearch.RunLocalSearch then - runFn = function(t) ns.MapSearch:RunLocalSearch(t) end - else - runFn = function(t) editBox:SetText(t) end - end - local cur = editBox:GetText() or "" - local len = #cur - if len == 0 then - if onComplete then onComplete() end - return - end - showMapCaret(editBox) - safeAfter(0.6, function() - local myGen = stepGen - local function nextDel() - if not active or myGen ~= stepGen then return end - len = len - 1 - if len <= 0 then - runFn("") - repositionMapCaret(editBox) - safeAfter(0.4, function() - hideMapCaret() - if onComplete then onComplete() end - end) - return - end - runFn(cur:sub(1, len)) - repositionMapCaret(editBox) - safeAfter(charDelay, nextDel) - end - nextDel() - end) - end - - -- Variant that leaves the caret visible after the last deletion - -- so the caret persists across backspace→type transitions (matching - -- real editbox behavior where focus isn't lost between edits). - local function backspaceMapTextKeepCaret(editBox, charDelay, onComplete) - local cur = editBox:GetText() or "" - if #cur == 0 then - showMapCaret(editBox) - if onComplete then onComplete() end - return - end - local gsf = _G["EasyFindMapGlobalSearchFrame"] - local isGlobal = gsf and gsf.editBox == editBox - local runFn - if isGlobal and ns.MapSearch and ns.MapSearch.RunGlobalSearch then - runFn = function(t) ns.MapSearch:RunGlobalSearch(t) end - elseif ns.MapSearch and ns.MapSearch.RunLocalSearch then - runFn = function(t) ns.MapSearch:RunLocalSearch(t) end - else - runFn = function(t) editBox:SetText(t) end - end - local len = #cur - showMapCaret(editBox) - safeAfter(0.6, function() - local myGen = stepGen - local function nextDel() - if not active or myGen ~= stepGen then return end - len = len - 1 - if len <= 0 then - runFn("") - repositionMapCaret(editBox) - safeAfter(0.4, function() - if onComplete then onComplete() end - end) - return - end - runFn(cur:sub(1, len)) - repositionMapCaret(editBox) - safeAfter(charDelay, nextDel) - end - nextDel() - end) - end - - local function typeMapText(editBox, text, charDelay, onComplete) - -- NEVER focus the real editbox: if it had keyboard focus, - -- the real user could type into it alongside the demo. - -- Instead, drive the MapSearch engine directly so results - -- appear and update as if typed. Detect which bar the - -- caller passed so we route to the right scope. - editBox:ClearFocus() - if editBox.placeholder then editBox.placeholder:Hide() end - local gsf = _G["EasyFindMapGlobalSearchFrame"] - local isGlobal = gsf and gsf.editBox == editBox - local runFn - if isGlobal and ns.MapSearch and ns.MapSearch.RunGlobalSearch then - runFn = function(t) ns.MapSearch:RunGlobalSearch(t) end - elseif ns.MapSearch and ns.MapSearch.RunLocalSearch then - runFn = function(t) ns.MapSearch:RunLocalSearch(t) end - else - runFn = function(t) editBox:SetText(t) end - end - runFn("") - showMapCaret(editBox) - local i = 0 - local myGen = stepGen - local function nextChar() - if not active or myGen ~= stepGen then return end - i = i + 1 - if i > #text then - hideMapCaret() - if onComplete then onComplete() end - return - end - runFn(text:sub(1, i)) - repositionMapCaret(editBox) - safeAfter(charDelay, nextChar) - end - nextChar() - end - - -- Variant that re-shows the caret after typing finishes, matching - -- real editbox behavior where the caret persists until the user - -- clicks away. Used in browse flows where backspace→type cycles - -- repeat without defocusing. - local function typeMapTextKeepCaret(editBox, text, charDelay, onComplete) - typeMapText(editBox, text, charDelay, function() - showMapCaret(editBox) - if onComplete then onComplete() end - end) - end - - -- Find a map search result button by partial name match. - local function findMapResultByName(pattern) - for i = 1, 20 do - local btn = _G["EasyFindMapResultButton" .. i] - if btn and btn:IsShown() and btn.data and btn.data.name then - if sfind(slower(btn.data.name), slower(pattern)) then - return btn - end - end - end - return nil - end - - -- Find the first visible map result (skipping any header rows). - local function findFirstVisibleMapResult() - for i = 1, 20 do - local btn = _G["EasyFindMapResultButton" .. i] - if btn and btn:IsShown() and btn.data and btn.data.name - and not btn.data.isHeader then - return btn - end - end - return nil - end - - -- Open the world map, loading the addon if needed. - local function openWorldMap() - if C_AddOns and C_AddOns.LoadAddOn then - pcall(C_AddOns.LoadAddOn, "Blizzard_WorldMap") - end - -- Lazy-capture the pre-demo WMF mapID: if WMF wasn't loaded - -- at Demo.Start, this is the first moment it exists with a - -- valid mapID, and we need that value to restore on demo - -- switch / panel close. Capture BEFORE ToggleWorldMap since - -- opening the map may trigger a SetMapID of its own. - if savedWmfMapID == nil and WorldMapFrame and WorldMapFrame.GetMapID then - savedWmfMapID = WorldMapFrame:GetMapID() - end - if not WorldMapFrame or not WorldMapFrame:IsShown() then - pcall(ToggleWorldMap) - end - end - - closeWorldMap = function() - if not InCombatLockdown() and WorldMapFrame and WorldMapFrame:IsShown() then - pcall(HideUIPanel, WorldMapFrame) - end - end - - local function getMapCloseBtn() - local wmf = WorldMapFrame - if not wmf then return nil end - return wmf.CloseButton - or (wmf.BorderFrame and wmf.BorderFrame.CloseButton) - or nil - end - - -- Helper: clean up map search state (clear editboxes, focus, - -- results, pins, zone highlight, activePinState, waypoint). - -- Both local and global search bars so nothing bleeds across - -- demos or rewinds. Uses MapSearch:ClearAll which nulls out - -- activePinState (otherwise WorldMapFrame's OnShow hook would - -- restore a stale pin the next time the map opens). - resetMapSearchState = function() - hideMapCaret() - local lsf = _G["EasyFindMapSearchFrame"] - if lsf and lsf.editBox then - lsf.editBox:SetText("") - lsf.editBox:ClearFocus() - if lsf.editBox.placeholder then lsf.editBox.placeholder:Show() end - end - local gsf = _G["EasyFindMapGlobalSearchFrame"] - if gsf and gsf.editBox then - gsf.editBox:SetText("") - gsf.editBox:ClearFocus() - if gsf.editBox.placeholder then gsf.editBox.placeholder:Show() end - end - if ns.MapSearch then - if ns.MapSearch.HideResults then pcall(ns.MapSearch.HideResults, ns.MapSearch) end - if ns.MapSearch.ClearAll then pcall(ns.MapSearch.ClearAll, ns.MapSearch) end - if ns.MapSearch.ClearZoneHighlight then pcall(ns.MapSearch.ClearZoneHighlight, ns.MapSearch) end - end - end - - -- Hover over the waypoint pin to show "found it". When the - -- cursor arrives, call ClearHighlight (same as real OnEnter for - -- global search pins) which hides the pin, glow, and indicator - -- arrow — exactly matching what happens when a real user hovers. - local function hoverWaypointPin(afterHover) - local pin = _G["EasyFindLocationPin"] - if pin and pin:IsShown() then - moveCursorTo(pin, CURSOR_MOVE, function() - if ns.MapSearch and ns.MapSearch.ClearHighlight then - pcall(ns.MapSearch.ClearHighlight, ns.MapSearch) - end - safeAfter(SETTLE_PAUSE + 1.0, afterHover) - end) - else - safeAfter(SETTLE_PAUSE, afterHover) - end - end - - -- Click-animates the cursor onto the global map search bar, - -- then types the query via typeMapText. Owns the full "start - -- typing" action so there's no standalone focus step. - -- All Zone/Instance map search step helpers live on a single - -- table so they only consume one local in this huge function. - local msz = {} - - function msz.stepFocusAndType(query, done) - local gsf = _G["EasyFindMapGlobalSearchFrame"] - local editBox = gsf and gsf.editBox - if not editBox then done(); return end - moveCursorTo(editBox, CURSOR_MOVE, function() - clickAnim(function() - safeAfter(STEP_PAUSE, function() - typeMapText(editBox, query, TYPE_DELAY, function() - safeAfter(SETTLE_PAUSE, done) - end) - end) - end) - end) - end - - function msz.openMap(done) - if InCombatLockdown() then done(); return end - openWorldMap() - safeAfter(0.8, done) - end - - function msz.clickNexus(done) - local target = findMapResultByName("The Nexus") - or _G["EasyFindMapResultButton1"] - if not target or not target:IsShown() then done(); return end - moveCursorTo(target, CURSOR_MOVE, function() - hideMapCaret() - clickAnim(function() - if target.data and ns.MapSearch then - pcall(ns.MapSearch.SelectResult, ns.MapSearch, target.data) - end - safeAfter(SETTLE_PAUSE, done) - end) - end) - end - - function msz.finish(done) - hoverWaypointPin(function() - local closeBtn = getMapCloseBtn() - if closeBtn and closeBtn:IsShown() then - moveCursorTo(closeBtn, CURSOR_MOVE, function() - clickAnim(function() - resetMapSearchState() - closeWorldMap() - safeAfter(0.5, function() - cursor:Hide() - done() - end) - end) - end) - else - resetMapSearchState() - closeWorldMap() - safeAfter(0.5, function() - cursor:Hide() - done() - end) - end - end) - end - - DEMOS.mapSearchZone.rebuild = function(def) - def.stepDefs = { - { text = "Open the world map", section = 1 }, - { text = 'Start typing "The Nexus"', section = 1 }, - { text = "Click The Nexus result", section = 1 }, - { text = "Hover over to clear highlight", section = 1 }, - } - def.sections = { - { header = "", section = 1, firstStep = 1, lastStep = 4 }, - } - def.run = { - msz.openMap, - function(done) msz.stepFocusAndType("nex", done) end, - msz.clickNexus, - msz.finish, - } - def.setupAfter = { - function() openWorldMap() end, - function() - openWorldMap() - if ns.MapSearch and ns.MapSearch.RunGlobalSearch then - pcall(ns.MapSearch.RunGlobalSearch, ns.MapSearch, "nex") - end - end, - function() openWorldMap() end, - function() resetMapSearchState(); closeWorldMap() end, - } - end - - -------------------------------------------------------------------- - -- Current Zone Map Search demo - -------------------------------------------------------------------- - - -- Open the map and set it to the player's current zone. If the - -- player's zone is unsuitable (cosmic map, dungeon, raid, nil), - -- returns false so the caller can bail out gracefully. - local function openWorldMapToPlayerZone() - openWorldMap() - local getBest = _G["GetBestMapForUnit"] - local playerMapID = getBest and getBest("player") - if not playerMapID then return false end - if WorldMapFrame then - pcall(WorldMapFrame.SetMapID, WorldMapFrame, playerMapID) - end - return true - end - - -- Click the waypoint pin to place a SuperTrack user waypoint. - -- This triggers the real minimap glow / guide-circle behavior. - -- Uses MapSearch helpers that resolve the pin location whether - -- it was rendered via the overlay (ShowWaypointAt) or by scaling - -- up a native Blizzard pin (HighlightPin). - local function clickPinAndTrack(afterTrack) - local targetFrame = ns.MapSearch and ns.MapSearch.GetActivePinFrame - and ns.MapSearch:GetActivePinFrame() - if not targetFrame then - safeAfter(SETTLE_PAUSE, afterTrack) - return - end - moveCursorTo(targetFrame, CURSOR_MOVE, function() - clickAnim(function() - if ns.MapSearch and ns.MapSearch.TrackActiveLocation then - pcall(ns.MapSearch.TrackActiveLocation, ns.MapSearch) - end - -- Short settle so the glow renders before the - -- caller's minimap-hint cursor walk kicks in. - safeAfter(SETTLE_PAUSE, afterTrack) - end) - end) - end - - -- Clear any waypoint the demo placed, so it doesn't linger. - local function clearDemoWaypoint() - if C_Map and C_Map.ClearUserWaypoint then - pcall(C_Map.ClearUserWaypoint) - end - if C_SuperTrack and C_SuperTrack.SetSuperTrackedUserWaypoint then - pcall(C_SuperTrack.SetSuperTrackedUserWaypoint, false) - end - end - - -- Tutorial chevron pointer attached to the minimap callout box, - -- animating rightward into the minimap. Gap between the box's - -- right edge and the minimap's left edge (set on the callout's - -- SetPoint above) is sized so the chevron's full visible sweep - -- (~27 px shape + 36 px travel) lands the apex just shy of the - -- minimap edge at peak travel, without intruding. - -- Cadence copied from modePointer (canonical recipe). - local minimapChevron = ns.TutorialBox.AttachPointer(minimapCalloutFrame, { - direction = "right", - travel = 36, - duration = 1.25, - count = 2, - easing = 0.5, - fadeStart = 0.75, - startOffset = -10, - glow = 0.7, - }) - minimapChevron.frame:Hide() - - -- Attach real methods to the forward-declared minimapArrow table - -- so upvalues in endDemo / cancelInFlight / resetDemoGameState - -- see the live implementation. The old bouncing indicator - -- texture was replaced with chevrons attached to the callout - -- box, so Show/Hide now just toggle the chevron driver frame's - -- visibility (OnUpdate pauses automatically while hidden). - function minimapArrow:Show() - minimapChevron.frame:Show() - end - function minimapArrow:Hide() - minimapChevron.frame:Hide() - end - function minimapArrow:SetPaused(_) - -- No-op: chevron OnUpdate is bound to parent visibility, and - -- the callout itself stays shown during a pause, so the - -- chevron keeps cycling. Acceptable tradeoff for how rarely - -- users pause mid-callout. - end - - -- Show the minimap callout text and a bouncing arrow pointing - -- at the minimap. No cursor movement: the arrow does the - -- pointing so the user knows where to look. - local function showMinimapHint(onDone) - minimapCallout:Show() - minimapArrow:Show() - safeAfter(3.0, function() - if onDone then onDone() end - end) - end - - ---------------------------------------------------------------- - -- Current Zone Map Search: browse POIs around the player. - -- All helpers live on a single table so this huge parent - -- function only consumes one local for the whole bundle. - ---------------------------------------------------------------- - local msc = {} - - function msc.openMap(done) - if InCombatLockdown() then done(); return end - local ok = openWorldMapToPlayerZone() - if not ok then - safeAfter(0.5, done) - return - end - safeAfter(0.8, done) - end - - function msc.retypeLastBrowse(done) - local lsf = _G["EasyFindMapSearchFrame"] - local editBox = lsf and lsf.editBox - if not editBox then done(); return end - local lastPOI = msc.lastBrowsePOI - local query = lastPOI and slower(lastPOI.name):sub(1, 3) or "ban" - moveCursorTo(editBox, CURSOR_MOVE, function() - clickAnim(function() - backspaceMapTextKeepCaret(editBox, TYPE_DELAY, function() - typeMapTextKeepCaret(editBox, query, TYPE_DELAY, function() - safeAfter(SETTLE_PAUSE, done) - end) - end) - end) - end) - end - - local function closeMapAndFinish(done) - local closeBtn = getMapCloseBtn() - if closeBtn and closeBtn:IsShown() then - moveCursorTo(closeBtn, CURSOR_MOVE, function() - clickAnim(function() - clearDemoWaypoint() - resetMapSearchState() - closeWorldMap() - safeAfter(0.5, function() - cursor:Hide() - done() - end) - end) - end) - else - clearDemoWaypoint() - resetMapSearchState() - closeWorldMap() - safeAfter(0.5, function() - cursor:Hide() - done() - end) - end - end - - function msc.finishHintAndClose(done) - showMinimapHint(function() - minimapCallout:Hide() - minimapArrow:Hide() - closeMapAndFinish(done) - end) - end - - -- Inline hint shown right after a result is clicked and the - -- highlights activate: scroll mapSearchCallout's text from the - -- "Now let's click this result" narration to a short sentence - -- calling out the clear buttons, point a chevron at each, and - -- fire the local button's real tooltip so the user sees what - -- they do. No separate step in the demo list; runs between the - -- click-animation settle and the minimap callout. - function msc.clearBtnHint(done) - local lsf = _G["EasyFindMapSearchFrame"] - local gsf = _G["EasyFindMapGlobalSearchFrame"] - local localBtn = lsf and lsf.clearBtn - local globalBtn = gsf and gsf.clearBtn - local haveLocal = localBtn and localBtn:IsShown() - local haveGlobal = globalBtn and globalBtn:IsShown() - if not haveLocal and not haveGlobal then - done(); return - end - -- Hide the callout's own left-pointing chevron: the two - -- clear-button chevrons below already point at their real - -- targets, so a third arrow from the callout is noise. - mapSearchCallout.arrow.frame:Hide() - -- Two sequential messages. The first sets context; the - -- second is the call-to-action that the chevrons + cursor - -- tooltip will reinforce. Pre-measure the box size required - -- for each, take the max, then tween the box to that size - -- alongside the slide below. Staying at one size across - -- both scrolls avoids a second resize mid-sequence. - local hintContext = "Clear icons will be visible anytime a highlight is active." - local hintAction = "Click any to clear all highlights." - local startW = mapSearchCallout.frame:GetWidth() - local startH = mapSearchCallout.frame:GetHeight() - local savedText = mapSearchCallout.fs:GetText() or "" - mapSearchCallout.fs:SetText(hintContext) - mapSearchCallout.frame:SetAutoSized(340) - local wCtx = mapSearchCallout.frame:GetWidth() - local hCtx = mapSearchCallout.frame:GetHeight() - mapSearchCallout.fs:SetText(hintAction) - mapSearchCallout.frame:SetAutoSized(340) - local wAct = mapSearchCallout.frame:GetWidth() - local hAct = mapSearchCallout.frame:GetHeight() - local targetW = wCtx > wAct and wCtx or wAct - local targetH = hCtx > hAct and hCtx or hAct - mapSearchCallout.fs:SetText(savedText) - mapSearchCallout.frame:SetSize(startW, startH) - mapSearchCallout:SetTextScrolling(hintContext) - -- Slide the callout from its anchored spot next to the - -- results frame down and to the left, so it lands centered - -- below the two map search bars. Tween position and size - -- together so the box grows to fit the two-line text in - -- sync with the slide. - local startL = mapSearchCallout.frame:GetLeft() - local startT = mapSearchCallout.frame:GetTop() - local leftEdge = lsf and lsf:GetLeft() - local rightEdge = gsf and gsf:GetRight() - local lsfBot = lsf and lsf:GetBottom() - local gsfBot = gsf and gsf:GetBottom() - if startL and startT and leftEdge and rightEdge and lsfBot and gsfBot then - local targetL = (leftEdge + rightEdge) * 0.5 - targetW * 0.5 - local targetT = (lsfBot < gsfBot and lsfBot or gsfBot) - 28 - if not mapSearchCallout.moveAnim then - mapSearchCallout.moveAnim = CreateFrame("Frame") - mapSearchCallout.moveAnim:Hide() - end - local moveAnim = mapSearchCallout.moveAnim - moveAnim:SetScript("OnUpdate", nil) - local dur = 0.5 - local el = 0 - moveAnim:SetScript("OnUpdate", function(_, dt) - el = el + dt - local t = el / dur - if t > 1 then t = 1 end - local eased = t * t * (3 - 2 * t) - local nl = startL + (targetL - startL) * eased - local nt = startT + (targetT - startT) * eased - local nw = startW + (targetW - startW) * eased - local nh = startH + (targetH - startH) * eased - mapSearchCallout.frame:SetSize(nw, nh) - mapSearchCallout.frame:ClearAllPoints() - mapSearchCallout.frame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", nl, nt) - if t >= 1 then - moveAnim:SetScript("OnUpdate", nil) - moveAnim:Hide() - end - end) - moveAnim:Show() - end - -- Chevron phase-1 apex lands ~63 px right of emitter.RIGHT - -- (visible 27 + travel 36). Anchoring emitter.RIGHT to the - -- button's LEFT with a -63 offset puts the apex right at - -- the button's left edge at peak travel. - local function showChevronAt(pointer, btn) - if not btn then return end - pointer.emitter:ClearAllPoints() - pointer.emitter:SetPoint("RIGHT", btn, "LEFT", -63, 0) - pointer.emitter:Show() - pointer.chev.frame:Show() - end - local hoverBtn = haveLocal and localBtn or globalBtn - -- Fires once the cursor has travelled to the clear button. - -- Triggers the real OnEnter tooltip, waits, then runs the - -- "Let's try it." -> click -> retype -> reclick sequence - -- before handing off to done() for the minimap callout. - local function clearHintOnArrive() - local onEnter = hoverBtn:GetScript("OnEnter") - if onEnter then pcall(onEnter, hoverBtn) end - safeAfter(2.8, function() - GameTooltip_Hide() - localClearPointer.chev.frame:Hide() - localClearPointer.emitter:Hide() - globalClearPointer.chev.frame:Hide() - globalClearPointer.emitter:Hide() - mapSearchCallout:SetTextScrolling("Let's try it.") - safeAfter(2.0, function() - clickAnim(function() - local onClick = hoverBtn:GetScript("OnClick") - if onClick then - pcall(onClick, hoverBtn, "LeftButton", true) - end - safeAfter(0.9, function() - mapSearchCallout:SetTextScrolling("Now let's quickly get tracking again.") - safeAfter(1.8, function() - -- Keep the callout visible through - -- retype + cursor travel so the user - -- reads the narration the whole way; - -- hide it right at the click. - msc.retypeLastBrowse(function() - local lastPOI = msc.lastBrowsePOI - local target = (lastPOI and findMapResultByName(lastPOI.name)) - or findFirstVisibleMapResult() - if not target then - mapSearchCallout:Hide() - done(); return - end - moveCursorTo(target, CURSOR_MOVE, function() - hideMapCaret() - clickAnim(function() - if target._demoMapHover then target._demoMapHover = nil end - setHoveredRow(nil) - mapSearchCallout:Hide() - if target.data and ns.MapSearch then - pcall(ns.MapSearch.SelectResult, ns.MapSearch, target.data) - end - safeAfter(SETTLE_PAUSE, done) - end) - end) - end) - end) - end) - end) - end) - end) - end - -- Chevrons show immediately so the callout text and the - -- buttons it refers to are pointed at from the first - -- word. Cursor travel + tooltip wait until the text rolls - -- to the action sentence so the visual call-to-action - -- aligns with the imperative. - if haveLocal then showChevronAt(localClearPointer, localBtn) end - if haveGlobal then showChevronAt(globalClearPointer, globalBtn) end - safeAfter(2.2, function() - mapSearchCallout:SetTextScrolling(hintAction) - moveCursorTo(hoverBtn, CURSOR_MOVE, clearHintOnArrive) - end) - end - - -- After the "point is now being tracked" callout, detect - -- whether the player is currently in NEAR mode (circle+arrow - -- visible) or FAR mode (perimeter glow). Then narrate a - -- "move to see it flip" flow tailored to their starting - -- state. EasyFindNearTrack is the frame that only appears - -- when the POI is within 75% of the minimap's radar radius - -- (see MapSearch.lua:769), so its visibility is the mode - -- test. - function msc.trackingStateNarration(done) - local nearTrack = _G["EasyFindNearTrack"] - local glow = _G["EasyFindMinimapGlow"] - local function inNearMode() - return nearTrack and nearTrack:IsShown() - end - local function trackingActive() - local nearShown = nearTrack and nearTrack:IsShown() - local glowShown = glow and glow:IsShown() - return nearShown or glowShown - end - local function cancelTicker() - if trackingStateTicker then - trackingStateTicker:Cancel() - trackingStateTicker = nil - end - end - local function pollUntil(predicate, onArrive) - cancelTicker() - trackingStateTicker = C_Timer.NewTicker(0.25, function() - if predicate() then - cancelTicker() - onArrive() - end - end) - end - -- Final wait: don't end the demo until the player has - -- actually reached the POI and super-tracking has cleared - -- (both the near-track frame and the perimeter glow are - -- hidden). The user asked for this explicitly; no timeout. - local function waitForArrival(onArrive) - pollUntil(function() return not trackingActive() end, onArrive) - end - if inNearMode() then - minimapCallout:SetText("Try moving further to see the perimeter arrow glow.\n(Glow can be turned off in settings.)") - pollUntil(function() return not inNearMode() end, function() - minimapCallout:SetText("Venture all the way to the point to see the circle shrink.") - pollUntil(inNearMode, function() - waitForArrival(done) - end) - end) - else - minimapCallout:SetText("Try moving closer to place the guiding circle around you.\n(Glow can be turned off in settings.)") - pollUntil(inNearMode, function() - minimapCallout:SetText("Venture all the way to the point to see the circle shrink.") - waitForArrival(done) - end) - end - end - - function msc.clickResult(done) - local lastPOI = msc.lastBrowsePOI - local target = (lastPOI and findMapResultByName(lastPOI.name)) - or findFirstVisibleMapResult() - if not target then - safeAfter(0.5, done) - return - end - -- Announce the next action. Scroll-transition the text so - -- the callout (already showing "Hovering over a result..." - -- at the end of browseWhatsAround) stays put and its text - -- ticker-rolls to the new narration instead of popping out - -- and back. Falls back to Show if the callout wasn't already - -- open (e.g. the browse step was skipped). - mapSearchCallout:SetTextScrolling("Now let's click this result.") - -- Extra beat before the click so the user has time to read - -- the freshly-scrolled narration (CURSOR_MOVE below adds - -- another 0.8s of reading while the cursor travels to the - -- row). - safeAfter(2.8, function() - moveCursorTo(target, CURSOR_MOVE, function() - hideMapCaret() - clickAnim(function() - -- Clear the hover tag so setHoveredRow fires OnLeave - -- when SelectResult hides the results frame. - if target._demoMapHover then target._demoMapHover = nil end - setHoveredRow(nil) - if target.data and ns.MapSearch then - pcall(ns.MapSearch.SelectResult, ns.MapSearch, target.data) - end - safeAfter(SETTLE_PAUSE, function() - -- Scroll the callout's text from "Now let's - -- click this result." to the clear-button - -- hint, point chevrons at both clear - -- buttons, fire the local one's tooltip. - -- Then hand off to the minimap tracking - -- callout. - msc.clearBtnHint(function() - mapSearchCallout:Hide() - minimapCallout:SetText("The point is now being tracked.") - minimapCallout:Show() - minimapArrow:Show() - moveCursorTo(minimapCalloutFrame, CURSOR_MOVE, function() - safeAfter(2.5, function() - msc.trackingStateNarration(function() - minimapCallout:Hide() - minimapArrow:Hide() - closeMapAndFinish(done) - end) - end) - end) - end) - end) - end) - end) - end) - end - - function msc.clickPinTrackContinue(done) - -- Scroll-transition from the previous step's "Now let's click - -- this result." callout so the box doesn't pop out and back. - -- Falls through to a normal Show if the callout is hidden. - mapSearchCallout:SetTextScrolling("Click the pin to place a waypoint and start tracking.") - clickPinAndTrack(function() - safeAfter(1.5, function() - mapSearchCallout:Hide() - minimapCallout:SetText("Tracked. There's an even easier way...") - minimapCallout:Show() - minimapArrow:Show() - moveCursorTo(minimapCalloutFrame, CURSOR_MOVE, function() - safeAfter(2.5, function() - minimapCallout:Hide() - minimapArrow:Hide() - -- Move cursor to the map search clear button and click it - -- so the user sees how to dismiss the pin, rather than - -- having it magically disappear. - local lsf = _G["EasyFindMapSearchFrame"] - local clearBtn = lsf and lsf.clearBtn - if clearBtn and clearBtn:IsShown() then - moveCursorTo(clearBtn, CURSOR_MOVE, function() - clickAnim(function() - local handler = clearBtn:GetScript("OnClick") - if handler then pcall(handler, clearBtn) end - clearDemoWaypoint() - safeAfter(0.4, done) - end) - end) - else - clearDemoWaypoint() - resetMapSearchState() - if lsf and lsf.editBox then lsf.editBox:SetText("") end - safeAfter(0.4, done) - end - end) - end) - end) - end) - end - - function msc.clickNavBtnAutoTrack(done) - local lastPOI = msc.lastBrowsePOI - local row = (lastPOI and findMapResultByName(lastPOI.name)) - or findFirstVisibleMapResult() - if not row or not row.navBtn or not row.navBtn:IsShown() then - safeAfter(0.5, done) - return - end - moveCursorTo(row.navBtn, CURSOR_MOVE, function() - hideMapCaret() - -- Briefly show the nav button tooltip before clicking - local onEnter = row.navBtn:GetScript("OnEnter") - if onEnter then pcall(onEnter, row.navBtn) end - safeAfter(1.0, function() - GameTooltip_Hide() - clickAnim(function() - if row.data and ns.MapSearch then - ns.MapSearch.autoTrackNextPin = true - pcall(ns.MapSearch.SelectResult, ns.MapSearch, row.data) - end - safeAfter(SETTLE_PAUSE, done) - end) - end) - end) - end - - function msc.minimapHintAndClose(done) - minimapCallout:SetText("And just like that, your target is tracked on the minimap!") - msc.finishHintAndClose(done) - end - - function msc.pickDiversePOIs() - if not (ns.MapSearch and ns.MapSearch.GetStaticLocations) then return {} end - -- This demo is about what's around the player right now, so - -- prefer the player's actual zone. WMF's mapID is only a - -- fallback because the zone/instance demo navigates WMF to - -- an instance entrance map (no POIs) via SelectResult and - -- stays there after the demo closes. Using WMF as the - -- primary source would incorrectly disable this demo on the - -- very next switch. GetBestMapForUnit can return nil - -- transiently (loading screens, very early init), so cache - -- a last-good mapID as a final fallback. Same priority as - -- MapSearch:SearchForUI's local search. - local getBest = _G["GetBestMapForUnit"] - local playerMapID = getBest and getBest("player") - local wmfMapID = WorldMapFrame and WorldMapFrame:GetMapID() - local pois = playerMapID and ns.MapSearch:GetStaticLocations(playerMapID) - if pois and #pois > 0 then - msc.lastGoodMapID = playerMapID - else - pois = wmfMapID and ns.MapSearch:GetStaticLocations(wmfMapID) - if pois and #pois > 0 then - msc.lastGoodMapID = wmfMapID - elseif msc.lastGoodMapID then - pois = ns.MapSearch:GetStaticLocations(msc.lastGoodMapID) - end - end - if not pois or #pois == 0 then return {} end - local picks, seenCat = {}, {} - for _, poi in ipairs(pois) do - local cat = poi.category or "unknown" - if cat ~= "flightmaster" and not seenCat[cat] and poi.name and poi.name ~= "" then - seenCat[cat] = true - picks[#picks + 1] = poi - if #picks >= 3 then break end - end - end - return picks - end - - -- Max rows to hover per typed prefix. Capped so a prefix that - -- yields many results (e.g. a very common first 3 chars) doesn't - -- stretch the browse step into a long slog. - local MAX_BROWSE_HOVERS = 3 - -- Pause on each row when hovering multiple; a full narration pause - -- when there's only one row so the single preview has time to land. - local BROWSE_MULTI_HOVER_PAUSE = 1.0 - local BROWSE_SINGLE_HOVER_PAUSE = 2.2 - - -- Type `query` into the local map search bar, then walk the cursor - -- through each visible result row (up to MAX_BROWSE_HOVERS), - -- pausing at each so the map preview can update. Each row is - -- tagged with _demoMapHover so setHoveredRow fires real - -- OnEnter/OnLeave as the cursor crosses rows, making the preview - -- track the cursor in real time. - function msc.hoverRowsForPrefix(query, isFirstVisit, done) - local lsf = _G["EasyFindMapSearchFrame"] - local editBox = lsf and lsf.editBox - if not editBox then done(); return end - local function hoverAllRows() - local rows = {} - for i = 1, 20 do - local btn = _G["EasyFindMapResultButton" .. i] - if btn and btn:IsShown() and btn.data and btn.data.name - and not btn.data.isHeader then - rows[#rows + 1] = btn - if #rows >= MAX_BROWSE_HOVERS then break end - end - end - if #rows == 0 then done(); return end - -- Tag every row up front so the preview fires even on rows - -- the cursor passes through between deliberate stops. - for i = 1, #rows do rows[i]._demoMapHover = true end - local pause = (#rows > 1) and BROWSE_MULTI_HOVER_PAUSE or BROWSE_SINGLE_HOVER_PAUSE - local idx = 0 - local function nextRow() - idx = idx + 1 - if idx > #rows then - -- Remember the last visible row so later steps - -- (clickResult, retypeLastBrowse) re-target it. - local lastRow = rows[#rows] - if lastRow and lastRow.data and lastRow.data.name then - msc.lastBrowsePOI = { name = lastRow.data.name } - end - -- Untag the rows we already visited so when the - -- cursor travels back to the editbox for the next - -- prefix it doesn't re-trigger previews on each - -- row it passes over. The last row stays tagged - -- so its preview clears via OnLeave when the - -- cursor finally departs. - for i = 1, #rows - 1 do - rows[i]._demoMapHover = nil - end - done() - return - end - local row = rows[idx] - moveCursorTo(row, CURSOR_MOVE, function() - safeAfter(pause, nextRow) - end) - end - nextRow() - end - local function afterFocus() - backspaceMapTextKeepCaret(editBox, TYPE_DELAY, function() - typeMapTextKeepCaret(editBox, query, TYPE_DELAY, function() - -- Show the narration as soon as typing finishes - -- (only on the first prefix; later prefixes reuse - -- the callout that's already up). Raising it here - -- instead of waiting for cursor arrival at the - -- first row lets the user read the instruction - -- while the cursor is still travelling. - if isFirstVisit then - mapSearchCallout:SetText("Hovering over a result shows it on the map.") - mapSearchCallout:ShowWithArrow() - end - safeAfter(SETTLE_PAUSE, hoverAllRows) - end) - end) - end - if isFirstVisit then - moveCursorTo(editBox, CURSOR_MOVE, function() - clickAnim(afterFocus) - end) - else - moveCursorTo(editBox, CURSOR_MOVE, afterFocus) - end - end - - msc.lastBrowsePOI = nil - function msc.browseWhatsAround(done) - local picks = msc.pickDiversePOIs() - if #picks == 0 then done(); return end - local idx = 0 - local function nextPick() - idx = idx + 1 - if idx > #picks then - -- Leave the callout visible so clickResult can - -- scroll its text from "Hovering over a result..." - -- to "Now let's click this result." without the box - -- popping out and back. - done() - return - end - local poi = picks[idx] - -- Per-category overrides: typing "ah" or "fp" reads as - -- the natural in-game shorthand and avoids spelling out - -- "auc"/"fli" which look awkward in the demo. - local cat = poi.category - local query - if cat == "auctionhouse" then - query = "ah" - elseif cat == "flightmaster" then - query = "fp" - else - query = slower(poi.name):sub(1, 3) - end - msc.hoverRowsForPrefix(query, idx == 1, nextPick) - end - nextPick() - end - - DEMOS.mapSearchCurrent.rebuild = function(def) - msc.lastBrowsePOI = nil - -- Fully wipe the demo payload each rebuild. Both the - -- disabled state and the step list live on the shared `def` - -- table across rebuilds. - def.disabled = false - def.disabledMessage = nil - def.stepDefs = nil - def.sections = nil - def.run = nil - def.setupAfter = nil - local hasBrowse = #msc.pickDiversePOIs() > 0 - if not hasBrowse then - def.disabled = true - def.disabledMessage = "No searchable POIs in this zone." - return - end - local openSnap = function() - openWorldMapToPlayerZone() - end - -- Snap to end-of-browse: map open with the last POI query - -- typed and results visible, ready for "Click a result". - local browseSnap = function() - openWorldMapToPlayerZone() - local picks = msc.pickDiversePOIs() - if #picks > 0 then - local lastPick = picks[#picks] - msc.lastBrowsePOI = { name = lastPick.name } - local query = slower(lastPick.name):sub(1, 3) - if ns.MapSearch and ns.MapSearch.RunLocalSearch then - ns.MapSearch:RunLocalSearch(query) - end - local lastName - local seen = 0 - for i = 1, 20 do - local btn = _G["EasyFindMapResultButton" .. i] - if btn and btn:IsShown() and btn.data and btn.data.name - and not btn.data.isHeader then - lastName = btn.data.name - seen = seen + 1 - if seen >= MAX_BROWSE_HOVERS then break end - end - end - if lastName then - msc.lastBrowsePOI = { name = lastName } - end - end - end - local doneSnap = function() - clearDemoWaypoint(); resetMapSearchState(); closeWorldMap() - end - def.stepDefs = { - { text = "Open the world map", section = 1 }, - { text = "Browse what's around", section = 1 }, - { text = "Click a result", section = 1 }, - } - def.sections = { { header = "", section = 1, firstStep = 1, lastStep = 3 } } - def.run = { - msc.openMap, - msc.browseWhatsAround, - msc.clickResult, - } - def.setupAfter = { openSnap, browseSnap, doneSnap } - end - - ---------------------------------------------------------------- - -- Map search through UI bar: demonstrates searching for map - -- POIs from the main UI search bar and how Fast/Guide mode - -- affect the result click. Fast Mode places a waypoint - -- directly without opening the map. Guide Mode starts a guide - -- to open the map, then shows a pin the user must click. - ---------------------------------------------------------------- - -- Helpers for the Map-search-through-UI-bar demo. All msui.* - -- helpers live on a single table so they only consume one local - -- in this huge function. The demo verifies Fast Mode + the UI - -- search bar's Map Search filter (Local sub-option), then - -- drives the actual search and click. - local msui = {} - - function msui.openFilterDropdown() - local dd = searchFrame.filterDropdown - if dd and not dd:IsShown() then - local anchorFrame = searchFrame - local barScale = (EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0) - dd:SetScale(barScale) - local scale = anchorFrame:GetEffectiveScale() / (UIParent:GetEffectiveScale() * barScale) - local right = anchorFrame:GetRight() * scale - local bottom = anchorFrame:GetBottom() * scale - dd:ClearAllPoints() - dd:SetPoint("TOPRIGHT", UIParent, "BOTTOMLEFT", right, bottom) - dd:Show() - end - end - - function msui.closeFilterDropdown() - local dd = searchFrame.filterDropdown - if dd and dd:IsShown() then dd:Hide() end - end - - -- Fire the full OnClick handler for a filter-dropdown row. The - -- row's OnClick runs the LayoutDropdown closure which isn't - -- exposed externally — but calling the script directly triggers - -- it and correctly repositions sub-rows. - function msui.fireCheckRowClick(row) - if not row then return end - local handler = row:GetScript("OnClick") - if handler then pcall(handler, row) end - end - - function msui.ensureMapFilterEnabled() - EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} - local dd = searchFrame.filterDropdown - if not (dd and dd.checkRows and dd.checkRows.map) then - EasyFind.db.uiSearchFilters.map = true - return - end - local row = dd.checkRows.map - -- If the row isn't checked, flip the checkbox and fire the - -- real OnClick so LayoutDropdown runs and the Local/Global - -- sub-rows become visible in the dropdown. - if not row:GetChecked() then - row:SetChecked(true) - msui.fireCheckRowClick(row) - end - EasyFind.db.uiSearchFilters.map = true - if row.updateMapToggle then row.updateMapToggle() end - end - - -- Returns the map row's Local or Global sub-row. isLocal = true - -- for Local, false for Global. - function msui.getMapSubRow(isLocal) - local dd = searchFrame.filterDropdown - local mr = dd and dd.checkRows and dd.checkRows.map - if not mr or not mr.mapSubRows then return nil end - for _, sr in ipairs(mr.mapSubRows) do - if sr.isLocalKey == isLocal then return sr end - end - return nil - end - - function msui.setMapSubLocal(isLocal) - EasyFind.db.uiMapSearchLocal = isLocal - local dd = searchFrame.filterDropdown - local mr = dd and dd.checkRows and dd.checkRows.map - if mr and mr.mapSubRows then - for _, sr in ipairs(mr.mapSubRows) do - sr:SetChecked(sr.isLocalKey == isLocal) - end - end - end - - -- Reusable helpers for the mapSearchUI run steps. - function msui.stepFilterBtnClick(done) - local fb = searchFrame.filterBtn - if not fb then done(); return end - moveCursorTo(fb, CURSOR_MOVE, function() - clickAnim(function() safeAfter(STEP_PAUSE, done) end) - end) - end - - -- Merged "focus the search bar + start typing" step. Closes the - -- filter dropdown (simulating auto-close on focus), moves the - -- cursor to the search bar, clicks, shows the blink cursor, - -- then types the given text. Used by both Local and Global - -- search sections so focus + typing live in a single user- - -- meaningful step instead of three awkward ones. - function msui.stepFocusAndType(text, done) - msui.closeFilterDropdown() - moveCursorTo(searchFrame.editBox, CURSOR_MOVE, function() - clickAnim(function() - if searchFrame.editBox.placeholder then - searchFrame.editBox.placeholder:Hide() - end - startBlinkCursor() - safeAfter(STEP_PAUSE, function() - typeText(text, TYPE_DELAY, function() - safeAfter(SETTLE_PAUSE, done) - end) - end) - end) - end) - end - - -- Find a visible UI search result whose name contains the given - -- substring (case-insensitive). Falls back to the first visible - -- non-header row if no match (so the demo still has something - -- to click in zones missing the expected POI type). - function msui.findResultRowByPartialName(substring) - local lowSub = substring and substring ~= "" and slower(substring) or nil - local first - for i = 1, #resultButtons do - local row = resultButtons[i] - if row and row:IsShown() and row.data and row.data.name - and not row.data.isPinHeader and not row.data.isHeader then - if lowSub and sfind(slower(row.data.name), lowSub) then - return row - end - if not first then first = row end - end - end - return first - end - - -- Helper: fire the click handler for the flight master or - -- Eastern Plaguelands result row, then settle. - local function msui_clickPartial(substring, afterClick) - local target = msui.findResultRowByPartialName(substring) - if not target then - safeAfter(0.5, afterClick) - return - end - moveCursorTo(target, CURSOR_MOVE, function() - clickAnim(function() - stopBlinkCursor() - if target.data and ns.MapSearch and ns.MapSearch.HandleUISearchClick then - pcall(ns.MapSearch.HandleUISearchClick, ns.MapSearch, target.data) - end - afterClick() - end) - end) - end - - local mapSearchUI_run = { - ---------------------------------------------------------------- - -- SECTION 1: Local Map POI search (flight master) - ---------------------------------------------------------------- - -- 1: Open filter menu - function(done) msui.stepFilterBtnClick(function() msui.openFilterDropdown(); safeAfter(STEP_PAUSE, done) end) end, - -- 2: Enable Map Search filter - function(done) - msui.ensureMapFilterEnabled() - safeAfter(STEP_PAUSE, done) - end, - -- 3: Confirm Local is selected - function(done) - local localRow = msui.getMapSubRow(true) - if localRow then - moveCursorTo(localRow, CURSOR_MOVE, function() - if EasyFind.db.uiMapSearchLocal == false then - clickAnim(function() - msui.setMapSubLocal(true) - safeAfter(STEP_PAUSE, done) - end) - else - safeAfter(0.6, done) - end - end) - else - msui.setMapSubLocal(true) - safeAfter(0.5, done) - end - end, - -- 4: Type "fli" - function(done) msui.stepFocusAndType("fli", done) end, - -- 5: Click the Flight Master result. - function(done) - msui_clickPartial("flight master", function() - minimapCallout:SetText("The point is now being tracked.") - safeAfter(SETTLE_PAUSE, function() - showMinimapHint(function() - minimapCallout:Hide() - minimapArrow:Hide() - clearDemoWaypoint() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if searchFrame.editBox.placeholder then - searchFrame.editBox.placeholder:Show() - end - safeAfter(1.0, function() - transitionFS:SetText("Now let's try a global zone search to find Eastern Plaguelands.") - transitionText:Show() - beginSectionTransition(2) - safeAfter(3.5, done) - end) - end) - end) - end) - end, - ---------------------------------------------------------------- - -- SECTION 2: Global zone search (Eastern Plaguelands) - ---------------------------------------------------------------- - -- 6: Open filter menu - function(done) - transitionText:Hide() - msui.stepFilterBtnClick(function() msui.openFilterDropdown(); safeAfter(STEP_PAUSE, done) end) - end, - -- 7: Switch to Global - function(done) - msui.ensureMapFilterEnabled() - local globalRow = msui.getMapSubRow(false) - if globalRow then - moveCursorTo(globalRow, CURSOR_MOVE, function() - clickAnim(function() - msui.setMapSubLocal(false) - safeAfter(STEP_PAUSE, done) - end) - end) - else - msui.setMapSubLocal(false) - safeAfter(0.5, done) - end - end, - -- 8: Type "eas" - function(done) msui.stepFocusAndType("eas", done) end, - -- 9: Click the Eastern Plaguelands result. - function(done) - msui_clickPartial("eastern plaguelands", function() - safeAfter(SETTLE_PAUSE + 1.5, function() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if searchFrame.editBox.placeholder then - searchFrame.editBox.placeholder:Show() - end - local closeBtn = getMapCloseBtn() - local function finish() - resetMapSearchState() - closeWorldMap() - safeAfter(0.5, function() - cursor:Hide() - done() - end) - end - if closeBtn and closeBtn:IsShown() then - moveCursorTo(closeBtn, CURSOR_MOVE, function() - clickAnim(finish) - end) - else - finish() - end - end) - end) - end, - } - - -- Baseline every setupAfter builds on: filter enabled + local/global sub. - function msui.baseline(isLocal) - msui.ensureMapFilterEnabled() - msui.setMapSubLocal(isLocal) - end - - local mapSearchUI_setupAfter = { - -- 1: Filter menu opened - function() msui.baseline(true); msui.openFilterDropdown() end, - -- 2: Map filter enabled - function() msui.baseline(true); msui.openFilterDropdown() end, - -- 3: Local confirmed - function() msui.baseline(true); msui.closeFilterDropdown() end, - -- 4: "fli" typed - function() - msui.baseline(true); msui.closeFilterDropdown() - searchFrame.editBox:SetText("fli") - if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Hide() end - UI:OnSearchTextChanged("fli") - startBlinkCursor() - end, - -- 5: Flight master clicked - function() - msui.baseline(true); msui.closeFilterDropdown() - clearDemoWaypoint() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end - end, - -- 6: Filter menu opened for section 2 - function() msui.baseline(false); msui.openFilterDropdown() end, - -- 7: Global filter enabled - function() msui.baseline(false); msui.closeFilterDropdown() end, - -- 8: "eas" typed - function() - msui.baseline(false); msui.closeFilterDropdown() - searchFrame.editBox:SetText("eas") - if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Hide() end - UI:OnSearchTextChanged("eas") - startBlinkCursor() - end, - -- 9: Eastern Plaguelands clicked, map closed - function() - msui.baseline(false); msui.closeFilterDropdown() - clearDemoWaypoint() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end - resetMapSearchState(); closeWorldMap() - end, - } - - DEMOS.mapSearchUI.rebuild = function(def) - def.run = mapSearchUI_run - def.setupAfter = mapSearchUI_setupAfter - end - - -------------------------------------------------------------------- - -- Disabled-demo overlay: shown over the step list when a demo - -- can't run (e.g. no saved outfits). Created once, toggled by - -- loadDemo based on def.disabled / def.disabledMessage. - -------------------------------------------------------------------- - local disabledOverlay = CreateFrame("Frame", nil, stepScrollFrame) - disabledOverlay:SetAllPoints() - disabledOverlay:SetFrameLevel(stepScrollFrame:GetFrameLevel() + 10) - local disabledText = disabledOverlay:CreateFontString(nil, "OVERLAY", "GameFontNormal") - disabledText:SetPoint("CENTER", 0, 10) - disabledText:SetWidth(180) - disabledText:SetTextColor(0.7, 0.7, 0.7) - disabledOverlay:Hide() - - -------------------------------------------------------------------- - -- Outfits demo - -------------------------------------------------------------------- - local od = {} - - function od.findOutfitEntry() - if not ns.Database or not ns.Database.uiSearchData then return nil end - local activeID = C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetActiveOutfitID - and C_TransmogOutfitInfo.GetActiveOutfitID() - for _, e in ipairs(ns.Database.uiSearchData) do - if e.outfitID and e.outfitID ~= activeID then return e end - end - return nil - end - - od.entry = nil - - function od.enableFilter(done) - local dd = searchFrame.filterDropdown - local outfitRow = dd and dd.checkRows and dd.checkRows.outfits - msui.stepFilterBtnClick(function() - msui.openFilterDropdown() - safeAfter(STEP_PAUSE, function() - if not outfitRow then - EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} - EasyFind.db.uiSearchFilters.outfits = true - safeAfter(0.3, done) - return - end - moveCursorTo(outfitRow, CURSOR_MOVE, function() - if not outfitRow:GetChecked() then - clickAnim(function() - outfitRow:SetChecked(true) - EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} - EasyFind.db.uiSearchFilters.outfits = true - local h = outfitRow:GetScript("OnClick") - if h then pcall(h, outfitRow) end - safeAfter(STEP_PAUSE, done) - end) - else - safeAfter(0.8, done) - end - end) - end) - end) - end - - function od.typeQuery(query, done) - moveCursorTo(searchFrame.editBox, CURSOR_MOVE, function() - clickAnim(function() - msui.closeFilterDropdown() - startBlinkCursor() - safeAfter(STEP_PAUSE, function() - typeText(query, TYPE_DELAY, function() - safeAfter(STEP_PAUSE, done) - end) - end) - end) - end) - end - - -- Bouncing red arrow (same style as the tutorial mode-toggle arrow) - -- that sits outside the results window pointing at a target row. - local OD_ARROW_SIZE = 28 - local odArrowFrame = CreateFrame("Frame", nil, UIParent) - odArrowFrame:SetFrameStrata("TOOLTIP") - odArrowFrame:SetFrameLevel(1002) - odArrowFrame:SetSize(OD_ARROW_SIZE, OD_ARROW_SIZE) - odArrowFrame:SetIgnoreParentAlpha(true) - local odArrowTex = odArrowFrame:CreateTexture(nil, "ARTWORK") - odArrowTex:SetSize(OD_ARROW_SIZE, OD_ARROW_SIZE) - odArrowTex:SetPoint("CENTER") - odArrowTex:SetTexture(1121272) - odArrowTex:SetTexCoord(0.6078, 0.6402, 0.9381, 0.9688) - odArrowTex:SetRotation(math.pi / 2) -- point left (toward results) - local odArrowBox = ns.TutorialBox.Create(UIParent, "GameFontNormalLarge") - odArrowBox:SetPoint("LEFT", odArrowFrame, "RIGHT", 4, 0) - odArrowBox:Hide() - odArrowFrame:Hide() - - local odPokeElapsed = 0 - local OD_POKE_AMOUNT = 8 - local OD_POKE_PERIOD = 1.4 - odArrowFrame:SetScript("OnUpdate", function(_, dt) - if not odArrowFrame:IsShown() then return end - odPokeElapsed = odPokeElapsed + dt - local offset = math.sin(odPokeElapsed / OD_POKE_PERIOD * math.pi * 2) * OD_POKE_AMOUNT - odArrowTex:SetPoint("CENTER", odArrowFrame, "CENTER", -offset, 0) - end) - - local function odShowArrow(target, labelText) - odArrowFrame:ClearAllPoints() - odArrowFrame:SetPoint("LEFT", target, "RIGHT", 4, 0) - odArrowBox.fs:SetText(labelText or "") - odArrowBox:SetAutoSized(360) - odPokeElapsed = 0 - odArrowFrame:Show() - odArrowBox:Show() - end - - local function odHideArrow() - odArrowFrame:Hide() - odArrowBox:Hide() - end - - -- Wait for the user to actually equip the target outfit - function od.waitForEquip(targetOutfitID, done) - local row = findResultRowByName(od.entry and od.entry.name) or findFirstResultRow() - if not row then done(); return end - stopBlinkCursor() - cursor:Hide() - odShowArrow(row, "Click here to equip outfit") - -- Suppress the lock system and release locks so the user's - -- hardware click reaches the SecureActionButton. - locksSuppressed = true - releaseRunningLocks() - -- Keep the editbox locked so the user can't type during the wait - searchFrame.editBox:EnableMouse(false) - searchFrame.editBox:ClearFocus() - local ticker - ticker = C_Timer.NewTicker(0.2, function() - local activeID = C_TransmogOutfitInfo - and C_TransmogOutfitInfo.GetActiveOutfitID - and C_TransmogOutfitInfo.GetActiveOutfitID() - if activeID == targetOutfitID then - ticker:Cancel() - locksSuppressed = false - applyRunningLocks() - odHideArrow() - od.clearSearch() - transitionFS:SetText("You now have the outfit equipped.") - transitionText:Show() - safeAfter(2.5, function() - transitionFS:SetText("Now let's see how we can pin this for quick access later.") - beginSectionTransition(2) - safeAfter(3.5, function() - transitionText:Hide() - done() - end) - end) - end - end) - od.equipTicker = ticker - end - - function od.pinOutfit(done) - local e = od.entry - if not e then done(); return end - od.typeQuery(slower(e.name):sub(1, 4), function() - local row = findResultRowByName(e.name) or findFirstResultRow() - if not row then done(); return end - moveCursorTo(row, CURSOR_MOVE, function() - mapSearchCallout:SetText("Right-click to pin for quick access.") - mapSearchCallout.frame:ClearAllPoints() - mapSearchCallout.frame:SetPoint("TOP", resultsFrame, "BOTTOM", 0, -8) - mapSearchCallout.frame:Show() - -- Show the right-click mouse indicator next to the cursor - rightClickIcon:Show() - safeAfter(1.0, function() - -- Right-click animation - clickAnim(function() - -- Trigger the real right-click handler to show pin popup - local postClick = row:GetScript("PostClick") - if postClick then postClick(row, "RightButton") end - local popup = _G["EasyFindPinPopup"] - if not popup or not popup:IsShown() then - rightClickIcon:Hide() - if row.data then - UI.PinUIItem(row.data) - od.pinnedByDemo = true - end - safeAfter(1.5, function() - mapSearchCallout:Hide() - od.clearSearch() - safeAfter(0.5, done) - end) - return - end - -- Anchor popup near the row (ShowPinPopup used - -- the real cursor position which is wrong here) - popup:ClearAllPoints() - popup:SetPoint("LEFT", row, "RIGHT", 4, 0) - -- Let the user see the popup for a moment - safeAfter(1.2, function() - rightClickIcon:Hide() - moveCursorTo(popup, CURSOR_MOVE * 0.6, function() - clickAnim(function() - if popup.pinRow and popup.pinRow.Click then - popup.pinRow:Click() - elseif popup.Hide then - popup:Hide() - end - od.pinnedByDemo = true - safeAfter(1.5, function() - mapSearchCallout:Hide() - safeAfter(0.5, done) - end) - end) - end) - end) - end) - end) - end) - end) - end - - function od.showPinnedAccess(done) - local clearBtn = searchFrame.clearTextBtn - if not clearBtn or not clearBtn:IsShown() then - -- No text to clear, just focus the bar - moveCursorTo(searchFrame.editBox, CURSOR_MOVE, function() - clickAnim(function() - if searchFrame.editBox then - searchFrame.editBox:SetText("") - end - if UI.ShowPinnedItems then - pcall(UI.ShowPinnedItems, UI) - end - safeAfter(1.5, function() - transitionFS:SetText("Now you can easily get to it again later without typing.") - transitionText:Show() - safeAfter(3.0, function() - transitionText:Hide() - od.clearSearch() - cursor:Hide() - safeAfter(0.3, done) - end) - end) - end) - end) - return - end - -- Move to the clear (X) button and click it - moveCursorTo(clearBtn, CURSOR_MOVE, function() - clickAnim(function() - local onClick = clearBtn:GetScript("OnClick") - if onClick then pcall(onClick, clearBtn) end - safeAfter(0.6, function() - -- Now focus the empty search bar to show pinned items - moveCursorTo(searchFrame.editBox, CURSOR_MOVE, function() - clickAnim(function() - if UI.ShowPinnedItems then - pcall(UI.ShowPinnedItems, UI) - end - safeAfter(1.5, function() - transitionFS:SetText("Now you can easily get to it again later without typing.") - transitionText:Show() - safeAfter(3.0, function() - transitionText:Hide() - od.clearSearch() - cursor:Hide() - safeAfter(0.3, done) - end) - end) - end) - end) - end) - end) - end) - end - - function od.clearSearch() - if searchFrame and searchFrame.editBox then - searchFrame.editBox:SetText("") - searchFrame.editBox:ClearFocus() - if UI.OnSearchTextChanged then - pcall(UI.OnSearchTextChanged, UI, "") - end - end - setHoveredRow(nil) - stopBlinkCursor() - end - - function od.cleanup() - odHideArrow() - rightClickIcon:Hide() - mapSearchCallout:Hide() - transitionText:Hide() - if od.equipTicker then od.equipTicker:Cancel(); od.equipTicker = nil end - locksSuppressed = false - if od.pinnedByDemo and od.entry then - pcall(UI.UnpinUIItem, od.entry) - od.pinnedByDemo = false - end - od.clearSearch() - end - - - DEMOS.outfits.rebuild = function(def) - local outfitEntry = od.findOutfitEntry() - - if not outfitEntry then - def.disabled = true - def.disabledMessage = "Please save an outfit before trying this demo." - def.sections = {} - def.stepDefs = {} - def.run = {} - def.setupAfter = {} - return - end - - def.disabled = false - - od.entry = outfitEntry - od.pinnedByDemo = false - local savedFilters = EasyFind.db.uiSearchFilters - and EasyFind.db.uiSearchFilters.outfits - - def.sections = { - { header = "Equip a saved outfit", section = 1, firstStep = 1, lastStep = 3 }, - { header = "Pin for quick access", section = 2, firstStep = 4, lastStep = 5 }, - } - def.stepDefs = { - { text = "Enable Outfits search filter", section = 1 }, - { text = "Start typing an outfit name", section = 1 }, - { text = "Click the outfit to equip it", section = 1 }, - { text = "Right-click to pin it", section = 2 }, - { text = "Focus the search bar to see pins", section = 2 }, - } - - def.run = { - -- 1: Enable filter - function(done) - od.enableFilter(function() - od.entry = od.findOutfitEntry() - done() - end) - end, - -- 2: Type outfit name - function(done) - local e = od.entry - if not e then done(); return end - od.typeQuery(slower(e.name):sub(1, 4), done) - end, - -- 3: Show arrow, wait for user to equip - function(done) - local e = od.entry - if not e then done(); return end - od.waitForEquip(e.outfitID, done) - end, - -- 4: Re-search + right-click to pin - function(done) - transitionText:Hide() - od.pinOutfit(done) - end, - -- 5: Focus bar to show pinned items - function(done) - od.showPinnedAccess(done) - end, - } - - local function snap() - od.cleanup() - EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} - EasyFind.db.uiSearchFilters.outfits = true - end - def.setupAfter = { - function() snap() end, - function() snap() end, - function() snap() end, - function() snap() end, - function() - od.cleanup() - if EasyFind.db.uiSearchFilters then - EasyFind.db.uiSearchFilters.outfits = savedFilters - end - if od.entry then pcall(UI.UnpinUIItem, od.entry) end - cursor:Hide() - end, - } - end - - -------------------------------------------------------------------- - -- Appearance Sets demo - -------------------------------------------------------------------- - local asd = {} - - local CLASS_ARMOR = { - [1]=4,[2]=4,[6]=4, -- Plate - [3]=3,[7]=3,[13]=3, -- Mail - [4]=2,[10]=2,[11]=2,[12]=2, -- Leather - [5]=1,[8]=1,[9]=1, -- Cloth - } - - function asd.findSetEntry() - if not ns.Database or not ns.Database.uiSearchData then return nil end - local GetBaseSetID = C_TransmogSets and C_TransmogSets.GetBaseSetID - local GetSetInfo = C_TransmogSets and C_TransmogSets.GetSetInfo - for _, e in ipairs(ns.Database.uiSearchData) do - if e.transmogSetID then - -- Only pick base PvE sets; variants can't be scrolled - -- to, and PvP sets have faction/navigation issues - if not GetBaseSetID or GetBaseSetID(e.transmogSetID) == e.transmogSetID then - local info = GetSetInfo and GetSetInfo(e.transmogSetID) - local label = info and info.label and slower(info.label) or "" - local isPvP = sfind(label, "pvp") or sfind(label, "season") - or sfind(label, "gladiator") or sfind(label, "aspirant") - or sfind(label, "combatant") - if not isPvP then - return e - end - end - end - end - return nil - end - - -- Cached entries so every step in the same section clicks - -- the exact same set instead of re-querying the database - -- (which may return a different entry after filter changes). - asd.playerSetEntry = nil - asd.altSetEntry = nil - - -- "+ Ctrl" label that floats to the right of the cursor during - -- Ctrl+Click steps. Same gold font as the floating callout text - -- so it reads as part of the demo narration, not a UI element. - local ctrlBadge = CreateFrame("Frame", nil, UIParent) - ctrlBadge:SetSize(80, 20) - ctrlBadge:SetFrameStrata("TOOLTIP") - ctrlBadge:SetFrameLevel(10000) - local ctrlBadgeFS = ctrlBadge:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") - ctrlBadgeFS:SetPoint("LEFT") - ctrlBadgeFS:SetText("+ Ctrl") - ctrlBadgeFS:SetTextColor(1.0, 0.82, 0.0) - ctrlBadgeFS:SetShadowColor(0, 0, 0, 1) - ctrlBadgeFS:SetShadowOffset(2, -2) - ctrlBadge:Hide() - - function asd.showCtrlBadge() - ctrlBadge:ClearAllPoints() - ctrlBadge:SetPoint("LEFT", cursor, "RIGHT", 4, 0) - ctrlBadge:Show() - end - - function asd.enableFilter(done) - local dd = searchFrame.filterDropdown - local asRow = dd and dd.checkRows and dd.checkRows.appearanceSets - msui.stepFilterBtnClick(function() - msui.openFilterDropdown() - safeAfter(STEP_PAUSE, function() - if not asRow then - EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} - EasyFind.db.uiSearchFilters.appearanceSets = true - safeAfter(0.3, done) - return - end - moveCursorTo(asRow, CURSOR_MOVE, function() - if not asRow:GetChecked() then - clickAnim(function() - asRow:SetChecked(true) - EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} - EasyFind.db.uiSearchFilters.appearanceSets = true - local h = asRow:GetScript("OnClick") - if h then pcall(h, asRow) end - safeAfter(STEP_PAUSE, done) - end) - else - safeAfter(0.8, done) - end - end) - end) - end) - end - - function asd.typeQuery(query, done) - moveCursorTo(searchFrame.editBox, CURSOR_MOVE, function() - clickAnim(function() - msui.closeFilterDropdown() - if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Hide() end - startBlinkCursor() - safeAfter(STEP_PAUSE, function() - typeText(query, TYPE_DELAY, function() - safeAfter(SETTLE_PAUSE, done) - end) - end) - end) - end) - end - - -- Animate cursor to a frame's close button and click it. - local function closeFrameAnimated(frame, afterClose) - if not frame or not frame:IsShown() then - if afterClose then afterClose() end - return - end - local closeBtn = frame.CloseButton - or (frame.BorderFrame and frame.BorderFrame.CloseButton) - if closeBtn and closeBtn:IsShown() then - moveCursorTo(closeBtn, CURSOR_MOVE, function() - clickAnim(function() - pcall(HideUIPanel, frame) - safeAfter(0.4, afterClose) - end) - end) - else - pcall(HideUIPanel, frame) - safeAfter(0.4, afterClose) - end - end - - function asd.ctrlClickSet(calloutText, done) - local row = findResultRowByName(nil) or findFirstResultRow() - if not row or not row.data or not row.data.transmogSetID then done(); return end - asd.showCtrlBadge() - moveCursorTo(row, CURSOR_MOVE, function() - clickAnim(function() - stopBlinkCursor() - mapSearchCallout:SetText(calloutText) - mapSearchCallout:Show() - if UI.DressUpAppearanceSet then - pcall(UI.DressUpAppearanceSet, UI, row.data.transmogSetID) - end - safeAfter(2.5, function() - ctrlBadge:Hide() - mapSearchCallout:Hide() - closeFrameAnimated(_G["DressUpFrame"], function() - asd.clearSearch() - safeAfter(0.3, done) - end) - end) - end) - end) - end - - function asd.regularClickSet(calloutText, useFast, done) - local row = findResultRowByName(nil) or findFirstResultRow() - if not row or not row.data or not row.data.transmogSetID then done(); return end - local clickedData = row.data - moveCursorTo(row, CURSOR_MOVE, function() - clickAnim(function() - stopBlinkCursor() - mapSearchCallout:SetText(calloutText) - mapSearchCallout:Show() - local guideData = { - name = clickedData.name, - steps = { - { buttonFrame = "CollectionsMicroButton" }, - { waitForFrame = "CollectionsJournal", tabIndex = 5 }, - { waitForFrame = "WardrobeCollectionFrame", wardrobeSetsTab = true }, - { waitForFrame = "WardrobeCollectionFrame", - transmogSetID = clickedData.transmogSetID, - transmogSetName = clickedData.name }, - }, - } - if useFast then - UI:DirectOpen(guideData) - else - EasyFind:StartGuide(guideData) - end - safeAfter(3.0, function() - mapSearchCallout:Hide() - if ns.Highlight and ns.Highlight.ClearAll then - pcall(ns.Highlight.ClearAll, ns.Highlight) - end - closeFrameAnimated(_G["CollectionsJournal"], function() - asd.clearSearch() - safeAfter(0.3, done) - end) - end) - end) - end) - end - - function asd.switchClassFilter(targetClassID, done) - msui.stepFilterBtnClick(function() - msui.openFilterDropdown() - safeAfter(STEP_PAUSE, function() - local dd = searchFrame.filterDropdown - local asRow = dd and dd.checkRows and dd.checkRows.appearanceSets - local csRow = asRow and asRow.asClassSelectRow - if not csRow then - EasyFind.db.appearanceSetClass = { classID = targetClassID } - if ns.Database and ns.Database.PopulateDynamicTransmogSets then - ns.Database:PopulateDynamicTransmogSets() - end - msui.closeFilterDropdown() - done() - return - end - moveCursorTo(csRow, CURSOR_MOVE, function() - clickAnim(function() - -- Fire the row's real OnClick which calls - -- LayoutClassPopup + positions + shows the popup. - local handler = csRow:GetScript("OnClick") - if handler then pcall(handler, csRow) end - local popup = _G["EasyFindAsClassPopup"] - safeAfter(0.5, function() - local found - if popup then - for _, child in ipairs({popup:GetChildren()}) do - if child._classID == targetClassID then - found = child; break - end - end - end - if found then - moveCursorTo(found, CURSOR_MOVE, function() - clickAnim(function() - local h = found:GetScript("OnClick") - if h then pcall(h, found) end - safeAfter(0.8, function() - msui.closeFilterDropdown() - done() - end) - end) - end) - else - EasyFind.db.appearanceSetClass = { classID = targetClassID } - if ns.Database and ns.Database.PopulateDynamicTransmogSets then - ns.Database:PopulateDynamicTransmogSets() - end - if popup then popup:Hide() end - msui.closeFilterDropdown() - done() - end - end) - end) - end) - end) - end) - end - - function asd.clearSearch() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end - end - - function asd.cleanupAll() - ctrlBadge:Hide() - mapSearchCallout:Hide() - local duf = _G["DressUpFrame"] - if duf and duf:IsShown() then pcall(HideUIPanel, duf) end - local cj = _G["CollectionsJournal"] - if cj and cj:IsShown() then pcall(HideUIPanel, cj) end - if ns.Highlight and ns.Highlight.ClearAll then - pcall(ns.Highlight.ClearAll, ns.Highlight) - end - asd.clearSearch() - end - - DEMOS.appearanceSets.rebuild = function(def) - local _, _, playerClassID = UnitClass("player") - local myArmor = CLASS_ARMOR[playerClassID] - local altClassID - for cid, armor in pairs(CLASS_ARMOR) do - if armor ~= myArmor then altClassID = cid; break end - end - local altClassName = altClassID and (GetClassInfo(altClassID)) or "another class" - - def.sections = { - { header = "Preview on your character", section = 1, firstStep = 1, lastStep = 3 }, - { header = "Open in Collections", section = 2, firstStep = 4, lastStep = 5 }, - { header = "Try " .. altClassName .. "'s armor", section = 3, firstStep = 6, lastStep = 8 }, - } - def.stepDefs = { - { text = "Enable Appearance Sets search", section = 1 }, - { text = "Start typing an appearance set name", section = 1 }, - { text = "Ctrl+Click to preview on your character", section = 1 }, - { text = "Search the same set again", section = 2 }, - { text = "Click to open it in Collections", section = 2 }, - { text = "Switch class filter to " .. altClassName, section = 3 }, - { text = "Search a " .. altClassName .. " appearance set", section = 3 }, - { text = "Ctrl+Click to preview on your character", section = 3 }, - } - local savedAsClass = EasyFind.db.appearanceSetClass - local savedAsFilters = {} - for _, k in ipairs({"appearanceSetCollected", "appearanceSetNotCollected", "appearanceSetPvE", "appearanceSetPvP"}) do - savedAsFilters[k] = EasyFind.db[k] - end - - def.run = { - -- 1: Enable filter + cache player's set - function(done) - asd.enableFilter(function() - asd.playerSetEntry = asd.findSetEntry() - done() - end) - end, - -- 2: Type set name - function(done) - local e = asd.playerSetEntry - if not e then done(); return end - asd.typeQuery(slower(e.name):sub(1, 4), done) - end, - -- 3: Ctrl+Click → DressUp (opens, shows callout, closes) - function(done) - asd.ctrlClickSet( - "Ctrl+Click previews the set on your character.", done) - end, - -- 4: Type same set again - function(done) - local e = asd.playerSetEntry - if not e then done(); return end - asd.typeQuery(slower(e.name):sub(1, 4), done) - end, - -- 5: Regular click → Collections (opens, shows callout, closes) - function(done) - asd.regularClickSet( - "A regular click opens the set in Collections.", - true, done) - end, - -- 6: Switch class filter + cache alt set - function(done) - if not altClassID then done(); return end - asd.switchClassFilter(altClassID, function() - asd.altSetEntry = asd.findSetEntry() - done() - end) - end, - -- 7: Type different class set - function(done) - local e = asd.altSetEntry - if not e then done(); return end - asd.typeQuery(slower(e.name):sub(1, 4), done) - end, - -- 8: Ctrl+Click → DressUp (different armor, then restore) - function(done) - asd.ctrlClickSet( - "You can preview any class's armor on your character.", - function() - EasyFind.db.appearanceSetClass = savedAsClass - for k, v in pairs(savedAsFilters) do EasyFind.db[k] = v end - if ns.Database and ns.Database.PopulateDynamicTransmogSets then - ns.Database:PopulateDynamicTransmogSets() - end - cursor:Hide() - done() - end) - end, - } - - local function snap(classOverride) - asd.cleanupAll() - EasyFind.db.uiSearchFilters = EasyFind.db.uiSearchFilters or {} - EasyFind.db.uiSearchFilters.appearanceSets = true - if classOverride then - EasyFind.db.appearanceSetClass = classOverride - else - EasyFind.db.appearanceSetClass = savedAsClass - end - if ns.Database and ns.Database.PopulateDynamicTransmogSets then - ns.Database:PopulateDynamicTransmogSets() - end - end - def.setupAfter = { - function() snap() end, -- 1 - function() snap() end, -- 2 - function() snap() end, -- 3 - function() snap() end, -- 4 - function() snap() end, -- 5 - function() snap(altClassID and { classID = altClassID } or nil) end, -- 6 - function() snap(altClassID and { classID = altClassID } or nil) end, -- 7 - function() -- 8 - snap() - EasyFind.db.appearanceSetClass = savedAsClass - for k, v in pairs(savedAsFilters) do EasyFind.db[k] = v end - if ns.Database and ns.Database.PopulateDynamicTransmogSets then - ns.Database:PopulateDynamicTransmogSets() - end - end, - } - end - - -------------------------------------------------------------------- - -- Demo state machine. Transport controls: Prev / Play-Pause / Next. - -- State vars completedUpTo and animatingIdx are already declared - -- above next to refreshStepList. - -------------------------------------------------------------------- - local autoPlay = false - - -------------------------------------------------------------------- - -- Click-blocker lock system. While the demo is actively running - -- (animatingIdx > 0 or autoPlay && !paused), every interactive - -- frame the demo touches gets a transparent click-blocker child - -- frame on top that eats real mouse events. Programmatic clicks - -- (tab:Click(), UI:SelectResult(), etc.) bypass the blocker so - -- the demo can still drive the UI internally. The drag handler - -- and editbox keyboard are also disabled during run. - -- - -- The moment the demo is paused, idle (between manual steps), - -- or stopped, every blocker is released and the player gets - -- full access back, exactly as the user requested. - -------------------------------------------------------------------- - local clickBlockers = {} - local function lockFrame(f, topPad) - if not f or clickBlockers[f] then return end - topPad = topPad or 8 - local blocker = CreateFrame("Frame", nil, f) - blocker:SetPoint("TOPLEFT", f, "TOPLEFT", -8, topPad) - blocker:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 8, -8) - blocker:SetFrameStrata(f:GetFrameStrata()) - blocker:SetFrameLevel(1000) - blocker:EnableMouse(true) - -- Also eat scroll wheel events so the player can't scroll - -- the underlying frame's contents while a step is running. - -- Mouse wheel needs both EnableMouseWheel and an explicit - -- OnMouseWheel handler; without the handler the event - -- propagates to the parent. - blocker:EnableMouseWheel(true) - blocker:SetScript("OnMouseWheel", function() end) - blocker:Show() - clickBlockers[f] = blocker - end - local function unlockAllFrames() - for f, blocker in pairs(clickBlockers) do - blocker:EnableMouse(false) - blocker:EnableMouseWheel(false) - blocker:SetScript("OnMouseWheel", nil) - blocker:Hide() - blocker:SetParent(nil) - clickBlockers[f] = nil - end - end - -- Lock the map search bars (local + global) while the demo is - -- running so the real user can't click/focus them or type into - -- them. Clicks are eaten by the lockFrame blocker, EnableMouse - -- on the editbox is a belt-and-suspenders, and ClearFocus makes - -- sure the editbox doesn't hold pre-existing keyboard focus. - local function lockMapSearchEditBoxes() - for _, frameName in ipairs({ "EasyFindMapSearchFrame", "EasyFindMapGlobalSearchFrame" }) do - local lsf = _G[frameName] - local eb = lsf and lsf.editBox - if eb then - lockFrame(lsf) - eb:EnableMouse(false) - eb:ClearFocus() - end - end - end - local function unlockMapSearchEditBoxes() - for _, frameName in ipairs({ "EasyFindMapSearchFrame", "EasyFindMapGlobalSearchFrame" }) do - local lsf = _G[frameName] - local eb = lsf and lsf.editBox - if eb then - eb:EnableMouse(true) - end - end - end - - applyRunningLocks = function() - lockFrame(searchFrame) - lockFrame(resultsFrame) - -- Demo-specific frames (PlayerSpellsFrame, WorldMapFrame, - -- etc.) need a bigger top pad because frame tabs anchor - -- just outside the parent's bounds. - local def = DEMOS[currentDemoKey] - if def and def.lockFrames then - for _, name in ipairs(def.lockFrames) do - lockFrame(_G[name], 32) - end - end - searchFrame:SetScript("OnDragStart", nil) - searchFrame.setupMode = true - searchFrame.editBox:EnableMouse(false) - if searchFrame.filterDropdown then - searchFrame.filterDropdown._demoSuspend = true - end - lockMapSearchEditBoxes() - end - releaseRunningLocks = function() - unlockAllFrames() - searchFrame:SetScript("OnDragStart", savedDragStart) - searchFrame.setupMode = nil - searchFrame.editBox:EnableMouse(true) - if searchFrame.filterDropdown then - searchFrame.filterDropdown._demoSuspend = nil - end - unlockMapSearchEditBoxes() - end - locksSuppressed = false - updateLockState = function() - if not active then releaseRunningLocks(); return end - if locksSuppressed then return end - local isRunning = (not paused) and (animatingIdx > 0 or autoPlay) - if isRunning then - applyRunningLocks() - else - releaseRunningLocks() - end - minimapArrow:SetPaused(not isRunning) - end - - local function updateButtons() - -- Empty demo (no animation steps yet): disable all transport - -- buttons and show the play icon in its idle state. - if not demoSteps or #demoSteps == 0 then - prevBtn:Disable() - nextBtn:Disable() - sectPrevBtn:Disable() - sectNextBtn:Disable() - playBtn:Show() - playBtn:Disable() - playBtn.icon:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") - playBtn.icon:SetTexCoord(0, 1, 0, 1) - playBtn.icon:SetSize(11, 11) - replayBtn:Hide() - updateLockState() - return - end - local animating = animatingIdx > 0 - local atEnd = completedUpTo >= #demoSteps and not animating - -- Prev: disabled only at the very start (no step has run yet - -- and nothing is animating). Otherwise always clickable, so the - -- player can skip backward even mid-animation. - if completedUpTo < 1 and not animating then - prevBtn:Disable() - sectPrevBtn:Disable() - else - prevBtn:Enable() - sectPrevBtn:Enable() - end - -- Next: disabled only when there's no further step to run and - -- nothing is currently animating. - if atEnd then - nextBtn:Disable() - sectNextBtn:Disable() - else - nextBtn:Enable() - sectNextBtn:Enable() - end - -- At the end of the demo, swap the Play button out for the - -- Replay button (in the same middle position). Otherwise the - -- Play button is visible with its play/pause icon. - playBtn:SetShown(not atEnd) - playBtn:Enable() - replayBtn:SetShown(atEnd) - if animating or autoPlay then - replayBtn:Disable() - else - replayBtn:Enable() - end - -- The play icon shows "pause" only while actively running (the - -- next click will pause). When idle, paused, or stepping - -- manually it shows "play" (the next click will resume/start). - if autoPlay and not paused then - playBtn.icon:SetTexture("Interface\\AddOns\\EasyFind\\demo-pause") - playBtn.icon:SetTexCoord(0, 1, 0, 1) - playBtn.icon:SetSize(11, 11) - else - playBtn.icon:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") - playBtn.icon:SetTexCoord(0, 1, 0, 1) - playBtn.icon:SetSize(11, 11) - end - updateLockState() - end - - local runStep -- forward decl - local resetDemoGameState -- forward decl, defined below cancelInFlight - runStep = function(i) - if not active then return end - highlightOverride = nil - if i > #demoSteps then - -- Demo finished: stop autoplay, clean up the cursor + - -- any frames the demo opened, and revert the user's - -- pre-demo settings (Fast/Guide mode, fade, etc.) - -- so the demo doesn't leave their preferences mutated. - autoPlay = false - cursor:Hide() - transitionText:Hide() - if resetDemoGameState then resetDemoGameState() end - restoreUserSettings() - updateButtons() - return - end - if i < 1 then i = 1 end - -- Starting a real step clears any section-transition - -- highlight left over from the previous step's action so - -- the new step can take focus. - pendingSectionHighlight = nil - animatingIdx = i - refreshStepList() - updateButtons() - local myGen = stepGen - -- PRE-ACT pause: highlight just moved to step i (and the - -- list scrolled). Wait long enough for the user to read - -- the step label before the cursor starts moving. - safeAfter(PRE_ACT_GAP, function() - if not active or myGen ~= stepGen then return end - demoSteps[i](function() - if not active or myGen ~= stepGen then return end - -- Snapshot the cursor's final position for this - -- step so later jumpToStep/jumpToBeforeStep calls - -- can restore it to the exact same spot instead - -- of letting moveCursorTo fall back to its - -- default middle-right starting position. - if cursor:IsShown() and cursor:GetLeft() then - cursorEndPos[i] = { - x = cursor:GetLeft() + 4, - y = cursor:GetTop() - 4, - shown = true, - } - else - cursorEndPos[i] = { shown = false } - end - -- Snapshot whether the step's action already - -- pre-highlighted a section header (via - -- beginSectionTransition during its transition - -- text). If so, the boundary beat below is - -- redundant since the header has been on screen - -- throughout the transition. - local transitionedMidStep = pendingSectionHighlight ~= nil - -- POST-ACT pause: keep animatingIdx = i so the - -- step stays highlighted while the user sees the - -- result of the action. Only after this pause does - -- the highlight advance to the next step. - safeAfter(POST_ACT_GAP, function() - if not active or myGen ~= stepGen then return end - animatingIdx = 0 - if i > completedUpTo then - completedUpTo = i - end - refreshStepList() - -- Final step finished (manual path): settle, - -- clean up, restore user settings. - if completedUpTo >= #demoSteps and not autoPlay then - safeAfter(0.6, function() - if not active then return end - cursor:Hide() - if resetDemoGameState then resetDemoGameState() end - restoreUserSettings() - updateButtons() - end) - end - if autoPlay and active then - updateButtons() - -- Section transition was pre-announced by - -- the step's own action (header shown - -- during transition text). Skip the - -- boundary beat and chain straight to the - -- next step, whose PRE-ACT pause will - -- display the first step of the new - -- section before it acts. - if transitionedMidStep then - runStep(i + 1) - else - -- Normal section boundary: the - -- refreshStepList above highlighted - -- the header via the atBoundary logic - -- (not pendingSectionHighlight). Give - -- the header its own beat before the - -- next step takes over. - local nextIdx = i + 1 - local atBoundary = false - if nextIdx <= #demoSteps and DEMO_SECTIONS then - for _, sect in ipairs(DEMO_SECTIONS) do - if nextIdx == sect.firstStep and sect.header and sect.header ~= "" then - atBoundary = true - break - end - end - end - if atBoundary then - safeAfter(POST_ACT_GAP, function() - if not autoPlay or not active or myGen ~= stepGen then - updateButtons() - return - end - runStep(i + 1) - end) - else - runStep(i + 1) - end - end - else - updateButtons() - end - end) - end) - end) - end - - -- Reset the game state to a clean slate (nothing typed, no frames - -- open, no highlights showing). Used by Previous to rewind from - -- whatever the current state is before applying a step's "after" - -- state. Does NOT touch the demo panel itself or the mock cursor. - -- Forward-declared above so runStep's done callback can call it. - resetDemoGameState = function() - stopBlinkCursor() - searchFrame.editBox:SetText("") - UI:OnSearchTextChanged("") - if searchFrame.editBox.placeholder then - searchFrame.editBox.placeholder:Show() - end - if searchFrame.filterDropdown and searchFrame.filterDropdown:IsShown() then - searchFrame.filterDropdown:Hide() - end - if ns.Highlight and ns.Highlight.ClearAll then - pcall(ns.Highlight.ClearAll, ns.Highlight) - end - -- Wipe every demo-modified map state so switching demos - -- doesn't leave a prior demo's pin/waypoint/search text - -- bleeding into the next one. Hide the callout and arrow. - if minimapCallout then minimapCallout:Hide() end - if minimapArrow then minimapArrow:Hide() end - if mapSearchCallout then mapSearchCallout:Hide() end - if localClearPointer then - localClearPointer.chev.frame:Hide() - localClearPointer.emitter:Hide() - end - if globalClearPointer then - globalClearPointer.chev.frame:Hide() - globalClearPointer.emitter:Hide() - end - -- Outfit demo cleanup (arrow, ticker, right-click icon, pin) - if od then pcall(od.cleanup) end - if clearDemoWaypoint then pcall(clearDemoWaypoint) end - if resetMapSearchState then pcall(resetMapSearchState) end - -- Revert any WMF mapID change before closing so the next - -- demo's rebuild (e.g. mapSearchCurrent's pickDiversePOIs) - -- sees the player's real pre-demo WMF state instead of - -- wherever the previous demo navigated to. - restoreWmfMapID() - if closeWorldMap then pcall(closeWorldMap) end - if not InCombatLockdown() then - local psf = _G["PlayerSpellsFrame"] - if psf and psf.IsShown and psf:IsShown() then - pcall(HideUIPanel, psf) - end - end - resetPlayerSpellsFrame() - end - - -- Cancel any in-flight step animation. Bumps the generation counter - -- so every async callback captured before this point aborts on its - -- next check, stops the current timer / cursor OnUpdate / blink - -- cursor, and clears any stray tooltip / hover state. - local function cancelInFlight() - stepGen = stepGen + 1 - wipe(pendingTimers) - paused = false - highlightOverride = nil - pendingSectionHighlight = nil - cursor:SetScript("OnUpdate", nil) - cursor:SetSize(36, 36) - -- Hide the fake cursor. The next runStep will re-show it - -- at moveCursorTo's default starting position (mid-right - -- of the screen), so jumping to any step via the step - -- list starts the cursor from a clean spot instead of - -- wherever the previous animation left it. - cursor:Hide() - stopBlinkCursor() - setHoveredRow(nil) - clearButtonHover() - if transitionText then transitionText:Hide() end - if trackingStateTicker then trackingStateTicker:Cancel(); trackingStateTicker = nil end - if minimapCallout then minimapCallout:Hide() end - if minimapArrow then minimapArrow:Hide() end - if mapSearchCallout then mapSearchCallout:Hide() end - if localClearPointer then - localClearPointer.chev.frame:Hide() - localClearPointer.emitter:Hide() - end - if globalClearPointer then - globalClearPointer.chev.frame:Hide() - globalClearPointer.emitter:Hide() - end - animatingIdx = 0 - end - - -- setupAfterStep[i] puts the game in the end-state of step i, no - -- animation. These are used by Previous to rewind: reset to clean - -- slate, then call setupAfterStep[target] for the new position. - DEMOS.uiSearch.setupAfter = { - -- 1: "sp" typed + results showing - function() - searchFrame.editBox:SetText("sp") - UI:OnSearchTextChanged("sp") - startBlinkCursor() - end, - -- 2: Spellbook opened then cleaned up (the run function - -- closes its window and clears the search before calling - -- done), so the end state is idle. - function() end, - } - setupAfterStep = DEMOS.uiSearch.setupAfter - - -- Swap the active demo. Cancels any in-flight animation, restores - -- game state, retargets the working refs to the new demo's - -- tables, rewires the title, and rebuilds the step rows. - loadDemo = function(key) - local def = DEMOS[key] - if not def or not active then return end - cancelInFlight() - autoPlay = false - resetDemoGameState() - cursor:Hide() - -- Cursor-end snapshots are per-demo: wipe so the new - -- demo's step indices don't pick up stale positions from - -- the previous demo. - wipe(cursorEndPos) - -- Dynamic demos rebuild their step lists based on current - -- user settings (e.g. mapSearchCurrent adapts to the user's - -- Fast/Guide mode). Run rebuild BEFORE restoreUserSettings - -- so it can resync saved mode values to the live ones, - -- preserving any toggle the user made between demo loads. - if def.rebuild then def.rebuild(def) end - restoreUserSettings() - currentDemoKey = key - DEMO_STEPS = def.stepDefs - DEMO_SECTIONS = def.sections - demoSteps = def.run - setupAfterStep = def.setupAfter - title:SetText("|cffFFD100" .. def.title .. "|r") - completedUpTo = 0 - animatingIdx = 0 - rebuildStepRows() - refreshStepList() - updateButtons() - -- Disabled demo overlay - if def.disabled then - disabledText:SetText(def.disabledMessage or "This demo is not available.") - disabledOverlay:Show() - else - disabledOverlay:Hide() - end - if refreshDemoMenuActive then refreshDemoMenuActive() end - end - - -- Restore the fake cursor to its saved end-of-step position - -- (captured during a previous playthrough). If there's no - -- recorded position for that step, leaves the cursor hidden - -- so the next runStep places it at moveCursorTo's default - -- starting spot. - local function restoreCursorForStep(stepIdx) - local pos = cursorEndPos[stepIdx] - if pos and pos.shown and pos.x and pos.y then - placeCursorAt(pos.x, pos.y) - cursor:Show() - else - cursor:Hide() - end - end - - -- Jump directly to step N's end state. Used by clicking a row in - -- the step list. No animation: cancel anything in flight, wipe the - -- game state, then apply setupAfterStep[N]. - jumpToStep = function(target) - if not active then return end - if target < 1 or target > #demoSteps then return end - cancelInFlight() - autoPlay = false - resetDemoGameState() - if setupAfterStep[target] then - setupAfterStep[target]() - end - completedUpTo = target - refreshStepList() - -- Cursor belongs at the end of step `target`. - restoreCursorForStep(target) - updateButtons() - end - - -- Jump to the state right BEFORE step N (i.e., end state of N-1), - -- so the next Play / Next runs step N from the beginning. Used - -- when clicking a section header to start that section fresh. - jumpToBeforeStep = function(target) - if not active then return end - if target < 1 or target > #demoSteps then return end - cancelInFlight() - autoPlay = false - resetDemoGameState() - if target > 1 and setupAfterStep[target - 1] then - setupAfterStep[target - 1]() - end - completedUpTo = target - 1 - refreshStepList() - -- Cursor belongs at the end of step `target-1`, which is - -- the exact spot where step `target` would start naturally - -- during a linear playthrough. - if target > 1 then - restoreCursorForStep(target - 1) - else - cursor:Hide() - end - updateButtons() - end - - -- Prev/Next jump to the beginning of the adjacent step without - -- playing any animation. Only Play triggers animations. - -- The target step gets highlightOverride so it's always a step - -- that lights up (never a section header). - local function isAtBoundary() - local nextIdx = completedUpTo + 1 - if nextIdx > #demoSteps then return false end - for _, sect in ipairs(DEMO_SECTIONS) do - if nextIdx == sect.firstStep then return true end - end - return false - end - - prevBtn:SetScript("OnClick", function() - if not active then return end - if animatingIdx > 0 then cancelInFlight(); autoPlay = false end - local target - if highlightOverride then - target = highlightOverride - 1 - else - target = completedUpTo - end - if target < 1 then return end - jumpToBeforeStep(target) - highlightOverride = target - refreshStepList() - end) - - nextBtn:SetScript("OnClick", function() - if not active then return end - if animatingIdx > 0 then cancelInFlight(); autoPlay = false end - local target - if highlightOverride then - target = highlightOverride + 1 - elseif isAtBoundary() then - target = completedUpTo + 1 - else - target = completedUpTo + 2 - end - if target > #demoSteps then return end - jumpToBeforeStep(target) - highlightOverride = target - refreshStepList() - end) - - -- Find the section that the current position belongs to. A - -- section is "current" while completedUpTo < lastStep, which - -- treats end-of-section-N as "at section N+1's header" for - -- non-last sections, and "in the last section" for the final. - local function findCurrentSectionIdx() - if not DEMO_SECTIONS then return nil end - for idx, sect in ipairs(DEMO_SECTIONS) do - if completedUpTo < sect.lastStep then - return idx - end - end - return nil - end - - sectPrevBtn:SetScript("OnClick", function() - if not active then return end - local idx = findCurrentSectionIdx() - if not idx then - -- Past all sections (very end): jump to the last section's - -- header so the player can replay that section. - local last = DEMO_SECTIONS[#DEMO_SECTIONS] - if last and jumpToBeforeStep then - jumpToBeforeStep(last.firstStep) - end - return - end - local sect = DEMO_SECTIONS[idx] - if completedUpTo == sect.firstStep - 1 then - -- Already at the current section's header: go to the - -- previous section's header (or no-op if first). - if idx > 1 and jumpToBeforeStep then - jumpToBeforeStep(DEMO_SECTIONS[idx - 1].firstStep) - end - else - -- Mid section: go to the start of the current section. - if jumpToBeforeStep then - jumpToBeforeStep(sect.firstStep) - end - end - end) - - sectNextBtn:SetScript("OnClick", function() - if not active then return end - local idx = findCurrentSectionIdx() - if not idx then - -- Already past all sections: nothing to do. - return - end - if idx < #DEMO_SECTIONS then - -- Not the last section: jump to the next section's header. - if jumpToBeforeStep then - jumpToBeforeStep(DEMO_SECTIONS[idx + 1].firstStep) - end - else - -- Last section: jump to the very end of the demo. - if jumpToStep then - jumpToStep(#demoSteps) - end - end - end) - - local function startAutoPlay() - autoPlay = true - updateButtons() - if animatingIdx == 0 then - runStep(completedUpTo + 1) - end - end - - playBtn:SetScript("OnClick", function() - if not active then return end - if paused then - paused = false - updateButtons() - elseif autoPlay then - paused = true - updateButtons() - else - startAutoPlay() - end - end) - - replayBtn:SetScript("OnClick", function() - if not active then return end - -- Reset to the state shown when the demo window first opened. - -- User can then click Play to auto-run or Next to step through. - cancelInFlight() - autoPlay = false - resetDemoGameState() - completedUpTo = 0 - refreshStepList() - updateButtons() - end) - - -- Stop = full reset to as-if-just-opened state for the current - -- demo. Equivalent to switching to the same demo via the dropdown. - stopBtn:SetScript("OnClick", function() - if not active then return end - if loadDemo then loadDemo(currentDemoKey) end - end) - - updateButtons() - end -- _runDemo - _runDemo() - end diff --git a/EasyFind.toc b/EasyFind.toc index 94ec153..77743dc 100644 --- a/EasyFind.toc +++ b/EasyFind.toc @@ -12,10 +12,12 @@ Utils.lua Core.lua Database.lua +DatabaseDynamic.lua StaticLocations.lua TutorialBox.lua ChevronConfig.lua Aliases.lua +UIPins.lua UI.lua Wizard.lua Perf.lua @@ -25,4 +27,3 @@ MapTab.lua Rescaler.lua BlizzOptionsSearch.lua Options.lua -Demo.lua diff --git a/MapSearch.lua b/MapSearch.lua index 62291a6..e1c594d 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -7,24 +7,19 @@ local Utils = ns.Utils local DebugPrint = Utils.DebugPrint local pairs, ipairs, type, select = Utils.pairs, Utils.ipairs, Utils.type, Utils.select local tinsert, tsort, tconcat, tremove = Utils.tinsert, Utils.tsort, Utils.tconcat, Utils.tremove -local sfind, slower = Utils.sfind, Utils.slower +local sfind, slower, ssub = Utils.sfind, Utils.slower, Utils.ssub local mmin, mmax, mpi, mfloor = Utils.mmin, Utils.mmax, Utils.mpi, Utils.mfloor -local pcall, xpcall, tostring = Utils.pcall, Utils.xpcall, Utils.tostring -local ErrorHandler = Utils.ErrorHandler +local pcall, tostring = Utils.pcall, Utils.tostring local GOLD_COLOR = ns.GOLD_COLOR local YELLOW_HIGHLIGHT = ns.YELLOW_HIGHLIGHT -local DEFAULT_OPACITY = ns.DEFAULT_OPACITY local TOOLTIP_BORDER = ns.TOOLTIP_BORDER -local DARK_PANEL_BG = ns.DARK_PANEL_BG -local RESULT_ICON_SIZE = ns.RESULT_ICON_SIZE local CreateFrame = CreateFrame local C_Timer = C_Timer local GameTooltip = GameTooltip local GameTooltip_Hide = GameTooltip_Hide local UIParent = UIParent -local IsShiftKeyDown = IsShiftKeyDown local IsMouseButtonDown = IsMouseButtonDown local hooksecurefunc = hooksecurefunc local wipe = wipe @@ -41,13 +36,10 @@ local GetDelvesForMap = C_AreaPoiInfo and C_AreaPoiInfo.GetDelvesForMap local GetVignettes = C_VignetteInfo and C_VignetteInfo.GetVignettes local GetVignetteInfo = C_VignetteInfo and C_VignetteInfo.GetVignetteInfo local GetVignettePosition = C_VignetteInfo and C_VignetteInfo.GetVignettePosition -local GetTaxiNodesForMap = C_TaxiMap and C_TaxiMap.GetTaxiNodesForMap local SetSuperTrackedVignette = C_SuperTrack and C_SuperTrack.SetSuperTrackedVignette local GetDungeonEntrancesForMap = C_EncounterJournal and C_EncounterJournal.GetDungeonEntrancesForMap local HasUserWaypoint = C_Map.HasUserWaypoint local ClearUserWaypoint = C_Map.ClearUserWaypoint -local GetUserWaypoint = C_Map.GetUserWaypoint -local GetWorldPosFromMapPos = C_Map.GetWorldPosFromMapPos local GetMapRectOnMap = C_Map.GetMapRectOnMap local GetMapHighlightInfoAtPosition = C_Map.GetMapHighlightInfoAtPosition local GetPlayerMapPosition = C_Map.GetPlayerMapPosition @@ -63,10 +55,52 @@ local function normalizeName(name) return n end --- Copy entrance fields onto a zone result so it renders with the correct --- instance icon/category and supports preview/click via entranceX/Y. +local function GetNameLower(entry) + local nameLower = entry.nameLower + if not nameLower then + nameLower = slower(entry.name or "") + entry.nameLower = nameLower + end + return nameLower +end + +local function GetNameNorm(entry) + local nameNorm = entry.nameNorm + if not nameNorm then + nameNorm = normalizeName(entry.name or "") + entry.nameNorm = nameNorm + end + return nameNorm +end + +local function PreparePOI(entry) + if not entry then return entry end + GetNameLower(entry) + if entry.keywords and not entry.kwLower then + local kwLower = {} + for i = 1, #entry.keywords do + kwLower[i] = slower(entry.keywords[i]) + end + entry.kwLower = kwLower + end + if entry.isDungeonEntrance then + GetNameNorm(entry) + end + return entry +end + +local function PreparePOIList(entries) + for i = 1, #entries do + PreparePOI(entries[i]) + end + return entries +end + local function EnrichZoneWithEntrance(poi, entrance) poi.name = entrance.name + poi.nameLower = GetNameLower(entrance) + poi.nameNorm = GetNameNorm(entrance) + poi.kwLower = entrance.kwLower poi.entranceX = entrance.x poi.entranceY = entrance.y poi.entranceMapID = entrance.entranceMapID @@ -83,7 +117,7 @@ local function BuildEntranceLookup(entries) wipe(reuseEntranceLookup) for _, entry in ipairs(entries) do if entry.isDungeonEntrance and entry.x and entry.y then - reuseEntranceLookup[slower(entry.name)] = entry + reuseEntranceLookup[GetNameLower(entry)] = entry end end return reuseEntranceLookup @@ -316,22 +350,11 @@ function ns.GetDirectionalRotation(direction) return baseDown end -local searchFrame -- Local search bar (left) -local globalSearchFrame -- Global search bar (right) -local activeSearchFrame -- Which bar is currently active -local resultsFrame -local resultButtons = {} -local MAX_RESULTS_POOL = 50 -- pre-created button pool (scroll handles overflow) -local selectedResultIndex = 0 -local navBtnFocused = false -- true = Shift+Right moved focus to the nav pin button -local navFrame -- Keyboard capture frame for results navigation -local TEXT_WRAP_FRACTION = 0.85 -local SCROLL_CENTER_FRACTION = 0.95 local highlightFrame local indicatorFrame local waypointPin local zoneHighlightFrame -- For highlighting zones on continent maps -local isGlobalSearch = false -- Tracks which search bar triggered the current search +local isGlobalSearch = false -- Set by SelectResult when invoked in Guide mode (directOverride=false) -- so the final waypoint placed at the end of the breadcrumb teaches -- the user how to dismiss it (hover = clear), same as a global-style @@ -340,6 +363,10 @@ local pinHoverClearsOverride = nil local activePinState = nil -- {mapID, x, y, icon, category} - survives map close/reopen local mapIsMaximized = false -- Tracks WorldMapFrame maximize state for search bar repositioning local cachedWorldZones -- Built once per session by GetAllWorldZones +local worldZonePrefixIndex = {} +local worldZonePrefixSeen = {} +local worldZonePrefixReady = false +local emptyWorldZones = {} local rareTrackCache = {} -- [vignetteGUID] = rare entry, persists across scans for auto-track local rareTrackMapID = nil -- mapID the cache is valid for local rareDeadGUIDs = {} -- GUIDs confirmed dead/despawned, blocked from re-entering cache @@ -347,7 +374,6 @@ MapSearch._rareTrackCache = rareTrackCache -- exposed for DevMem diagnostics MapSearch._rareDeadGUIDs = rareDeadGUIDs -- exposed for DevMem diagnostics local efTrackedVignetteGUID = nil -- GUID we explicitly set via SetSuperTrackedVignette (rares only) --- Reusable tables for OnSearchTextChanged (wiped each call to avoid per-keystroke allocations) local reuseAllPOIs = {} local reuseZoneNames = {} local reuseExistingNames = {} @@ -358,629 +384,42 @@ local reuseFiltered = {} local reuseSearchResults = {} local reuseSearchSeen = {} local reuseSearchDuplicates = {} -local superTrackGlow -- Perimeter glow frame (far mode) -local nearTrackFrame -- Ring + arrow frame (near mode) -local waypointController -- Invisible controller that drives OnUpdate - --- MINIMAP WAYPOINT TRACKER - perimeter glow (far) + ring/arrow (near) - -local matan2, mcos, msin = math.atan2, math.cos, math.sin -local GetPlayerFacing = GetPlayerFacing -local UnitPosition = UnitPosition -local NEAR_RING_RADIUS = 28 -- pixels from minimap center to ring edge - --- Minimap yard radius: use C_Minimap.GetViewRadius() (available 11.x+) for --- exact per-frame values. Falls back to standard lookup tables --- for older clients (indoor/outdoor split, zoom 0-5). -local MINIMAP_SIZE_INDOOR = { [0]=300, [1]=240, [2]=180, [3]=120, [4]=80, [5]=50 } -local MINIMAP_SIZE_OUTDOOR = { [0]=466.67, [1]=400, [2]=333.33, [3]=266.67, [4]=200, [5]=133.33 } - -local function GetMinimapYardRadius() - if C_Minimap and C_Minimap.GetViewRadius then - return C_Minimap.GetViewRadius() - end - -- Fallback for older clients - local zoom = Minimap:GetZoom() - local isIndoors = IsIndoors and IsIndoors() - local sizeTable = isIndoors and MINIMAP_SIZE_INDOOR or MINIMAP_SIZE_OUTDOOR - return sizeTable[zoom] or sizeTable[0] -end - --- Forward declarations (defined after CreateWaypointTracker but referenced inside its OnUpdate) -local ShowSuperTrackGlow, HideSuperTrackGlow - --- Blizzard waypoint integration - we place Blizzard's native waypoint and add --- our perimeter glow on top. efPlacedWaypoint tracks whether we own the pin. +local reuseInstanceNameNorm = {} +local reuseUISearchPOIs = {} +local reuseUISearchExistingNames = {} +local reuseUISearchZoneNames = {} +local reuseUISearchInstanceNameNorm = {} +local reuseUISearchFiltered = {} +local reuseUISearchResults = {} +local reuseUISearchResultData = {} +local UI_MAP_RESULT_CAP = 60 local efPlacedWaypoint = false -local DEFAULT_ARRIVAL_DISTANCE = 10 -local function GetArrivalDistance() - return EasyFind.db.arrivalDistance or DEFAULT_ARRIVAL_DISTANCE -end - --- Cached waypoint data (refreshed on USER_WAYPOINT_UPDATED, not every frame) -local cachedWPMapID -local cachedWPWorldX, cachedWPWorldY -- world-space position (same space as UnitPosition) -local cachedPlayerVec -- reusable Vector2D for one-time waypoint conversion -local cachedCrossContinent -- true when pin and player are on different continents -local cachedIsNavWaypoint -- true when tracking an intermediate nav step (portal, boat) -local cachedAngle = 0 -local cachedDist = 0 -local cachedViewRadius = 300 -local cachedRotateMinimap = GetCVar("rotateMinimap") == "1" - --- Capture map coordinates when a Blizzard POI pin is clicked for tracking. --- The hook fires synchronously during the click, so cursor is on the pin. -local cachedPinMapID, cachedPinX, cachedPinY -if C_SuperTrack and C_SuperTrack.SetSuperTrackedMapPin then - hooksecurefunc(C_SuperTrack, "SetSuperTrackedMapPin", function() - local sc = WorldMapFrame and WorldMapFrame.ScrollContainer - if sc and sc.GetNormalizedCursorPosition and WorldMapFrame:IsShown() then - local x, y = sc:GetNormalizedCursorPosition() - if x and y and x >= 0 and x <= 1 and y >= 0 and y <= 1 then - cachedPinMapID = WorldMapFrame:GetMapID() - cachedPinX = x - cachedPinY = y - end - end - -- Invalidate cached waypoint so resolution picks up the new pin - cachedWPWorldX = nil - cachedWPWorldY = nil - cachedCrossContinent = nil - cachedIsNavWaypoint = nil - if superTrackGlow and superTrackGlow:IsShown() then - superTrackGlow.animGroup:Stop() - superTrackGlow:Hide() - end - end) -end -MapSearch._debugGetPinCache = function() return cachedPinMapID, cachedPinX, cachedPinY end - -local function CreateWaypointTracker() - -- Controller: invisible frame that runs the shared OnUpdate - if not waypointController then - waypointController = CreateFrame("Frame", nil, Minimap) - waypointController:SetSize(1, 1) - waypointController:Hide() - end - - -- Perimeter glow (shown behind Blizzard's supertrack arrow when waypoint is far). - -- The minimap perimeter arrow is a native C++ element (not a Lua frame), - -- so we position a centered glow at the same perimeter point. - if not superTrackGlow then - local glowSize = 48 - superTrackGlow = CreateFrame("Frame", "EasyFindMinimapGlow", MinimapCluster) - superTrackGlow:SetSize(glowSize, glowSize) - superTrackGlow:SetFrameStrata("HIGH") - superTrackGlow:SetFrameLevel(100) - - -- Centered star glow (radially symmetric, no rotation needed) - local glow = superTrackGlow:CreateTexture(nil, "ARTWORK") - glow:SetSize(glowSize * 1.5, glowSize * 1.5) - glow:SetPoint("CENTER") - glow:SetTexture(STAR_GLOW_TEXTURE) - glow:SetVertexColor(YELLOW_HIGHLIGHT[1], YELLOW_HIGHLIGHT[2], YELLOW_HIGHLIGHT[3], 0.7) - glow:SetBlendMode("ADD") - superTrackGlow.glow = glow - - local ag = superTrackGlow:CreateAnimationGroup() - ag:SetLooping("BOUNCE") - local alpha = ag:CreateAnimation("Alpha") - alpha:SetFromAlpha(1) - alpha:SetToAlpha(0.4) - alpha:SetDuration(0.6) - superTrackGlow.animGroup = ag - superTrackGlow:Hide() - end - - -- Near-track ring + directional arrow (shown when waypoint is on the minimap) - if not nearTrackFrame then - nearTrackFrame = CreateFrame("Frame", "EasyFindNearTrack", Minimap) - nearTrackFrame:SetAllPoints() - nearTrackFrame:SetFrameStrata("HIGH") - nearTrackFrame:SetFrameLevel(100) - - local ringSize = NEAR_RING_RADIUS * 2 + 6 - local ringLayers = {} - for i = 1, 2 do - 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:SetBlendMode("ADD") - layer:SetVertexColor(1, 1, 0.3, 1) - ringLayers[i] = layer - end - nearTrackFrame.ring = ringLayers[1] - nearTrackFrame.ringLayers = ringLayers - nearTrackFrame.ringBaseSize = ringSize - - local ag = nearTrackFrame:CreateAnimationGroup() - ag:SetLooping("BOUNCE") - local alpha = ag:CreateAnimation("Alpha") - alpha:SetFromAlpha(1) - alpha:SetToAlpha(0.6) - alpha:SetDuration(0.6) - nearTrackFrame.animGroup = ag - - -- Pulsing glow anchored to the waypoint pin position (shown during shrink) - local pinGlow = nearTrackFrame:CreateTexture(nil, "ARTWORK") - pinGlow:SetSize(36, 36) - pinGlow:SetTexture(STAR_GLOW_TEXTURE) - pinGlow:SetVertexColor(YELLOW_HIGHLIGHT[1], YELLOW_HIGHLIGHT[2], YELLOW_HIGHLIGHT[3], 0.7) - pinGlow:SetBlendMode("ADD") - pinGlow:Hide() - nearTrackFrame.pinGlow = pinGlow - - local pinGlowAG = nearTrackFrame:CreateAnimationGroup() - pinGlowAG:SetLooping("BOUNCE") - local pinGlowAlpha = pinGlowAG:CreateAnimation("Alpha") - pinGlowAlpha:SetFromAlpha(1) - pinGlowAlpha:SetToAlpha(0.4) - pinGlowAlpha:SetDuration(ANIM_DURATION) - nearTrackFrame.pinGlowAG = pinGlowAG - - nearTrackFrame:Hide() - end - - -- OnUpdate: calculate angle + distance, show perimeter glow when far, ring when near, auto-clear on arrival - -- Uses UnitPosition (returns primitives) for zero per-frame allocations. - -- Waypoint world position cached until USER_WAYPOINT_UPDATED fires. - -- Wrapped in pcall so a crash self-cancels instead of spamming errors every frame. - local function WaypointOnUpdate(self, elapsed) - local hasUserWP = HasUserWaypoint() and C_SuperTrack.IsSuperTrackingUserWaypoint() - local hasContentTrack = not hasUserWP - and C_SuperTrack.IsSuperTrackingAnything - and C_SuperTrack.IsSuperTrackingAnything() - - -- Skip quest tracking: no reliable API for quest objective minimap angle - if not hasUserWP and (not hasContentTrack - or (C_SuperTrack.IsSuperTrackingQuest and C_SuperTrack.IsSuperTrackingQuest())) then - if self.lastMode then - self.lastMode = nil - end - HideSuperTrackGlow() - return - end - -- Resolve waypoint world position. Cached for static targets (user waypoints), - -- re-resolved every frame for moving targets (rares we're super-tracking). - local trackingOurRare = efTrackedVignetteGUID - and C_SuperTrack.GetSuperTrackedVignette - and C_SuperTrack.GetSuperTrackedVignette() == efTrackedVignetteGUID - if trackingOurRare then - cachedWPWorldX = nil - end - if not cachedWPWorldX then - local wpMapID, wpX, wpY - if hasUserWP then - local wp = GetUserWaypoint() - if wp and wp.position then - wpMapID = wp.uiMapID - wpX = wp.position.x - wpY = wp.position.y - end - else - local playerMapID = GetBestMapForUnit("player") - -- Try vignette position (quartermasters, dungeon entrances, etc.) - if C_SuperTrack.GetSuperTrackedVignette and GetVignettePosition then - local vigGUID = C_SuperTrack.GetSuperTrackedVignette() - if vigGUID and playerMapID then - local pos = GetVignettePosition(vigGUID, playerMapID) - if pos then - wpMapID = playerMapID - wpX = pos.x - wpY = pos.y - end - end - end - -- Fallback: taxi node position by nodeID or name match. - -- Prefer cachedPinMapID (the map viewed when clicked) over - -- playerMapID, and reject matches with out-of-bounds coords. - if not wpX and C_SuperTrack.GetSuperTrackedMapPin and GetTaxiNodesForMap then - local pinType, pinID = C_SuperTrack.GetSuperTrackedMapPin() - if pinType == 2 and pinID then - local trackedName = C_SuperTrack.GetSuperTrackedItemName and C_SuperTrack.GetSuperTrackedItemName() - local lookupMap = cachedPinMapID or playerMapID - if lookupMap then - local nodes = GetTaxiNodesForMap(lookupMap) - if nodes then - for ni = 1, #nodes do - local node = nodes[ni] - if node.position and (node.nodeID == pinID or (trackedName and node.name and trackedName:find(node.name, 1, true))) then - local nx, ny = node.position.x, node.position.y - if nx >= 0 and nx <= 1 and ny >= 0 and ny <= 1 then - wpMapID = lookupMap - wpX = nx - wpY = ny - break - end - end - end - end - end - end - end - -- Fallback: area POI from tracked map pin - if not wpX and C_SuperTrack.GetSuperTrackedMapPin and GetAreaPOIInfo then - local _, poiID = C_SuperTrack.GetSuperTrackedMapPin() - if poiID then - -- Try with playerMapID first (nil mapID often returns 0,0) - local poiInfo = playerMapID and GetAreaPOIInfo(playerMapID, poiID) - if not poiInfo or not poiInfo.position or (poiInfo.position.x == 0 and poiInfo.position.y == 0) then - poiInfo = GetAreaPOIInfo(nil, poiID) - end - if poiInfo and poiInfo.position and (poiInfo.position.x ~= 0 or poiInfo.position.y ~= 0) then - wpMapID = poiInfo.uiMapID or playerMapID - wpX = poiInfo.position.x - wpY = poiInfo.position.y - end - end - end - -- Fallback: super-tracked waypoint (content tracking) - if not wpX and C_SuperTrack.GetNextWaypointForMap and playerMapID then - local qX, qY = C_SuperTrack.GetNextWaypointForMap(playerMapID) - if qX and qY and (qX ~= 0 or qY ~= 0) then - wpMapID = playerMapID - wpX = qX - wpY = qY - end - end - -- Fallback: cursor capture from world map click - if not wpX and cachedPinMapID and cachedPinX then - wpMapID = cachedPinMapID - wpX = cachedPinX - wpY = cachedPinY - end - end - if not wpMapID or not wpX then - HideSuperTrackGlow() - return - end - - local pMapID = GetBestMapForUnit("player") - - -- Prefer intermediate nav waypoint (portal, boat, etc.) when the - -- game's navigation mesh routes through one. Walk up the map - -- hierarchy (zone → parent → continent) because the waypoint may - -- be on a different zone than the player. - cachedIsNavWaypoint = false - if pMapID and C_SuperTrack.GetNextWaypointForMap then - local navMapID, navX, navY - local tryMap = pMapID - for _ = 1, 5 do - if not tryMap then break end - local nx, ny = C_SuperTrack.GetNextWaypointForMap(tryMap) - if nx and ny and (nx ~= 0 or ny ~= 0) - and nx >= 0 and nx <= 1 and ny >= 0 and ny <= 1 then - navMapID = tryMap - navX = nx - navY = ny - break - end - local info = GetMapInfo(tryMap) - tryMap = info and info.parentMapID - end - if navMapID and navX then - -- Only use nav waypoint if it differs from the actual destination - local dx = navX - (wpX or 0) - local dy = navY - (wpY or 0) - local isSameMap = wpMapID == navMapID - if not isSameMap or (dx * dx + dy * dy) > 0.001 then - wpMapID = navMapID - wpX = navX - wpY = navY - cachedIsNavWaypoint = true - end - end - end - - -- Convert to world space - cachedWPMapID = wpMapID - if not cachedPlayerVec then - cachedPlayerVec = CreateVector2D(wpX, wpY) - else - cachedPlayerVec.x = wpX - cachedPlayerVec.y = wpY - end - local pinContinent, wWorld = GetWorldPosFromMapPos(cachedWPMapID, cachedPlayerVec) - if not wWorld then return end - - -- Cross-continent check: world coords are in different spaces - if pMapID then - cachedPlayerVec.x = 0.5 - cachedPlayerVec.y = 0.5 - local playerContinent = GetWorldPosFromMapPos(pMapID, cachedPlayerVec) - if playerContinent ~= pinContinent then - cachedCrossContinent = true - return - end - end - - cachedCrossContinent = false - cachedWPWorldX = wWorld.x - cachedWPWorldY = wWorld.y - end - - if cachedCrossContinent then - HideSuperTrackGlow() - return - end - - -- UnitPosition returns (posY, posX) as primitives (zero allocation) - -- posY corresponds to C_Map world .x, posX corresponds to .y - local upY, upX = UnitPosition("player") - if not upY then return end - - local dx = upY - cachedWPWorldX - local dy = upX - cachedWPWorldY - cachedAngle = matan2(dy, -dx) - - if cachedRotateMinimap then - local facing = GetPlayerFacing() - if facing then - cachedAngle = cachedAngle - facing - end - end - - local rawDist = C_Navigation and C_Navigation.GetDistance and C_Navigation.GetDistance() - if rawDist and rawDist > 0 then - cachedDist = rawDist - end - cachedViewRadius = GetMinimapYardRadius() - - local angle = cachedAngle - local dist = cachedDist - local viewRadius = cachedViewRadius - - -- C_Navigation returns 0 before the engine processes a fresh waypoint - if dist <= 0 then return end - - if EasyFind.db.autoPinClear ~= false and dist < GetArrivalDistance() then - -- Arrived at an intermediate nav waypoint (portal, boat): re-resolve - -- to the next step instead of clearing the actual destination. - if cachedIsNavWaypoint then - cachedWPWorldX = nil - cachedWPWorldY = nil - cachedCrossContinent = nil - cachedIsNavWaypoint = false - return - end - if efPlacedWaypoint then - MapSearch:ClearAll() - return - elseif hasUserWP then - HideSuperTrackGlow() - C_SuperTrack.SetSuperTrackedUserWaypoint(false) - ClearUserWaypoint() - return - end - -- Content tracking (quests, etc.): hide visuals but don't untrack - HideSuperTrackGlow() - if nearTrackFrame:IsShown() then - nearTrackFrame.animGroup:Stop() - nearTrackFrame:Hide() - end - return - end - - -- Minimap pin becomes visible at 75% of the view radius - local showCircle = EasyFind.db.minimapGuideCircle ~= false - if showCircle and EasyFind.db.circleOnlyEasyFind and not efPlacedWaypoint then - showCircle = false - end - local showGlow = EasyFind.db.minimapArrowGlow ~= false - if showGlow and EasyFind.db.glowOnlyEasyFind and not efPlacedWaypoint then - showGlow = false - end - if dist < viewRadius * 0.75 then - -- NEAR MODE: ring around player (Blizzard's pin is visible on minimap) - if not self.lastMode or self.lastMode ~= "NEAR" then - self.lastMode = "NEAR" - end - if superTrackGlow:IsShown() then - superTrackGlow.animGroup:Stop() - superTrackGlow:Hide() - end - if showCircle then - if not nearTrackFrame:IsShown() then - nearTrackFrame:Show() - nearTrackFrame.animGroup:Play() - end - elseif nearTrackFrame:IsShown() then - nearTrackFrame.animGroup:Stop() - nearTrackFrame:Hide() - end - if showCircle then - local rot = -angle - - local minimapPxRadius = Minimap:GetWidth() / 2 - local pixelDist = (dist / viewRadius) * minimapPxRadius - local userScale = EasyFind.db.guideCircleScale or 1.0 - local baseSize = nearTrackFrame.ringBaseSize * userScale - local arrowTipPx = baseSize * 0.5 - local pinHalf = 6 - local shrinkThreshold = arrowTipPx + pinHalf - local shrinking = pixelDist < shrinkThreshold - local scale = 1 - if shrinking then - scale = pixelDist / shrinkThreshold - if scale < 0.15 then scale = 0.15 end - end - local sz = baseSize * scale - - for _, layer in ipairs(nearTrackFrame.ringLayers) do - layer:SetRotation(rot) - layer:SetSize(sz, sz) - end - - -- Pulsing glow on the pin once the ring starts shrinking - local pinGlow = nearTrackFrame.pinGlow - local showPinGlow = EasyFind.db.minimapPinGlow ~= false - if showPinGlow and shrinking then - local pinX = msin(angle) * pixelDist - local pinY = mcos(angle) * pixelDist - pinGlow:ClearAllPoints() - pinGlow:SetPoint("CENTER", Minimap, "CENTER", pinX, pinY) - if not pinGlow:IsShown() then - pinGlow:Show() - nearTrackFrame.pinGlowAG:Play() - end - elseif pinGlow:IsShown() then - nearTrackFrame.pinGlowAG:Stop() - pinGlow:Hide() - end - end - else - -- FAR MODE: perimeter glow - if not self.lastMode or self.lastMode ~= "FAR" then - self.lastMode = "FAR" - end - if nearTrackFrame:IsShown() then - nearTrackFrame.animGroup:Stop() - nearTrackFrame:Hide() - end - if showGlow then - if not superTrackGlow:IsShown() then - superTrackGlow:Show() - superTrackGlow.animGroup:Play() - end - -- Pull glow slightly inward so it sits over the arrow body - local perimeterRadius = Minimap:GetWidth() / 2 - 5 - local glowX = msin(angle) * perimeterRadius - local glowY = mcos(angle) * perimeterRadius - superTrackGlow:ClearAllPoints() - superTrackGlow:SetPoint("CENTER", Minimap, "CENTER", glowX, glowY) - elseif superTrackGlow:IsShown() then - superTrackGlow.animGroup:Stop() - superTrackGlow:Hide() - end - end - end - - waypointController:SetScript("OnUpdate", function(self, elapsed) - local ok, err = xpcall(WaypointOnUpdate, ErrorHandler, self, elapsed) - if not ok then - self:SetScript("OnUpdate", nil) - DebugPrint("Waypoint tracker stopped: " .. tostring(err)) - end - end) -end - -ShowSuperTrackGlow = function() - if EasyFind.db.enableMapSearch == false then return end - CreateWaypointTracker() - waypointController:Show() -end - -HideSuperTrackGlow = function() - if waypointController then - waypointController:Hide() - end - if superTrackGlow and superTrackGlow:IsShown() then - superTrackGlow.animGroup:Stop() - superTrackGlow:Hide() - end - if nearTrackFrame and nearTrackFrame:IsShown() then - nearTrackFrame.animGroup:Stop() - nearTrackFrame:Hide() - end -end -MapSearch.HideSuperTrackGlow = HideSuperTrackGlow - --- Auto-clear everything after a loading screen (teleport, hearthstone, portal, etc.) local loadingScreenFrame = CreateFrame("Frame") loadingScreenFrame:RegisterEvent("PLAYER_ENTERING_WORLD") loadingScreenFrame:RegisterEvent("USER_WAYPOINT_UPDATED") loadingScreenFrame:RegisterEvent("SUPER_TRACKING_CHANGED") loadingScreenFrame:RegisterEvent("NAVIGATION_DESTINATION_REACHED") -loadingScreenFrame:RegisterEvent("SUPER_TRACKING_PATH_UPDATED") -loadingScreenFrame:RegisterEvent("CVAR_UPDATE") -loadingScreenFrame:RegisterEvent("VIGNETTES_UPDATED") loadingScreenFrame:SetScript("OnEvent", function(_, event, isInitialLogin, isReloadingUI) - if event == "VIGNETTES_UPDATED" then - return - end - if event == "CVAR_UPDATE" then - -- isInitialLogin is repurposed here as the cvar name arg - if isInitialLogin == "rotateMinimap" then - cachedRotateMinimap = GetCVar("rotateMinimap") == "1" - end - return - end if event == "NAVIGATION_DESTINATION_REACHED" then - -- If the user waypoint is on a different map than the player, this - -- is an intermediate arrival (portal, boat), not the final destination. - -- Re-resolve instead of clearing, regardless of cachedIsNavWaypoint - -- (which can be cleared by earlier events in the same frame). - if HasUserWaypoint() then - local wp = GetUserWaypoint() - local pMapID = GetBestMapForUnit("player") - if wp and wp.uiMapID and pMapID and wp.uiMapID ~= pMapID then - cachedWPMapID = nil - cachedWPWorldX = nil - cachedWPWorldY = nil - cachedCrossContinent = nil - cachedIsNavWaypoint = nil - if superTrackGlow and superTrackGlow:IsShown() then - superTrackGlow.animGroup:Stop() - superTrackGlow:Hide() - end - return - end - end if EasyFind.db.autoPinClear == false then return end if efPlacedWaypoint then MapSearch:ClearAll() elseif HasUserWaypoint() then - HideSuperTrackGlow() C_SuperTrack.SetSuperTrackedUserWaypoint(false) ClearUserWaypoint() - else - HideSuperTrackGlow() - if nearTrackFrame and nearTrackFrame:IsShown() then - nearTrackFrame.animGroup:Stop() - nearTrackFrame:Hide() - end end return end - if event == "SUPER_TRACKING_PATH_UPDATED" then - -- Invalidate cached position so OnUpdate re-resolves (nav path changed, - -- e.g. after passing through a portal to a new zone) - cachedWPMapID = nil - cachedWPWorldX = nil - cachedWPWorldY = nil - cachedCrossContinent = nil - cachedIsNavWaypoint = nil - return - end + if event == "USER_WAYPOINT_UPDATED" or event == "SUPER_TRACKING_CHANGED" then - -- Invalidate cached waypoint so OnUpdate re-reads fresh coords - cachedWPMapID = nil - cachedWPWorldX = nil - cachedWPWorldY = nil - cachedCrossContinent = nil - -- Clear stale rare tracking GUID when tracking target changes local currentVig = C_SuperTrack.GetSuperTrackedVignette and C_SuperTrack.GetSuperTrackedVignette() if efTrackedVignetteGUID and currentVig ~= efTrackedVignetteGUID then efTrackedVignetteGUID = nil end - -- Hide stale glow so it doesn't linger at the old target's position - if superTrackGlow and superTrackGlow:IsShown() then - superTrackGlow.animGroup:Stop() - superTrackGlow:Hide() - end - cachedIsNavWaypoint = nil - -- Auto-track user waypoint when placed and not yet tracked if EasyFind.db.enableMapSearch ~= false and EasyFind.db.autoTrackPins ~= false and HasUserWaypoint() and not C_SuperTrack.IsSuperTrackingUserWaypoint() then - -- USER_WAYPOINT_UPDATED: user explicitly placed a new pin, always track it. - -- SUPER_TRACKING_CHANGED: only reclaim if nothing else is actively tracked. if event == "USER_WAYPOINT_UPDATED" then - -- Clear stale POI pin cache so zone check uses the waypoint's map - cachedPinMapID = nil - cachedPinX = nil - cachedPinY = nil C_SuperTrack.SetSuperTrackedUserWaypoint(true) return end @@ -992,57 +431,22 @@ loadingScreenFrame:SetScript("OnEvent", function(_, event, isInitialLogin, isRel return end end - if HasUserWaypoint() and C_SuperTrack.IsSuperTrackingUserWaypoint() then - ShowSuperTrackGlow() - elseif C_SuperTrack.IsSuperTrackingAnything - and C_SuperTrack.IsSuperTrackingAnything() then - if not HasUserWaypoint() then - efPlacedWaypoint = false - end - ShowSuperTrackGlow() - else - if not HasUserWaypoint() then - efPlacedWaypoint = false - end - HideSuperTrackGlow() + if not HasUserWaypoint() then + efPlacedWaypoint = false end return end - -- PLAYER_ENTERING_WORLD + if isInitialLogin or isReloadingUI then - C_Timer.After(0, function() - if HasUserWaypoint() and C_SuperTrack.IsSuperTrackingUserWaypoint() then - ShowSuperTrackGlow() - elseif C_SuperTrack.IsSuperTrackingAnything and C_SuperTrack.IsSuperTrackingAnything() then - efPlacedWaypoint = false - ShowSuperTrackGlow() - end - end) return end - -- Defer slightly so the map system has settled C_Timer.After(0, function() if ns.MapSearch then ns.MapSearch:ClearAll() ns.MapSearch:ClearZoneHighlight() end if ns.Highlight then ns.Highlight:ClearAll() end - -- Invalidate stale cached position from the previous zone - cachedWPMapID = nil - cachedWPWorldX = nil - cachedWPWorldY = nil - cachedCrossContinent = nil - cachedIsNavWaypoint = nil - cachedPinMapID = nil - cachedPinX = nil - cachedPinY = nil - -- Restart glow if a waypoint is still being tracked after the loading screen - if HasUserWaypoint() and C_SuperTrack.IsSuperTrackingUserWaypoint() then - ShowSuperTrackGlow() - elseif C_SuperTrack.IsSuperTrackingAnything and C_SuperTrack.IsSuperTrackingAnything() then - ShowSuperTrackGlow() - end end) end) @@ -1106,39 +510,6 @@ MapSearch.IsMapItemPinned = function(_, data) return IsMapItemPinned(data) end MapSearch.PinMapItem = function(_, data) return PinMapItem(data) end MapSearch.UnpinMapItem = function(_, data) return UnpinMapItem(data) end --- Simple pin context popup (BOTTOMLEFT anchored at cursor so it opens above) -local pinPopup -local function ShowPinPopup(btn, isPinned, onAction) - if not pinPopup then - pinPopup = CreateFrame("Button", "EasyFindMapPinPopup", UIParent, "BackdropTemplate") - pinPopup:SetSize(60, 22) - pinPopup:SetFrameStrata("TOOLTIP") - pinPopup:SetFrameLevel(10000) - pinPopup:SetBackdrop({ - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", - edgeFile = TOOLTIP_BORDER, - tile = true, tileSize = 16, edgeSize = 12, - insets = { left = 2, right = 2, top = 2, bottom = 2 } - }) - pinPopup:SetBackdropColor(DARK_PANEL_BG[1], DARK_PANEL_BG[2], DARK_PANEL_BG[3], DARK_PANEL_BG[4]) - local label = pinPopup:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - label:SetPoint("CENTER") - pinPopup.label = label - pinPopup:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD") - pinPopup:SetScript("OnLeave", function(self) self:Hide() end) - end - pinPopup.label:SetText(isPinned and "Unpin" or "Pin") - pinPopup:SetScript("OnClick", function(self) - self:Hide() - onAction() - end) - local scale = UIParent:GetEffectiveScale() - local x, y = GetCursorPosition() - pinPopup:ClearAllPoints() - pinPopup:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", x / scale, y / scale) - pinPopup:Show() -end - -- Category icons mapping local CATEGORY_ICONS = { flightmaster = "atlas:TaxiNode_Neutral", @@ -1232,23 +603,6 @@ ns.MapSearch.GetCategoryIcon = GetCategoryIcon -- function above CATEGORIES would silently treat the name as a global -- and every parent-based lookup would return nil). --- Helper: set a texture to a file path, fileDataID, atlas (prefixed "atlas:"), or --- cropped table { file = , coords = { L, R, T, B } }. --- Resets texture state first to prevent texCoord/atlas bleed between rows. -local function SetIconTexture(textureObj, icon) - textureObj:SetTexture(nil) - textureObj:SetTexCoord(0, 1, 0, 1) - if type(icon) == "table" then - textureObj:SetTexture(icon.file) - local c = icon.coords - textureObj:SetTexCoord(c[1], c[2], c[3], c[4]) - elseif type(icon) == "string" and sfind(icon, "^atlas:") then - textureObj:SetAtlas(icon:sub(7)) - else - textureObj:SetTexture(icon) - end -end - -- Category definitions with hierarchy local CATEGORIES = { travel = { keywords = {"travel", "transport", "transportation", "getting around"} }, @@ -1292,8 +646,6 @@ local CATEGORIES = { lorewalker = { keywords = {"lorewalker", "cho", "lore walker", "pandaria lore", "flashback", "replay cinematic"}, parent = "service" }, } --- Classify a result into the MapTab unified-filter bucket. Mirrors --- the logic inside BuildResults' local/global filter checks. local function GetFilterBucket(data) if not data then return "other" end -- Category takes priority over isZone so dungeons/raids/delves that @@ -1318,6 +670,11 @@ local function GetFilterBucket(data) end ns.MapSearch.GetFilterBucket = GetFilterBucket +local function MapTabFlightPathsEnabled() + local filters = EasyFind and EasyFind.db and EasyFind.db.mapTabFilters + return filters and filters.flightpath ~= false +end + -- Categories allowed in global (cross-zone) search results. -- Everything else (services, travel, etc.) is excluded to keep global results clean. local GLOBAL_SEARCH_CATEGORIES = { @@ -1331,18 +688,13 @@ local GLOBAL_SEARCH_CATEGORIES = { local STATIC_LOCATIONS = ns.STATIC_LOCATIONS or {} function MapSearch:Initialize() - -- Floating search bars (searchFrame / globalSearchFrame) and the floating - -- results dropdown (resultsFrame) are deprecated; MapTab is the new - -- front-end. We only create waypoint / highlight / world-map-hook - -- machinery, which MapTab, HandleUISearchClick, and rare tracking all - -- still depend on. self:CreateHighlightFrame() self:CreateZoneHighlightFrame() self:HookWorldMap() + self:BuildWorldZoneCache() end --- SHARED FILTER DROPDOWN BUILDER - creates a tracking-menu-style checkbox panel -function MapSearch:CreateFilterDropdown(globalName, options, dbKey, toggleBtn, anchorFrame, searchEditBox, onChanged) +function MapSearch:CreateFilterDropdown(globalName, options, dbKey, toggleBtn, anchorFrame, onChanged) local ROW_HEIGHT = 20 local DROPDOWN_WIDTH = 207 local PADDING_TOP = 8 @@ -1395,1394 +747,94 @@ function MapSearch:CreateFilterDropdown(globalName, options, dbKey, toggleBtn, a -- Label local label = row:CreateFontString(nil, "ARTWORK", "GameFontHighlight") label:SetPoint("LEFT", row:GetNormalTexture(), "RIGHT", 4, 0) - label:SetText(opt.label) - - -- Highlight on hover - local highlight = row:CreateTexture(nil, "HIGHLIGHT") - highlight:SetAllPoints() - highlight:SetColorTexture(1, 1, 1, 0.1) - - -- Keyboard focus highlight (separate from mouse hover) - local kbHighlight = row:CreateTexture(nil, "BACKGROUND") - kbHighlight:SetAllPoints() - kbHighlight:SetColorTexture(1, 1, 1, 0.1) - kbHighlight:Hide() - row.kbHighlight = kbHighlight - - -- Start checked - row:SetChecked(true) - - row:SetScript("OnClick", function(self) - local filters = EasyFind.db[dbKey] - filters[opt.key] = self:GetChecked() - if searchEditBox and searchEditBox.GetText and searchEditBox:GetText() ~= "" then - MapSearch:OnSearchTextChanged(searchEditBox:GetText()) - end - if onChanged then onChanged(opt.key, self:GetChecked()) end - end) - - checkRows[opt.key] = row - checkRowsByIndex[i] = row - end - - dropdown.rows = checkRowsByIndex - dropdown.selectedRow = 0 - - function dropdown:SetSelectedRow(idx) - self.selectedRow = idx - for ri = 1, #checkRowsByIndex do - checkRowsByIndex[ri].kbHighlight:SetShown(ri == idx) - end - end - - function dropdown:ToggleSelectedRow() - local row = checkRowsByIndex[self.selectedRow] - if row then - -- Click triggers OnSearchTextChanged which can kill keyboard state. - -- Preserve and restore it so the user can keep toggling. - local savedKb = navFrame and navFrame:IsKeyboardEnabled() - row:Click() - if savedKb and navFrame then Utils.SafeCallMethod(navFrame, "EnableKeyboard", true) end - end - end - - local totalHeight = PADDING_TOP + HEADER_HEIGHT + #options * ROW_HEIGHT + PADDING_BOTTOM - dropdown:SetSize(DROPDOWN_WIDTH, totalHeight) - - -- Sync checkmarks to saved state on show - dropdown:SetScript("OnShow", function(self) - local filters = EasyFind.db[dbKey] - for key, row in pairs(checkRows) do - row:SetChecked(filters[key] ~= false) - end - self:SetSelectedRow(self.keyboardOpen and 1 or 0) - self.keyboardOpen = nil - end) - - dropdown:SetScript("OnHide", function(self) - self:SetSelectedRow(0) - if self.restoreToolbar then - self.restoreToolbar() - self.restoreToolbar = nil - end - end) - - -- Close when clicking outside - dropdown:SetScript("OnUpdate", function(self) - if self:IsShown() and IsMouseButtonDown("LeftButton") then - if not self:IsMouseOver() and not toggleBtn:IsMouseOver() then - self:Hide() - end - end - end) - - -- Toggle on filter button click; position using screen coordinates - -- so scale differences between UIParent and the search bar don't cause gaps - toggleBtn:SetScript("OnClick", function(self) - if dropdown:IsShown() then - dropdown:Hide() - else - local scale = anchorFrame:GetEffectiveScale() / UIParent:GetEffectiveScale() - local right = anchorFrame:GetRight() * scale - local bottom = anchorFrame:GetBottom() * scale - dropdown:ClearAllPoints() - dropdown:SetPoint("TOPRIGHT", UIParent, "BOTTOMLEFT", right, bottom) - dropdown:Show() - end - end) - - return dropdown -end - -function MapSearch:CreateSearchFrame() - -- LOCAL search bar (left side - searches current map's child zones + POIs) - searchFrame = CreateFrame("Frame", "EasyFindMapSearchFrame", WorldMapFrame, "BackdropTemplate") - searchFrame:SetSize(250, ns.SEARCHBAR_HEIGHT) - searchFrame:SetFrameStrata("DIALOG") - searchFrame:SetFrameLevel(9999) - searchFrame:SetMovable(true) - searchFrame:EnableMouse(true) - searchFrame:SetToplevel(true) - - local yOff = EasyFind.db.mapSearchYOffset or 0 - if EasyFind.db.mapSearchPosition then - searchFrame:SetPoint("TOPLEFT", WorldMapFrame.ScrollContainer, "BOTTOMLEFT", EasyFind.db.mapSearchPosition, yOff) - else - searchFrame:SetPoint("TOPLEFT", WorldMapFrame.ScrollContainer, "BOTTOMLEFT", 0, yOff) - end - - ns.CreateSearchBorder(searchFrame) - searchFrame:SetBackdrop(nil) - ns.SetSearchBorderShown(searchFrame, true) - ns.SetSearchBorderBgAlpha(searchFrame, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) - searchFrame:SetClipsChildren(true) - - -- Draggable with Shift key (constrained to map edge) - searchFrame:RegisterForDrag("LeftButton") - searchFrame:SetScript("OnDragStart", function(self) - if IsShiftKeyDown() then - self.isDragging = true - self.dragStartX = select(4, self:GetPoint()) or 0 - end - end) - searchFrame:SetScript("OnDragStop", function(self) - self.isDragging = false - end) - searchFrame:SetScript("OnUpdate", function(self) - if self.isDragging and IsShiftKeyDown() then - local cursorX = GetCursorPosition() - local scale = UIParent:GetEffectiveScale() - local mapLeft = WorldMapFrame.ScrollContainer:GetLeft() * scale - local mapRight = WorldMapFrame.ScrollContainer:GetRight() * scale - local mapW = (mapRight - mapLeft) / scale - - local newX = (cursorX - mapLeft) / scale - (self:GetWidth() / 2) - - if mapIsMaximized then - -- Maximized: slide along top edge independently - local globalW = globalSearchFrame and globalSearchFrame:GetWidth() or 0 - local globalOff = EasyFind.db.globalSearchPositionMax or -150 - local globalLeftFromLeft = mapW + globalOff - globalW - local maxX = globalLeftFromLeft - self:GetWidth() - 6 - newX = mmax(0, mmin(newX, maxX)) - self:ClearAllPoints() - self:SetPoint("TOPLEFT", WorldMapFrame.ScrollContainer, "TOPLEFT", newX, -4) - EasyFind.db.mapSearchPositionMax = newX - else - -- Windowed: slide along bottom edge - local globalW = globalSearchFrame and globalSearchFrame:GetWidth() or 0 - local globalOff = EasyFind.db.globalSearchPosition or 0 - local globalLeftEdge = mapW + globalOff - globalW - local maxX = globalLeftEdge - self:GetWidth() - newX = mmax(0, mmin(newX, maxX)) - self:ClearAllPoints() - self:SetPoint("TOPLEFT", WorldMapFrame.ScrollContainer, "BOTTOMLEFT", newX, 2) - EasyFind.db.mapSearchPosition = newX - end - elseif self.isDragging then - self.isDragging = false - end - end) - - local contentSz = ns.SEARCHBAR_HEIGHT * ns.SEARCHBAR_FILL - local iconSz = contentSz * ns.SEARCHBAR_ICON_SCALE - - local localIconHolder = CreateFrame("Frame", nil, searchFrame) - localIconHolder:SetPoint("TOP", searchFrame, "TOP", 0, 0) - localIconHolder:SetPoint("BOTTOM", searchFrame, "BOTTOM", 0, 0) - localIconHolder:SetPoint("LEFT", searchFrame, "LEFT", 0, 0) - localIconHolder:SetWidth(ns.SEARCHBAR_HEIGHT) - localIconHolder:SetFrameLevel(searchFrame:GetFrameLevel() + 10) - - local searchIcon = localIconHolder:CreateTexture(nil, "OVERLAY") - searchIcon:SetSize(iconSz, iconSz) - searchIcon:SetPoint("CENTER") - searchIcon:SetAtlas("common-search-magnifyingglass") - searchIcon:SetVertexColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) - searchFrame.searchIcon = searchIcon - localIconHolder.icon = searchIcon - searchFrame.modeBtn = localIconHolder - - local editBox = CreateFrame("EditBox", "EasyFindMapSearchBox", searchFrame) - editBox:SetHeight(contentSz) - editBox:SetPoint("LEFT", localIconHolder, "RIGHT", 0, 0) - -- RIGHT anchor set below after clearBtn creation - editBox:SetFontObject(ns.SEARCHBAR_FONT) - editBox:SetAutoFocus(false) - editBox:SetMaxLetters(50) - - local placeholder = editBox:CreateFontString(nil, "ARTWORK", ns.SEARCHBAR_FONT) - placeholder:SetPoint("LEFT", 2, 0) - placeholder:SetJustifyH("LEFT") - placeholder:SetTextColor(0.5, 0.5, 0.5, 1.0) - placeholder:SetText("Search within this zone") - editBox.placeholder = placeholder - - editBox:SetScript("OnEditFocusGained", function(self) - if self.blockFocus then - self:ClearFocus() - return - end - self.placeholder:Hide() - -- Clear the other search bar when focusing this one - if globalSearchFrame and globalSearchFrame.editBox then - globalSearchFrame.editBox:SetText("") - globalSearchFrame.editBox:ClearFocus() - globalSearchFrame.editBox.placeholder:Show() - end - isGlobalSearch = false - activeSearchFrame = searchFrame - if selectedResultIndex > 0 then - selectedResultIndex = 0 - navBtnFocused = false - MapSearch:UpdateSelectionHighlight(true) - end - if self:GetText() == "" then - MapSearch:ShowPinnedItems() - end - end) - - editBox:SetScript("OnEditFocusLost", function(self) - if self:GetText() == "" then - self.placeholder:Show() - end - end) - - editBox:SetScript("OnTextChanged", function(self) - if self:GetText() ~= "" then - self.placeholder:Hide() - end - if not self:HasFocus() then return end - isGlobalSearch = false - activeSearchFrame = searchFrame - MapSearch:OnSearchTextChanged(self:GetText()) - end) - - editBox:SetScript("OnEnterPressed", function(self) - MapSearch:ActivateSelected() - end) - - editBox:SetScript("OnEscapePressed", function(self) - self:ClearFocus() - end) - - editBox:SetScript("OnKeyDown", function(self, key) - if resultsFrame and resultsFrame:IsShown() and selectedResultIndex == 0 then - if EasyFind.db.mapResultsAbove and not mapIsMaximized then - if key == "UP" then MapSearch:JumpToEnd() end - else - if key == "DOWN" then MapSearch:MoveSelection(1) end - end - end - Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) - end) - - -- Filter button (inside search bar, flush right - same as global bar) - local localFilterBtn = CreateFrame("Button", "EasyFindMapLocalFilterButton", searchFrame) - localFilterBtn:SetPoint("TOP", searchFrame, "TOP", 0, 0) - localFilterBtn:SetPoint("BOTTOM", searchFrame, "BOTTOM", 0, 0) - localFilterBtn:SetPoint("RIGHT", searchFrame, "RIGHT", 0, 0) - localFilterBtn:SetWidth(searchFrame:GetHeight()) - localFilterBtn:SetFrameLevel(searchFrame:GetFrameLevel() + 10) - - local localArrow = localFilterBtn:CreateTexture(nil, "OVERLAY") - localArrow:SetSize(11, 11) - localArrow:SetPoint("CENTER") - localArrow:SetTexture(423808) - localArrow:SetTexCoord(0.453, 0.203, 0.453, 0.016, 0.641, 0.203, 0.641, 0.016) - localArrow:SetDesaturated(true) - localArrow:SetBlendMode("ADD") - localArrow:SetVertexColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) - localFilterBtn.arrow = localArrow - - local localBtnBg = localFilterBtn:CreateTexture(nil, "ARTWORK") - localBtnBg:SetAllPoints() - localBtnBg:SetTexture(796424) - localBtnBg:Hide() - localFilterBtn.btnBg = localBtnBg - - localFilterBtn:SetHighlightTexture(130757) - - localFilterBtn:SetScript("OnEnter", function(self) - self.btnBg:Show() - GameTooltip:SetOwner(self, "ANCHOR_TOP") - GameTooltip:SetText("Filter Results") - GameTooltip:AddLine("Choose which result types to show.", 1, 1, 1, true) - GameTooltip:Show() - end) - localFilterBtn:SetScript("OnLeave", function(self) - if not self.keyboardFocused then self.btnBg:Hide() end - GameTooltip_Hide() - end) - - -- Clear button (grey circle X, matching retail quest log style) - local clearBtn = Utils.CreateClearButton(searchFrame) - clearBtn:ClearAllPoints() - clearBtn:SetPoint("RIGHT", searchFrame, "RIGHT", -32, 0) - clearBtn:SetFrameLevel(searchFrame:GetFrameLevel() + 10) - - clearBtn:SetScript("OnClick", function(self) - editBox:SetText("") - editBox:ClearFocus() - editBox.placeholder:Show() - if globalSearchFrame and globalSearchFrame.editBox then - globalSearchFrame.editBox:SetText("") - globalSearchFrame.editBox:ClearFocus() - globalSearchFrame.editBox.placeholder:Show() - end - MapSearch:HideResults() - MapSearch:ClearAll() - MapSearch:ClearZoneHighlight() - end) - clearBtn:SetScript("OnEnter", function(self) - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - GameTooltip:SetText("Clear all map icons and zone highlights") - GameTooltip:Show() - end) - clearBtn:SetScript("OnLeave", GameTooltip_Hide) - searchFrame.clearBtn = clearBtn - - -- Anchor editBox and placeholder relative to buttons so text never overlaps - editBox:SetPoint("RIGHT", clearBtn, "LEFT", -4, 0) - placeholder:SetPoint("RIGHT", localFilterBtn, "LEFT", -4, 0) - placeholder:SetWordWrap(false) - - -- Tooltip showing full placeholder when truncated and unfocused - editBox:HookScript("OnEnter", function(self) - if self:HasFocus() or not placeholder:IsShown() then return end - GameTooltip:SetOwner(searchFrame, "ANCHOR_TOP") - GameTooltip:SetText("|cFFFFD100Zone Search|r") - GameTooltip:AddLine("Right-click a result for Pin or Guide options.", 1, 1, 1, true) - GameTooltip:AddLine("Hold |cFF00FF00Shift|r and drag to reposition.", 0.7, 0.7, 0.7) - GameTooltip:Show() - end) - editBox:HookScript("OnLeave", function() - GameTooltip:Hide() - end) - editBox:HookScript("OnEditFocusGained", function() - GameTooltip:Hide() - end) - - local function UpdateLocalClearVisibility() - local hasText = editBox:GetText() ~= "" - local navActive = MapSearch.HasActiveNavigation and MapSearch:HasActiveNavigation() - clearBtn:SetShown(hasText or navActive) - end - searchFrame.UpdateClearButtonVisibility = UpdateLocalClearVisibility - editBox:HookScript("OnTextChanged", function() - UpdateLocalClearVisibility() - end) - - -- Shift-click editbox starts parent drag when not focused - editBox:HookScript("OnMouseDown", function(self) - if IsShiftKeyDown() and not self:HasFocus() then - self.blockFocus = true - searchFrame.isDragging = true - searchFrame.dragStartX = select(4, searchFrame:GetPoint()) or 0 - end - end) - editBox:HookScript("OnMouseUp", function(self) - self.blockFocus = nil - searchFrame.isDragging = false - end) - - -- Click anywhere on the search frame to focus the editbox - searchFrame:HookScript("OnMouseDown", function(self, button) - if button == "LeftButton" and not IsShiftKeyDown() then - editBox:SetFocus() - end - end) - - - -- Local filter dropdown - local LOCAL_FILTER_OPTIONS = { - { key = "instances", label = "Instances" }, - { key = "travel", label = "Travel" }, - { key = "services", label = "Services" }, - { key = "rares", label = "Rares" }, - } - - local localFilterDropdown = MapSearch:CreateFilterDropdown( - "EasyFindMapLocalFilterDropdown", LOCAL_FILTER_OPTIONS, - "localSearchFilters", localFilterBtn, searchFrame, editBox - ) - - -- "Auto-track" toggle on the Rares filter row - do - local raresRow = localFilterDropdown.rows[#LOCAL_FILTER_OPTIONS] -- last row = Rares - raresRow:SetHitRectInsets(0, 65, 0, 0) - local trackBtn = CreateFrame("Button", nil, raresRow) - trackBtn:SetSize(60, 18) - trackBtn:SetPoint("RIGHT", raresRow, "RIGHT", -2, 0) - trackBtn:SetFrameLevel(raresRow:GetFrameLevel() + 1) - local trackLabel = trackBtn:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall") - trackLabel:SetPoint("RIGHT") - trackLabel:SetText("Auto-track") - trackBtn.label = trackLabel - - local function UpdateAutoTrackColor() - if EasyFind.db.alwaysShowRares then - trackLabel:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) - else - trackLabel:SetTextColor(0.5, 0.5, 0.5) - end - end - UpdateAutoTrackColor() - - trackBtn:SetScript("OnClick", function() - EasyFind.db.alwaysShowRares = not EasyFind.db.alwaysShowRares - UpdateAutoTrackColor() - MapSearch:UpdateRareTracking() - if ns.optionsFrame and ns.optionsFrame.rareTrackCheckbox then - ns.optionsFrame.rareTrackCheckbox:SetChecked(EasyFind.db.alwaysShowRares) - end - end) - trackBtn:SetScript("OnEnter", function(self) - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - if EasyFind.db.alwaysShowRares then - GameTooltip:SetText("Auto-track: |cFF00FF00ON|r") - GameTooltip:AddLine("Active rares are always shown on the map.\nClick to disable.", 1, 1, 1, true) - else - GameTooltip:SetText("Auto-track: OFF") - GameTooltip:AddLine("Click to always show active rares on the map.", 1, 1, 1, true) - end - GameTooltip:Show() - end) - trackBtn:SetScript("OnLeave", GameTooltip_Hide) - - localFilterDropdown:HookScript("OnShow", UpdateAutoTrackColor) - - -- "Experimental" flashing tag on the Rares label - local expTag = CreateFrame("Frame", nil, localFilterDropdown) - expTag:EnableMouse(true) - expTag:SetFrameStrata("TOOLTIP") - local expText = expTag:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - expText:SetPoint("CENTER") - expText:SetText("NEW") - expText:SetTextColor(1, 1, 1) - expText:SetShadowColor(0.3, 0.9, 1.0, 1) - expText:SetShadowOffset(1, -1) - local textW = expText:GetStringWidth() + 4 - expTag:SetSize(textW, 14) - for _, region in pairs({raresRow:GetRegions()}) do - if region.GetText and region:GetText() == "Rares" then - expTag:SetPoint("LEFT", region, "RIGHT", 3, 0) - break - end - end - -- Pulsing glow behind text - local expGlow = expTag:CreateTexture(nil, "BACKGROUND") - expGlow:SetPoint("CENTER") - expGlow:SetSize(textW + 24, 24) - expGlow:SetAtlas("collections-newglow") - expGlow:SetVertexColor(0.3, 0.85, 1.0, 0.5) - expGlow:SetBlendMode("ADD") - -- Glow pulse animation (text stays at 100%, only glow pulses) - local glowPulse = expGlow:CreateAnimationGroup() - glowPulse:SetLooping("BOUNCE") - local flash = glowPulse:CreateAnimation("Alpha") - flash:SetFromAlpha(0.8) - flash:SetToAlpha(0.1) - flash:SetDuration(1.5) - glowPulse:Play() - expTag:SetScript("OnEnter", function(self) - GameTooltip:SetOwner(self, "ANCHOR_TOPLEFT", 0, 16) - GameTooltip:SetText("New Feature") - GameTooltip:AddLine("Rare mob tracking is new and still being refined. If you encounter any issues or have suggestions, please share feedback on CurseForge or GitHub.", 1, 1, 1, true) - GameTooltip:Show() - end) - expTag:SetScript("OnLeave", GameTooltip_Hide) - end - - searchFrame.filterBtn = localFilterBtn - searchFrame.filterDropdown = localFilterDropdown - - searchFrame.editBox = editBox - searchFrame:Hide() - - -- GLOBAL search bar (right side - searches all zones in the world) - globalSearchFrame = CreateFrame("Frame", "EasyFindMapGlobalSearchFrame", WorldMapFrame, "BackdropTemplate") - globalSearchFrame:SetSize(250, ns.SEARCHBAR_HEIGHT) - globalSearchFrame:SetFrameStrata("DIALOG") - globalSearchFrame:SetFrameLevel(9999) - globalSearchFrame:SetMovable(true) - globalSearchFrame:EnableMouse(true) - globalSearchFrame:SetToplevel(true) - - local gYOff = EasyFind.db.mapSearchYOffset or 0 - if EasyFind.db.globalSearchPosition then - globalSearchFrame:SetPoint("TOPRIGHT", WorldMapFrame.ScrollContainer, "BOTTOMRIGHT", EasyFind.db.globalSearchPosition, gYOff) - else - globalSearchFrame:SetPoint("TOPRIGHT", WorldMapFrame.ScrollContainer, "BOTTOMRIGHT", 0, gYOff) - end - - ns.CreateSearchBorder(globalSearchFrame) - globalSearchFrame:SetBackdrop(nil) - ns.SetSearchBorderShown(globalSearchFrame, true) - ns.SetSearchBorderBgAlpha(globalSearchFrame, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) - globalSearchFrame:SetClipsChildren(true) - - -- Draggable with Shift key (constrained to map edge) - globalSearchFrame:RegisterForDrag("LeftButton") - globalSearchFrame:SetScript("OnDragStart", function(self) - if IsShiftKeyDown() then - self.isDragging = true - end - end) - globalSearchFrame:SetScript("OnDragStop", function(self) - self.isDragging = false - end) - globalSearchFrame:SetScript("OnUpdate", function(self) - if self.isDragging and IsShiftKeyDown() then - local cursorX = GetCursorPosition() - local scale = UIParent:GetEffectiveScale() - local mapLeft = WorldMapFrame.ScrollContainer:GetLeft() * scale - local mapRight = WorldMapFrame.ScrollContainer:GetRight() * scale - local mapW = (mapRight - mapLeft) / scale - - if mapIsMaximized then - -- Maximized: slide along top edge independently (from right) - local newX = (cursorX - mapRight) / scale + (self:GetWidth() / 2) - local localOff = EasyFind.db.mapSearchPositionMax or 4 - local localW = searchFrame and searchFrame:GetWidth() or 0 - local localRightEdge = localOff + localW - local minX = localRightEdge + 6 + self:GetWidth() - mapW - newX = mmin(0, mmax(newX, minX)) - self:ClearAllPoints() - self:SetPoint("TOPRIGHT", WorldMapFrame.ScrollContainer, "TOPRIGHT", newX, -4) - EasyFind.db.globalSearchPositionMax = newX - else - -- Windowed: slide along bottom edge independently - local newX = (cursorX - mapRight) / scale + (self:GetWidth() / 2) - local localOff = EasyFind.db.mapSearchPosition or 0 - local localW = searchFrame and searchFrame:GetWidth() or 0 - local localRightEdge = localOff + localW - local minX = -(mapW - localRightEdge - self:GetWidth()) - newX = mmin(0, mmax(newX, minX)) - self:ClearAllPoints() - self:SetPoint("TOPRIGHT", WorldMapFrame.ScrollContainer, "BOTTOMRIGHT", newX, 2) - EasyFind.db.globalSearchPosition = newX - end - elseif self.isDragging then - self.isDragging = false - end - end) - - local globalIconHolder = CreateFrame("Frame", nil, globalSearchFrame) - globalIconHolder:SetPoint("TOP", globalSearchFrame, "TOP", 0, 0) - globalIconHolder:SetPoint("BOTTOM", globalSearchFrame, "BOTTOM", 0, 0) - globalIconHolder:SetPoint("LEFT", globalSearchFrame, "LEFT", 0, 0) - globalIconHolder:SetWidth(ns.SEARCHBAR_HEIGHT) - globalIconHolder:SetFrameLevel(globalSearchFrame:GetFrameLevel() + 10) - - local globalSearchIcon = globalIconHolder:CreateTexture(nil, "OVERLAY") - globalSearchIcon:SetSize(iconSz, iconSz) - globalSearchIcon:SetPoint("CENTER") - globalSearchIcon:SetAtlas("common-search-magnifyingglass") - globalSearchIcon:SetVertexColor(0.4, 0.8, 1) - globalSearchFrame.searchIcon = globalSearchIcon - globalIconHolder.icon = globalSearchIcon - globalSearchFrame.modeBtn = globalIconHolder - - local globalEditBox = CreateFrame("EditBox", "EasyFindMapGlobalSearchBox", globalSearchFrame) - globalEditBox:SetHeight(contentSz) - globalEditBox:SetPoint("LEFT", globalIconHolder, "RIGHT", 0, 0) - -- RIGHT anchor set below after globalClearBtn creation - globalEditBox:SetFontObject(ns.SEARCHBAR_FONT) - globalEditBox:SetAutoFocus(false) - globalEditBox:SetMaxLetters(50) - - local globalPlaceholder = globalEditBox:CreateFontString(nil, "ARTWORK", ns.SEARCHBAR_FONT) - globalPlaceholder:SetPoint("LEFT", 2, 0) - globalPlaceholder:SetJustifyH("LEFT") - globalPlaceholder:SetTextColor(0.5, 0.5, 0.5, 1.0) - globalPlaceholder:SetText("Search for zones/instances") - globalEditBox.placeholder = globalPlaceholder - - globalEditBox:SetScript("OnEditFocusGained", function(self) - if self.blockFocus then - self:ClearFocus() - return - end - self.placeholder:Hide() - -- Clear the other search bar when focusing this one - if searchFrame and searchFrame.editBox then - searchFrame.editBox:SetText("") - searchFrame.editBox:ClearFocus() - searchFrame.editBox.placeholder:Show() - end - isGlobalSearch = true - activeSearchFrame = globalSearchFrame - if selectedResultIndex > 0 then - selectedResultIndex = 0 - navBtnFocused = false - MapSearch:UpdateSelectionHighlight(true) - end - if self:GetText() == "" then - MapSearch:ShowPinnedItems() - end - end) - - globalEditBox:SetScript("OnEditFocusLost", function(self) - if self:GetText() == "" then - self.placeholder:Show() - end - end) - - globalEditBox:SetScript("OnTextChanged", function(self) - if self:GetText() ~= "" then - self.placeholder:Hide() - end - if not self:HasFocus() then return end - isGlobalSearch = true - activeSearchFrame = globalSearchFrame - MapSearch:OnSearchTextChanged(self:GetText()) - end) - - globalEditBox:SetScript("OnEnterPressed", function(self) - MapSearch:ActivateSelected() - end) - - globalEditBox:SetScript("OnEscapePressed", function(self) - self:ClearFocus() - end) - - globalEditBox:SetScript("OnKeyDown", function(self, key) - if resultsFrame and resultsFrame:IsShown() and selectedResultIndex == 0 then - if EasyFind.db.mapResultsAbove and not mapIsMaximized then - if key == "UP" then MapSearch:JumpToEnd() end - else - if key == "DOWN" then MapSearch:MoveSelection(1) end - end - end - Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) - end) - - -- Filter button (inside search bar, flush right) - local filterBtn = CreateFrame("Button", "EasyFindMapFilterButton", globalSearchFrame) - filterBtn:SetPoint("TOP", globalSearchFrame, "TOP", 0, 0) - filterBtn:SetPoint("BOTTOM", globalSearchFrame, "BOTTOM", 0, 0) - filterBtn:SetPoint("RIGHT", globalSearchFrame, "RIGHT", 0, 0) - filterBtn:SetWidth(globalSearchFrame:GetHeight()) - filterBtn:SetFrameLevel(globalSearchFrame:GetFrameLevel() + 10) - - local globalArrow = filterBtn:CreateTexture(nil, "OVERLAY") - globalArrow:SetSize(11, 11) - globalArrow:SetPoint("CENTER") - globalArrow:SetTexture(423808) - globalArrow:SetTexCoord(0.453, 0.203, 0.453, 0.016, 0.641, 0.203, 0.641, 0.016) - globalArrow:SetDesaturated(true) - globalArrow:SetBlendMode("ADD") - globalArrow:SetVertexColor(0.4, 0.8, 1) - filterBtn.arrow = globalArrow - - local globalBtnBg = filterBtn:CreateTexture(nil, "ARTWORK") - globalBtnBg:SetAllPoints() - globalBtnBg:SetTexture(796424) - globalBtnBg:Hide() - filterBtn.btnBg = globalBtnBg - - filterBtn:SetHighlightTexture(130757) - - filterBtn:SetScript("OnEnter", function(self) - self.btnBg:Show() - GameTooltip:SetOwner(self, "ANCHOR_TOP") - GameTooltip:SetText("Filter Results") - GameTooltip:AddLine("Choose which result types to show.", 1, 1, 1, true) - GameTooltip:Show() - end) - filterBtn:SetScript("OnLeave", function(self) - if not self.keyboardFocused then self.btnBg:Hide() end - GameTooltip_Hide() - end) - - -- Clear button for global search (grey circle X) - shifted left of filter button - local globalClearBtn = Utils.CreateClearButton(globalSearchFrame) - globalClearBtn:ClearAllPoints() - globalClearBtn:SetPoint("RIGHT", globalSearchFrame, "RIGHT", -32, 0) - globalClearBtn:SetFrameLevel(globalSearchFrame:GetFrameLevel() + 10) - - globalClearBtn:SetScript("OnClick", function(self) - globalEditBox:SetText("") - globalEditBox:ClearFocus() - globalEditBox.placeholder:Show() - if searchFrame and searchFrame.editBox then - searchFrame.editBox:SetText("") - searchFrame.editBox:ClearFocus() - searchFrame.editBox.placeholder:Show() - end - MapSearch:HideResults() - MapSearch:ClearAll() - MapSearch:ClearZoneHighlight() - end) - globalClearBtn:SetScript("OnEnter", function(self) - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - GameTooltip:SetText("Clear all map icons and zone highlights") - GameTooltip:Show() - end) - globalClearBtn:SetScript("OnLeave", GameTooltip_Hide) - globalSearchFrame.clearBtn = globalClearBtn - - -- Anchor editBox and placeholder relative to buttons - globalEditBox:SetPoint("RIGHT", globalClearBtn, "LEFT", -4, 0) - globalPlaceholder:SetPoint("RIGHT", filterBtn, "LEFT", -4, 0) - globalPlaceholder:SetWordWrap(false) - - -- Tooltip showing full placeholder when truncated and unfocused - globalEditBox:HookScript("OnEnter", function(self) - if self:HasFocus() or not globalPlaceholder:IsShown() then return end - GameTooltip:SetOwner(globalSearchFrame, "ANCHOR_TOP") - GameTooltip:SetText("|cFF66CCFFGlobal Search|r") - GameTooltip:AddLine("Right-click a result for Pin or Guide options.", 1, 1, 1, true) - GameTooltip:AddLine("Hold |cFF00FF00Shift|r and drag to reposition.", 0.7, 0.7, 0.7) - GameTooltip:Show() - end) - globalEditBox:HookScript("OnLeave", function() - GameTooltip:Hide() - end) - globalEditBox:HookScript("OnEditFocusGained", function() - GameTooltip:Hide() - end) - - local function UpdateGlobalClearVisibility() - local hasText = globalEditBox:GetText() ~= "" - local navActive = MapSearch.HasActiveNavigation and MapSearch:HasActiveNavigation() - globalClearBtn:SetShown(hasText or navActive) - end - globalSearchFrame.UpdateClearButtonVisibility = UpdateGlobalClearVisibility - globalEditBox:HookScript("OnTextChanged", function() - UpdateGlobalClearVisibility() - end) - - -- Shift-click editbox starts parent drag when not focused - globalEditBox:HookScript("OnMouseDown", function(self) - if IsShiftKeyDown() and not self:HasFocus() then - self.blockFocus = true - globalSearchFrame.isDragging = true - end - end) - globalEditBox:HookScript("OnMouseUp", function(self) - self.blockFocus = nil - globalSearchFrame.isDragging = false - end) - - -- Click anywhere on the search frame to focus the editbox - globalSearchFrame:HookScript("OnMouseDown", function(self, button) - if button == "LeftButton" and not IsShiftKeyDown() then - globalEditBox:SetFocus() - end - end) - - - globalSearchFrame.editBox = globalEditBox - globalSearchFrame:Hide() - - -- Key repeat with progressive acceleration for held arrow/tab keys - local REPEAT_INITIAL = 0.30 - local REPEAT_FAST = 0.05 - local REPEAT_ACCEL = 1.5 - local repeatAction, repeatHeld, repeatNext - local repeatFrame = CreateFrame("Frame") - - function MapSearch.StopKeyRepeat() - MapSearch.repeatKey = nil - repeatAction = nil - repeatFrame:SetScript("OnUpdate", nil) - end - - function MapSearch.StartKeyRepeat(key, action) - action() - MapSearch.repeatKey = key - repeatAction = action - repeatHeld = 0 - repeatNext = REPEAT_INITIAL - repeatFrame:SetScript("OnUpdate", function(_, elapsed) - repeatHeld = repeatHeld + elapsed - repeatNext = repeatNext - elapsed - if repeatNext <= 0 then - repeatAction() - local t = repeatHeld / REPEAT_ACCEL - if t > 1 then t = 1 end - repeatNext = REPEAT_INITIAL + (REPEAT_FAST - REPEAT_INITIAL) * t - end - end) - end - - -- Toolbar keyboard focus: 0 = editbox, 1 = clear button, 2 = filter button - local toolbarFocus = 0 - - -- Highlight frame that moves to the focused toolbar control - local toolbarHighlight = CreateFrame("Frame", nil, UIParent) - toolbarHighlight:SetFrameStrata("FULLSCREEN_DIALOG") - toolbarHighlight:SetFrameLevel(10000) - toolbarHighlight:Hide() - local tbHL = toolbarHighlight:CreateTexture(nil, "OVERLAY") - tbHL:SetAllPoints() - tbHL:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") - tbHL:SetBlendMode("ADD") - tbHL:SetVertexColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.5) - - local function GetToolbarControls() - local sf = activeSearchFrame - if not sf then return {} end - local controls = {} - if sf.clearBtn and sf.clearBtn:IsShown() then - tinsert(controls, sf.clearBtn) - end - if sf.filterBtn then - tinsert(controls, sf.filterBtn) - end - return controls - end - - local function SetToolbarFocus(idx) - -- Clear previous button state - local prevControls = GetToolbarControls() - local prevTarget = prevControls[toolbarFocus] - if prevTarget then - prevTarget.keyboardFocused = nil - if prevTarget.btnBg then prevTarget.btnBg:Hide() end - if prevTarget.UnlockHighlight then prevTarget:UnlockHighlight() end - end - toolbarFocus = idx - local controls = GetToolbarControls() - local target = controls[idx] - if target then - target.keyboardFocused = true - -- Filter buttons use their own highlight (LockHighlight + btnBg) - if target.btnBg then - target.btnBg:Show() - if target.LockHighlight then target:LockHighlight() end - toolbarHighlight:Hide() - else - toolbarHighlight:SetParent(target) - toolbarHighlight:ClearAllPoints() - toolbarHighlight:SetAllPoints(target) - toolbarHighlight:Show() - end - else - toolbarHighlight:Hide() - end - end - - local function ClearToolbarFocus() - local controls = GetToolbarControls() - local prevTarget = controls[toolbarFocus] - if prevTarget then - prevTarget.keyboardFocused = nil - if prevTarget.btnBg then prevTarget.btnBg:Hide() end - if prevTarget.UnlockHighlight then prevTarget:UnlockHighlight() end - end - toolbarFocus = 0 - toolbarHighlight:Hide() - end - MapSearch.ClearToolbarFocus = ClearToolbarFocus - - local function GetActiveDropdown() - local sf = activeSearchFrame - if sf and sf.filterDropdown and sf.filterDropdown:IsShown() then - return sf.filterDropdown - end - return nil - end - - -- Keyboard capture frame for navigating results without editbox focus - navFrame = CreateFrame("Frame", nil, searchFrame) - navFrame:SetSize(1, 1) - Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) - Utils.SafeCallMethod(navFrame, "SetPropagateKeyboardInput", false) - - local function HandleNavKeyDown(key) - if key == "LSHIFT" or key == "RSHIFT" or key == "LCTRL" or key == "RCTRL" - or key == "LALT" or key == "RALT" then return end - - local eb = activeSearchFrame and activeSearchFrame.editBox - local dropdown = GetActiveDropdown() - - -- Filter dropdown keyboard navigation takes priority - if dropdown then - if key == "DOWN" then - 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 - if dropdown.selectedRow <= 1 then - dropdown:Hide() - else - 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 - dropdown:ToggleSelectedRow() - end - elseif key == "ESCAPE" then - dropdown:Hide() - end - return - end - - -- Shift+Left/Right: nav pin toggle when in results - if IsShiftKeyDown() and key == "RIGHT" then - if selectedResultIndex > 0 then - local row = resultButtons[selectedResultIndex] - if row and row.navBtn and row.navBtn:IsShown() then - navBtnFocused = true - MapSearch:UpdateSelectionHighlight() - end - end - return - elseif IsShiftKeyDown() and key == "LEFT" then - if selectedResultIndex > 0 then - navBtnFocused = false - MapSearch:UpdateSelectionHighlight() - end - return - end - - if key == "DOWN" then - if IsControlKeyDown() then - MapSearch:JumpToEnd() - else - MapSearch.StartKeyRepeat(key, function() MapSearch:MoveSelection(1) end) - end - elseif key == "UP" then - if IsControlKeyDown() then - MapSearch:JumpToStart() - else - MapSearch.StartKeyRepeat(key, function() MapSearch:MoveSelection(-1) end) - end - elseif key == "PAGEDOWN" then - MapSearch.StartKeyRepeat(key, function() MapSearch:MoveSelection(5) end) - elseif key == "PAGEUP" then - MapSearch.StartKeyRepeat(key, function() MapSearch:MoveSelection(-5) end) - elseif key == "HOME" then - MapSearch:JumpToStart() - elseif key == "END" then - MapSearch:JumpToEnd() - elseif key == "TAB" then - if IsControlKeyDown() then - -- Ctrl+Tab: switch between local and global search bars - ClearToolbarFocus() - selectedResultIndex = 0 - Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) - if isGlobalSearch then - MapSearch:FocusLocalSearch() - else - MapSearch:FocusGlobalSearch() - end - elseif IsShiftKeyDown() then - -- Shift+Tab: in results toggles nav button off, otherwise toolbar backward - if selectedResultIndex > 0 then - navBtnFocused = false - MapSearch:UpdateSelectionHighlight() - else - local controls = GetToolbarControls() - if #controls > 0 and toolbarFocus > 0 then - local newIdx = toolbarFocus - 1 - if newIdx == 0 then - ClearToolbarFocus() - selectedResultIndex = 0 - MapSearch:UpdateSelectionHighlight() - else - SetToolbarFocus(newIdx) - end - elseif #controls > 0 then - SetToolbarFocus(#controls) - end - end - else - -- Tab: in results toggles nav button on, otherwise toolbar forward - if selectedResultIndex > 0 then - local row = resultButtons[selectedResultIndex] - if row and row.navBtn and row.navBtn:IsShown() then - navBtnFocused = true - MapSearch:UpdateSelectionHighlight() - end - elseif toolbarFocus > 0 then - local controls = GetToolbarControls() - local newIdx = toolbarFocus + 1 - if newIdx > #controls then - ClearToolbarFocus() - selectedResultIndex = 0 - MapSearch:UpdateSelectionHighlight() - else - SetToolbarFocus(newIdx) - end - end - end - elseif key == "ENTER" then - if toolbarFocus > 0 then - local controls = GetToolbarControls() - local target = controls[toolbarFocus] - if target then - -- Pre-select first row when opening filter dropdown via keyboard - local sf = activeSearchFrame - if sf and sf.filterBtn == target and sf.filterDropdown then - sf.filterDropdown.keyboardOpen = true - sf.filterDropdown.restoreToolbar = function() - if toolbarFocus > 0 then SetToolbarFocus(toolbarFocus) end - end - toolbarHighlight:Hide() - end - target:Click() - end - else - MapSearch:ActivateSelected() - end - elseif key == "ESCAPE" then - if toolbarFocus > 0 then - ClearToolbarFocus() - elseif navBtnFocused then - navBtnFocused = false - MapSearch:UpdateSelectionHighlight() - else - selectedResultIndex = 0 - navBtnFocused = false - Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) - if MapSearch.StopKeyRepeat then MapSearch.StopKeyRepeat() end - MapSearch:UpdateSelectionHighlight(true) - end - else - ClearToolbarFocus() - selectedResultIndex = 0 - MapSearch:UpdateSelectionHighlight() - if eb and not IsControlKeyDown() and not IsAltKeyDown() and #key == 1 then - local char = IsShiftKeyDown() and key or slower(key) - eb:Insert(char) - end - end - end - - navFrame:SetScript("OnKeyDown", function(self, key) - HandleNavKeyDown(key) - Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) - end) - navFrame:SetScript("OnKeyUp", function(_, key) - if MapSearch.repeatKey == key then MapSearch.StopKeyRepeat() end - end) - - -- Tab from editbox: toolbar first, then results. Ctrl+Tab: switch bars. - local function HookEditBoxKeys(eb) - eb:HookScript("OnKeyDown", function(self, key) - if key ~= "TAB" then return end - if IsControlKeyDown() then - self:ClearFocus() - if isGlobalSearch then - MapSearch:FocusLocalSearch() - else - MapSearch:FocusGlobalSearch() - end - elseif IsShiftKeyDown() then - local controls = GetToolbarControls() - if #controls > 0 then - self:ClearFocus() - Utils.SafeCallMethod(navFrame, "EnableKeyboard", true) - SetToolbarFocus(#controls) - end - else - local controls = GetToolbarControls() - if #controls > 0 then - self:ClearFocus() - Utils.SafeCallMethod(navFrame, "EnableKeyboard", true) - SetToolbarFocus(1) - elseif resultsFrame and resultsFrame:IsShown() and selectedResultIndex == 0 then - MapSearch:MoveSelection(1) - end - end - end) - end - HookEditBoxKeys(editBox) - HookEditBoxKeys(globalEditBox) - - -- Filter dropdown (styled like WoW tracking menu) - local FILTER_OPTIONS = { - { key = "zones", label = "Zones" }, - { key = "dungeons", label = "Dungeons" }, - { key = "raids", label = "Raids" }, - { key = "delves", label = "Delves" }, - } - - local filterDropdown = MapSearch:CreateFilterDropdown( - "EasyFindMapFilterDropdown", FILTER_OPTIONS, - "globalSearchFilters", filterBtn, globalSearchFrame, globalEditBox - ) - - globalSearchFrame.filterBtn = filterBtn - globalSearchFrame.filterDropdown = filterDropdown + label:SetText(opt.label) - -- Set initial active frame - activeSearchFrame = searchFrame -end + -- Highlight on hover + local highlight = row:CreateTexture(nil, "HIGHLIGHT") + highlight:SetAllPoints() + highlight:SetColorTexture(1, 1, 1, 0.1) -function MapSearch:CreateResultsFrame() - resultsFrame = CreateFrame("Frame", "EasyFindMapResultsFrame", WorldMapFrame, "BackdropTemplate") - resultsFrame:SetWidth(300) - resultsFrame:SetFrameStrata("TOOLTIP") - resultsFrame:SetFrameLevel(1001) + -- Keyboard focus highlight (separate from mouse hover) + local kbHighlight = row:CreateTexture(nil, "BACKGROUND") + kbHighlight:SetAllPoints() + kbHighlight:SetColorTexture(1, 1, 1, 0.1) + kbHighlight:Hide() + row.kbHighlight = kbHighlight - resultsFrame:SetPoint("TOPLEFT", searchFrame, "BOTTOMLEFT", 0, 2) + -- Start checked + row:SetChecked(true) - resultsFrame:SetBackdrop({ - edgeFile = TOOLTIP_BORDER, - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 }, - }) - resultsFrame:SetBackdropBorderColor(0.50, 0.48, 0.45, 1.0) - local bgTex = resultsFrame:CreateTexture(nil, "BACKGROUND", nil, -1) - bgTex:SetPoint("TOPLEFT", 4, -4) - bgTex:SetPoint("BOTTOMRIGHT", -4, 4) - bgTex:SetAtlas("QuestLog-main-background", false) - bgTex:Show() - resultsFrame:SetClipsChildren(true) - resultsFrame.bgAtlasTex = bgTex - - resultsFrame:Hide() - - -- Plain ScrollFrame for clipping + mouse wheel - local scrollFrame = CreateFrame("ScrollFrame", nil, resultsFrame) - scrollFrame:EnableMouseWheel(true) - scrollFrame:SetScript("OnMouseWheel", function(self, delta) - local range = self:GetVerticalScrollRange() - local cur = self:GetVerticalScroll() - self:SetVerticalScroll(mmax(0, mmin(range, cur - delta * 72))) - end) - resultsFrame.scrollFrame = scrollFrame + row:SetScript("OnClick", function(self) + local filters = EasyFind.db[dbKey] + filters[opt.key] = self:GetChecked() + if onChanged then onChanged(opt.key, self:GetChecked()) end + end) - local scrollChild = CreateFrame("Frame", nil, scrollFrame) - scrollFrame:SetScrollChild(scrollChild) - resultsFrame.scrollChild = scrollChild + checkRows[opt.key] = row + checkRowsByIndex[i] = row + end - -- Minimal retail-style scrollbar (overlays right edge, no content squish) - resultsFrame.scrollBar = ns.Utils.CreateMinimalScrollBar(scrollFrame, resultsFrame) + dropdown.rows = checkRowsByIndex + dropdown.selectedRow = 0 - for i = 1, MAX_RESULTS_POOL do - local resultRow = self:CreateResultButton(i) - resultButtons[i] = resultRow + function dropdown:SetSelectedRow(idx) + self.selectedRow = idx + for ri = 1, #checkRowsByIndex do + checkRowsByIndex[ri].kbHighlight:SetShown(ri == idx) + end end -end --- Indent line color for map search grouped results -local MAP_INDENT_COLOR = {0.40, 0.85, 1.00, 0.70} -- cyan - -function MapSearch:CreateResultButton(index) - local scrollChild = resultsFrame.scrollChild - local resultRow = CreateFrame("Button", "EasyFindMapResultButton"..index, scrollChild) - resultRow:SetSize(280, 24) - -- No fixed SetPoint here; ShowResults positions dynamically - - -- Vertical indent line for grouped children - local indentLine = resultRow:CreateTexture(nil, "BACKGROUND") - indentLine:SetColorTexture(MAP_INDENT_COLOR[1], MAP_INDENT_COLOR[2], MAP_INDENT_COLOR[3], MAP_INDENT_COLOR[4]) - indentLine:SetWidth(2) - indentLine:SetPoint("TOP", resultRow, "TOPLEFT", 14, 2) - indentLine:SetPoint("BOTTOM", resultRow, "BOTTOMLEFT", 14, -2) - indentLine:Hide() - resultRow.indentLine = indentLine - - local icon = resultRow:CreateTexture(nil, "ARTWORK") - icon:SetSize(RESULT_ICON_SIZE, RESULT_ICON_SIZE) - icon:SetPoint("LEFT", 5, 0) - resultRow.icon = icon - - -- Highlight only covers text area (right of icon) so icons stay crisp - resultRow:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD") - local hl = resultRow:GetHighlightTexture() - hl:ClearAllPoints() - hl:SetPoint("LEFT", icon, "RIGHT", 2, 0) - hl:SetPoint("RIGHT", resultRow, "RIGHT", 0, 0) - hl:SetPoint("TOP", resultRow, "TOP", 0, 0) - hl:SetPoint("BOTTOM", resultRow, "BOTTOM", 0, 0) - - -- Keyboard selection highlight (persistent, gold-tinted) - local selHL = resultRow:CreateTexture(nil, "ARTWORK", nil, 1) - selHL:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") - selHL:SetBlendMode("ADD") - selHL:SetVertexColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.4) - selHL:SetAllPoints(hl) - selHL:Hide() - resultRow.selectionHighlight = selHL - - -- Secondary text for path prefix (shown above/before main text in gray) - local prefixText = resultRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - prefixText:SetPoint("LEFT", icon, "RIGHT", 6, 0) - prefixText:SetTextColor(0.5, 0.5, 0.5) - prefixText:SetJustifyH("LEFT") - prefixText:Hide() - resultRow.prefixText = prefixText - - local text = resultRow:CreateFontString(nil, "OVERLAY", "GameFontNormal") - text:SetPoint("LEFT", icon, "RIGHT", 6, 0) - text:SetPoint("RIGHT", resultRow, "RIGHT", -5, 0) - text:SetJustifyH("LEFT") - resultRow.text = text - - -- Pin indicator (small map pin icon, shown for pinned items) - local pinIcon = resultRow:CreateTexture(nil, "OVERLAY") - pinIcon:SetSize(10, 10) - pinIcon:SetPoint("BOTTOMLEFT", icon, "BOTTOMRIGHT", -4, -1) - pinIcon:SetAtlas("Waypoint-MapPin-ChatIcon") - pinIcon:Hide() - resultRow.pinIcon = pinIcon - - -- Pin header elements (collapse toggle + gold separator line) - local pinToggle = resultRow:CreateTexture(nil, "OVERLAY") - pinToggle:SetSize(14, 14) - pinToggle:SetPoint("RIGHT", -4, 0) - pinToggle:Hide() - resultRow.pinToggle = pinToggle - - local pinHeaderLine = resultRow:CreateTexture(nil, "OVERLAY") - pinHeaderLine:SetHeight(1) - pinHeaderLine:SetPoint("BOTTOMLEFT", 4, 0) - pinHeaderLine:SetPoint("BOTTOMRIGHT", -4, 0) - pinHeaderLine:SetColorTexture(1.0, 0.82, 0.0, 0.4) - pinHeaderLine:Hide() - resultRow.pinHeaderLine = pinHeaderLine - - -- Navigate button - shortcut: select result + auto-set waypoint in one click - local navBtn = CreateFrame("Button", nil, resultRow) - navBtn:SetSize(24, 24) - navBtn:SetPoint("RIGHT", resultRow, "RIGHT", -2, 0) - local navTex = navBtn:CreateTexture(nil, "ARTWORK") - navTex:SetSize(22, 22) - navTex:SetPoint("CENTER") - navTex:SetAtlas("Waypoint-MapPin-Untracked") - navBtn.texture = navTex - navBtn:SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square", "ADD") - navBtn:SetScript("OnEnter", function(self) - -- Show preview pinned to the nearest instance (since that's where navigate will go) - local data = resultRow.data - if data then - local coords = MapSearch:GetPreviewCoords(data) - if coords then - MapSearch._savedPinState = activePinState - MapSearch._previewing = true - if coords.instances then - local nearest = MapSearch:GetNearestInstance(coords.instances, WorldMapFrame:GetMapID()) - if nearest then - MapSearch:ShowWaypointAt(nearest.x, nearest.y, nil, nearest.category) - end - elseif coords.pin and coords.pin:IsShown() then - MapSearch:HighlightPin(coords.pin, coords.x, coords.y, coords.icon, coords.category) - else - MapSearch:ShowWaypointAt(coords.x, coords.y, coords.icon, coords.category) - end - activePinState = MapSearch._savedPinState - end + function dropdown:ToggleSelectedRow() + local row = checkRowsByIndex[self.selectedRow] + if row then + row:Click() end - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - if self.disabled then - GameTooltip:SetText("Navigate", 0.5, 0.5, 0.5) - GameTooltip:AddLine("Only available when viewing your current zone", 0.6, 0.6, 0.6) - else - GameTooltip:SetText("Navigate") - GameTooltip:AddLine("Place waypoint and track on minimap", 0.6, 0.6, 0.6) + end + + local totalHeight = PADDING_TOP + HEADER_HEIGHT + #options * ROW_HEIGHT + PADDING_BOTTOM + dropdown:SetSize(DROPDOWN_WIDTH, totalHeight) + + -- Sync checkmarks to saved state on show + dropdown:SetScript("OnShow", function(self) + local filters = EasyFind.db[dbKey] + for key, row in pairs(checkRows) do + row:SetChecked(filters[key] ~= false) end - GameTooltip:Show() + self:SetSelectedRow(self.keyboardOpen and 1 or 0) + self.keyboardOpen = nil end) - navBtn:SetScript("OnLeave", function(self) - GameTooltip_Hide() - if not MapSearch._previewing then return end - MapSearch._previewing = nil - MapSearch:ClearHighlight() - local saved = MapSearch._savedPinState - MapSearch._savedPinState = nil - if saved and saved.mapID == WorldMapFrame:GetMapID() then - if saved.instances then - MapSearch:ShowMultipleWaypoints(saved.instances) - else - MapSearch:ShowWaypointAt(saved.x, saved.y, saved.icon, saved.category) - end + + dropdown:SetScript("OnHide", function(self) + self:SetSelectedRow(0) + if self.restoreToolbar then + self.restoreToolbar() + self.restoreToolbar = nil end end) - navBtn:SetScript("OnClick", function(self) - if self.disabled then return end - local data = resultRow.data - if not data then return end - - -- Flag: auto-track once the pin is placed by ShowWaypointAt - MapSearch.autoTrackNextPin = true - MapSearch:SelectResult(data) - end) - navBtn:Hide() - resultRow.navBtn = navBtn - - -- Keyboard focus highlight for nav button (Shift+Right in results) - local navBtnHL = navBtn:CreateTexture(nil, "BACKGROUND") - navBtnHL:SetAllPoints() - navBtnHL:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") - navBtnHL:SetBlendMode("ADD") - navBtnHL:SetVertexColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.6) - navBtnHL:Hide() - resultRow.navBtnHighlight = navBtnHL - - resultRow:RegisterForClicks("LeftButtonUp", "RightButtonUp") - resultRow:SetScript("OnClick", function(self, mouseButton) - if self.data and self.data.isPinHeader then - EasyFind.db.mapPinsCollapsed = not EasyFind.db.mapPinsCollapsed - local editBox = activeSearchFrame and activeSearchFrame.editBox - local text = editBox and editBox:GetText() or "" - if text == "" and editBox and editBox:HasFocus() then - MapSearch:ShowPinnedItems() - else - MapSearch:OnSearchTextChanged(text) + + -- Close when clicking outside + dropdown:SetScript("OnUpdate", function(self) + if self:IsShown() and IsMouseButtonDown("LeftButton") then + if not self:IsMouseOver() and not toggleBtn:IsMouseOver() then + self:Hide() end - return - end - if mouseButton == "RightButton" and self.data then - local pinData = self.data - local isPinned = IsMapItemPinned(pinData) - ShowPinPopup(self, isPinned, function() - if isPinned then - UnpinMapItem(pinData) - else - PinMapItem(pinData) - end - local editBox = activeSearchFrame and activeSearchFrame.editBox - local text = editBox and editBox:GetText() or "" - if text == "" and editBox and editBox:HasFocus() then - MapSearch:ShowPinnedItems() - else - MapSearch:OnSearchTextChanged(text) - end - end) - return end - MapSearch:SelectResult(self.data) end) - -- Hover preview: show pin at result coordinates while hovering - resultRow:SetScript("OnEnter", function(self) - local data = self.data - if not data then return end - -- Only preview results with coordinates on the current map - local coords = MapSearch:GetPreviewCoords(data) - if not coords then return end - -- Save current pin state before previewing - MapSearch._savedPinState = activePinState - MapSearch._previewing = true - if coords.instances then - MapSearch:ShowMultipleWaypoints(coords.instances) - elseif coords.pin and coords.pin:IsShown() then - -- Glow the live native pin in place. coords.x/y/icon/category - -- are forwarded so HighlightPin can fall back to ShowWaypointAt - -- if the pin disappears mid-hover. - MapSearch:HighlightPin(coords.pin, coords.x, coords.y, coords.icon, coords.category) + toggleBtn:SetScript("OnClick", function(self) + if dropdown:IsShown() then + dropdown:Hide() else - MapSearch:ShowWaypointAt(coords.x, coords.y, coords.icon, coords.category) - end - -- Mark as preview so it doesn't become the persistent state - activePinState = MapSearch._savedPinState - end) - resultRow:SetScript("OnLeave", function(self) - if MapSearch._demoHoverLock then return end - if not MapSearch._previewing then return end - -- Mouse moved to a child frame (navBtn, etc.) - still hovering - if self:IsMouseOver() then return end - MapSearch._previewing = nil - -- Clear the preview pin - MapSearch:ClearHighlight() - -- Restore the previously active pin if it was on this map - local saved = MapSearch._savedPinState - MapSearch._savedPinState = nil - if saved and saved.mapID == WorldMapFrame:GetMapID() then - if saved.instances then - MapSearch:ShowMultipleWaypoints(saved.instances) - else - MapSearch:ShowWaypointAt(saved.x, saved.y, saved.icon, saved.category) - end + local scale = anchorFrame:GetEffectiveScale() / UIParent:GetEffectiveScale() + local right = anchorFrame:GetRight() * scale + local bottom = anchorFrame:GetBottom() * scale + dropdown:ClearAllPoints() + dropdown:SetPoint("TOPRIGHT", UIParent, "BOTTOMLEFT", right, bottom) + dropdown:Show() end end) - resultRow:Hide() - return resultRow + return dropdown end -- Resize highlight border textures in canvas units so they match the UI search @@ -2922,7 +974,6 @@ function MapSearch:CreateHighlightFrame() SetUserWaypoint(UiMapPoint.CreateFromCoordinates(viewingMapID, x, y)) C_SuperTrack.SetSuperTrackedUserWaypoint(true) efPlacedWaypoint = true - ShowSuperTrackGlow() MapSearch:RefreshAllClearButtons() end end @@ -3015,7 +1066,7 @@ end -- Get direct child zones only (1 level deep) for local search function MapSearch:GetDirectChildZones(mapID) - mapID = mapID or WorldMapFrame:GetMapID() + mapID = mapID or (WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID()) if not mapID then return {} end local zones = {} @@ -3218,44 +1269,95 @@ local function CachePut(cache, query, value) cache.lastQuery = query end +local function AddWorldZonePrefix(zone, prefix) + if worldZonePrefixSeen[prefix] == zone then return end + worldZonePrefixSeen[prefix] = zone + local bucket = worldZonePrefixIndex[prefix] + if not bucket then + bucket = {} + worldZonePrefixIndex[prefix] = bucket + end + bucket[#bucket + 1] = zone +end + +local function IndexWorldZoneText(zone, text) + if not text then return end + for word in text:gmatch("%S+") do + local len = #word + if len >= 1 then AddWorldZonePrefix(zone, ssub(word, 1, 1)) end + if len >= 2 then AddWorldZonePrefix(zone, ssub(word, 1, 2)) end + end +end + +local function BuildWorldZonePrefixIndex(zones) + wipe(worldZonePrefixIndex) + wipe(worldZonePrefixSeen) + for i = 1, #zones do + local zone = zones[i] + zone.nameLower = zone.nameLower or slower(zone.name) + IndexWorldZoneText(zone, zone.nameLower) + end + for abbrev, target in pairs(ZONE_ABBREVIATIONS) do + for i = 1, #zones do + local zone = zones[i] + if zone.nameLower == target then + AddWorldZonePrefix(zone, abbrev) + break + end + end + end + wipe(worldZonePrefixSeen) + worldZonePrefixReady = true +end + +function MapSearch:BuildWorldZoneCache() + if cachedWorldZones then return cachedWorldZones end + + local worldPath = {{mapID = 946, name = "World"}} + local zones = {} + local cosmicChildren = GetMapChildrenInfo(946, nil, false) + if cosmicChildren then + for _, child in ipairs(cosmicChildren) do + if child.name then + tinsert(zones, { + mapID = child.mapID, + name = child.name, + mapType = child.mapType, + parentMapID = 946, + parentName = "World", + path = worldPath, + depth = 0 + }) + end + local worldZones = self:GetAllWorldZones(child.mapID, 0, worldPath) + for _, z in ipairs(worldZones) do + tinsert(zones, z) + end + end + end + cachedWorldZones = zones + BuildWorldZonePrefixIndex(zones) + return zones +end + function MapSearch:SearchZones(query) if not query or query == "" then return {} end query = slower(query) local zones + local candidates if isGlobalSearch then - if cachedWorldZones then - zones = cachedWorldZones - else - local worldPath = {{mapID = 946, name = "World"}} - zones = {} - local cosmicChildren = GetMapChildrenInfo(946, nil, false) - if cosmicChildren then - for _, child in ipairs(cosmicChildren) do - if child.name then - tinsert(zones, { - mapID = child.mapID, - name = child.name, - mapType = child.mapType, - parentMapID = 946, - parentName = "World", - path = worldPath, - depth = 0 - }) - end - local worldZones = self:GetAllWorldZones(child.mapID, 0, worldPath) - for _, z in ipairs(worldZones) do - tinsert(zones, z) - end - end - end - cachedWorldZones = zones - end + zones = self:BuildWorldZoneCache() + if not worldZonePrefixReady then BuildWorldZonePrefixIndex(zones) end + candidates = worldZonePrefixIndex[ssub(query, 1, 2)] + or worldZonePrefixIndex[ssub(query, 1, 1)] + or emptyWorldZones else -- Local: only direct children of current map zones = self:GetDirectChildZones() + candidates = zones end -- Query cache: exact-hit returns cached results (covers backspace @@ -3268,7 +1370,6 @@ function MapSearch:SearchZones(query) cache.lastQuery = query return cachedHit end - local candidates = zones if cache.lastQuery ~= "" and #query > #cache.lastQuery and query:sub(1, #cache.lastQuery) == cache.lastQuery then @@ -3325,88 +1426,82 @@ end -- Group zone matches by their FULL parent path for clean display -- ONLY groups zones when multiple search results share the EXACT SAME parent path -function MapSearch:GroupZonesByParent(zones) - -- Build a path string key for each zone's parent - local function getPathKey(zone) - if zone.path and #zone.path > 0 then - local parts = {} - for _, p in ipairs(zone.path) do - tinsert(parts, tostring(p.mapID)) - end - return tconcat(parts, ">") - end - return tostring(zone.parentMapID or 0) +local zoneGroupPool = {} +local zoneGroupPoolN = 0 +local zoneGroupByKey = {} +local zoneGroupResults = {} +local zonePathParts = {} + +local function ZoneNameLess(a, b) + return a.name < b.name +end + +local function ZoneGroupLess(a, b) + return (a.parentPath or "") < (b.parentPath or "") +end + +local function EnsureZoneGroupFields(zone) + if zone.parentPathKey then return end + local path = zone.path + if path and #path > 0 then + wipe(zonePathParts) + for i = 1, #path do zonePathParts[i] = tostring(path[i].mapID) end + zone.parentPathKey = tconcat(zonePathParts, ">", 1, #path) + wipe(zonePathParts) + for i = 1, #path do zonePathParts[i] = path[i].name end + zone.parentPathDisplay = tconcat(zonePathParts, " > ", 1, #path) + zone.parentPathMapID = path[#path].mapID + else + zone.parentPathKey = tostring(zone.parentMapID or 0) + zone.parentPathDisplay = zone.parentName or "" + zone.parentPathMapID = zone.parentMapID end +end - local function getPathDisplay(zone) - if zone.path and #zone.path > 0 then - local parts = {} - for _, p in ipairs(zone.path) do - tinsert(parts, p.name) - end - return tconcat(parts, " > ") - end - return zone.parentName or "" +local function GetZoneGroup() + zoneGroupPoolN = zoneGroupPoolN + 1 + local group = zoneGroupPool[zoneGroupPoolN] + if not group then + group = { zones = {} } + zoneGroupPool[zoneGroupPoolN] = group + else + wipe(group.zones) end + group.parentMapID = nil + group.parentPath = nil + group.isGrouped = nil + return group +end - -- First pass: build path display and parent mapIDs - local pathDisplay = {} - local pathParentMapID = {} +function MapSearch:GroupZonesByParent(zones) + zoneGroupPoolN = 0 + wipe(zoneGroupByKey) + wipe(zoneGroupResults) - for _, zone in ipairs(zones) do - local pathKey = getPathKey(zone) - if not pathDisplay[pathKey] then - pathDisplay[pathKey] = getPathDisplay(zone) - -- Get the last mapID in the path for navigation - if zone.path and #zone.path > 0 then - pathParentMapID[pathKey] = zone.path[#zone.path].mapID - else - pathParentMapID[pathKey] = zone.parentMapID - end + for i = 1, #zones do + local zone = zones[i] + EnsureZoneGroupFields(zone) + local key = zone.parentPathKey + local group = zoneGroupByKey[key] + if not group then + group = GetZoneGroup() + group.parentMapID = zone.parentPathMapID + group.parentPath = zone.parentPathDisplay + zoneGroupByKey[key] = group + zoneGroupResults[#zoneGroupResults + 1] = group end + group.zones[#group.zones + 1] = zone end - -- Second pass: build result list - local result = {} - local processedPaths = {} - - for _, zone in ipairs(zones) do - local pathKey = getPathKey(zone) - - if not processedPaths[pathKey] then - processedPaths[pathKey] = true - - -- Collect all zones with this same parent path - local groupZones = {} - for _, z in ipairs(zones) do - if getPathKey(z) == pathKey then - tinsert(groupZones, z) - end - end - - -- Sort zones within the group alphabetically - tsort(groupZones, function(a, b) - return a.name < b.name - end) - - -- Only create a grouped header if there are 2+ zones with the same parent - local isGrouped = #groupZones >= 2 - - tinsert(result, { - parentMapID = pathParentMapID[pathKey], - parentPath = pathDisplay[pathKey], - zones = groupZones, - isGrouped = isGrouped - }) - end + for i = 1, #zoneGroupResults do + local group = zoneGroupResults[i] + local count = #group.zones + if count > 1 then tsort(group.zones, ZoneNameLess) end + group.isGrouped = count >= 2 end - -- Sort groups by their parent path alphabetically so related items appear together - tsort(result, function(a, b) - return (a.parentPath or "") < (b.parentPath or "") - end) - - return result + tsort(zoneGroupResults, ZoneGroupLess) + return zoneGroupResults end -- Walk up the parent chain to find the continent a map belongs to @@ -4751,90 +2846,6 @@ function MapSearch:IsOnContinentMap() return mapInfo.mapType == Enum.UIMapType.Continent or mapInfo.mapType == Enum.UIMapType.World end --- Map Smart Show: per-bar fade in/out on hover -local function SmartShowFadeIn(frame) - if frame.smartShowTimer then frame.smartShowTimer:Cancel(); frame.smartShowTimer = nil end - if not frame.smartShowVisible then - frame.smartShowVisible = true - UIFrameFadeIn(frame, 0.15, frame:GetAlpha(), 1) - end -end - -local function SmartShowFadeOut(frame) - if frame.editBox:HasFocus() then return end - if resultsFrame and resultsFrame:IsShown() then return end - if frame.smartShowTimer then frame.smartShowTimer:Cancel() end - frame.smartShowTimer = C_Timer.NewTimer(0.4, function() - frame.smartShowTimer = nil - if frame.editBox:HasFocus() then return end - if resultsFrame and resultsFrame:IsShown() then return end - if frame:IsMouseOver() then return end - frame.smartShowVisible = false - UIFrameFadeOut(frame, 0.25, frame:GetAlpha(), 0) - end) -end - -local function HookSmartShowChild(child, frame) - child:HookScript("OnEnter", function() - if EasyFind.db.mapSmartShow then SmartShowFadeIn(frame) end - end) - child:HookScript("OnLeave", function() - if EasyFind.db.mapSmartShow then SmartShowFadeOut(frame) end - end) -end - -local function HookMapSmartShow(frame) - frame.smartShowVisible = true - frame:HookScript("OnEnter", function() - if EasyFind.db.mapSmartShow then SmartShowFadeIn(frame) end - end) - frame:HookScript("OnLeave", function() - if EasyFind.db.mapSmartShow then SmartShowFadeOut(frame) end - end) - frame.editBox:HookScript("OnEditFocusGained", function() - if EasyFind.db.mapSmartShow then SmartShowFadeIn(frame) end - end) - frame.editBox:HookScript("OnEditFocusLost", function() - if not EasyFind.db.mapSmartShow then return end - if frame:IsMouseOver() then return end - frame.smartShowVisible = false - UIFrameFadeOut(frame, 0.25, frame:GetAlpha(), 0) - end) - -- Child buttons intercept mouse events and don't propagate OnEnter/OnLeave - -- to the parent frame, so hook each one explicitly. - HookSmartShowChild(frame.modeBtn, frame) - HookSmartShowChild(frame.editBox, frame) - HookSmartShowChild(frame.clearBtn, frame) - HookSmartShowChild(frame.filterBtn, frame) -end - -function MapSearch:UpdateMapSmartShow() - if not searchFrame then return end - local frames = { searchFrame, globalSearchFrame } - for i = 1, #frames do - local frame = frames[i] - if EasyFind.db.mapSmartShow then - frame.smartShowVisible = false - frame:SetAlpha(0) - else - if frame.smartShowTimer then frame.smartShowTimer:Cancel(); frame.smartShowTimer = nil end - frame.smartShowVisible = true - frame:SetAlpha(1) - end - end -end - -function MapSearch:UpdateHideMaximized() - if not searchFrame then return end - if mapIsMaximized and EasyFind.db.hideSearchBarsMaximized then - searchFrame:Hide() - if globalSearchFrame then globalSearchFrame:Hide() end - elseif mapIsMaximized then - searchFrame:Show() - if globalSearchFrame then globalSearchFrame:Show() end - end -end - function MapSearch:HookWorldMap() WorldMapFrame:HookScript("OnShow", function() -- Restore pins only if the player is in the pin's zone. @@ -4872,9 +2883,6 @@ function MapSearch:HookWorldMap() self.pendingWaypoint = nil end) - -- Track maximized state so breadcrumb repositioning and waypoint - -- placement logic can branch on it, even though we no longer have - -- floating bars to move. if WorldMapFrame.IsMaximized then local function OnMapModeChange() mapIsMaximized = WorldMapFrame:IsMaximized() @@ -4992,23 +3000,13 @@ function MapSearch:HookWorldMap() end end) - -- Live-refresh search results when rares spawn or despawn nearby + -- Live-refresh always-on rare pins when rares spawn or despawn nearby. local vignetteFrame = CreateFrame("Frame") vignetteFrame:RegisterEvent("VIGNETTES_UPDATED") vignetteFrame:SetScript("OnEvent", function() -- Don't wipe a hover preview; the refresh will happen when - -- the preview ends or the user types more. + -- the preview ends or the user interacts again. if MapSearch._previewing then return end - if isGlobalSearch then return end - local editBox = searchFrame and searchFrame.editBox - if editBox and editBox:HasFocus() then - local text = editBox:GetText() - if text and #text >= 2 then - MapSearch:OnSearchTextChanged(text) - return - end - end - -- Refresh always-on rare pins when not actively searching if EasyFind.db.alwaysShowRares then MapSearch:UpdateRareTracking() end @@ -5186,7 +3184,7 @@ function MapSearch:ScanDungeonEntrances(mapID) if abbrs then for ai = 1, #abbrs do kw[#kw + 1] = abbrs[ai] end end - tinsert(results, { + local entry = { name = entrance.name, category = cat, icon = nil, -- use category icon @@ -5197,7 +3195,8 @@ function MapSearch:ScanDungeonEntrances(mapID) y = ey, pathPrefix = parentLabel, keywords = kw, - }) + } + tinsert(results, PreparePOI(entry)) end -- Pass 1: scan the zone directly; exclude entrances owned by a different zone @@ -5245,6 +3244,8 @@ end -- Lazily-built cache of dungeon/raid/delve entrances across the entire world. -- Built once on first global search, then reused. local globalInstanceCache +local cachedAllFlightMasters +local ResetSearchPoisCache -- Invalidate map caches when Blizzard signals the map/world state may -- have become richer. Without this, a first-search call runs before @@ -5252,12 +3253,30 @@ local globalInstanceCache -- partial; later searches reuse the stale cache and miss descendant -- zones (e.g. Northrend's children) entirely. local localScanCache = nil -local LOCAL_SCAN_TTL = 1.0 +local staticLocationCache = {} +local emptyStaticLocations = {} +local emptyFlightMasters = {} -- Cache of promoted instance POIs (zone-style entries with breadcrumb -- paths) so BuildResults doesn't allocate ~300 new tables per keystroke. -- Built once when globalInstanceCache + cachedWorldZones are ready, -- invalidated alongside them. local promotedInstancePOIs = nil + +local function ReleaseGlobalMapCaches() + globalInstanceCache = nil + promotedInstancePOIs = nil + cachedAllFlightMasters = nil + MapSearch._cachedFlightMasters = nil + if ResetSearchPoisCache then ResetSearchPoisCache() end +end + +local function CollectMapGarbage() + if collectgarbage then + collectgarbage("step", 300) + collectgarbage("step", 300) + end +end + do local invalidator = CreateFrame("Frame") invalidator:RegisterEvent("PLAYER_ENTERING_WORLD") @@ -5265,14 +3284,25 @@ do invalidator:RegisterEvent("CHALLENGE_MODE_MAPS_UPDATE") invalidator:SetScript("OnEvent", function() cachedWorldZones = nil + wipe(worldZonePrefixIndex) + wipe(worldZonePrefixSeen) + worldZonePrefixReady = false globalInstanceCache = nil promotedInstancePOIs = nil localScanCache = nil + wipe(staticLocationCache) -- Use namespace lookups for reset helpers defined further down -- in the file; locals declared after this invalidator block -- aren't visible to its closure. if ns.MapSearch.ResetSearchZonesCache then ns.MapSearch.ResetSearchZonesCache() end if ns.MapSearch.ResetSearchPoisCache then ns.MapSearch.ResetSearchPoisCache() end + if C_Timer and C_Timer.After then + C_Timer.After(0.2, function() + if ns.MapSearch.BuildWorldZoneCache then ns.MapSearch:BuildWorldZoneCache() end + end) + elseif ns.MapSearch.BuildWorldZoneCache then + ns.MapSearch:BuildWorldZoneCache() + end end) end @@ -5285,6 +3315,7 @@ end do local function FlushLocalCaches() localScanCache = nil + wipe(staticLocationCache) if ns.MapSearch.ResetSearchZonesCache then ns.MapSearch.ResetSearchZonesCache() end if ns.MapSearch.ResetSearchPoisCache then ns.MapSearch.ResetSearchPoisCache() end end @@ -5310,20 +3341,21 @@ end -- 1-second TTL is long enough to coalesce a typing burst but short -- enough that a freshly-placed pin shows up on the user's next natural -- pause. -local function GetLocalScans(self) - local mapID = WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID() or 0 - local now = GetTime() +local function GetLocalScans(self, mapID) + mapID = mapID or (WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID()) or 0 + local includeFlightMasters = MapTabFlightPathsEnabled() local cache = localScanCache - if cache and cache.mapID == mapID and (now - cache.ts) < LOCAL_SCAN_TTL then + if cache and cache.mapID == mapID and cache.includeFlightMasters == includeFlightMasters then return cache.dynamicPOIs, cache.dungeonEntrances, cache.flightMasters, cache.vignetteRares end - local dynamicPOIs = self:ScanMapPOIs() - local dungeonEntrances = self:ScanDungeonEntrances() - local flightMasters = self:ScanFlightMasters() - local vignetteRares = self:ScanVignettes() + local dynamicPOIs = self:ScanMapPOIs(mapID) + local dungeonEntrances = self:ScanDungeonEntrances(mapID) + local flightMasters = includeFlightMasters and self:ScanFlightMasters(mapID) or emptyFlightMasters + local vignetteRares = self:ScanVignettes(mapID) localScanCache = { - mapID = mapID, ts = now, + mapID = mapID, + includeFlightMasters = includeFlightMasters, dynamicPOIs = dynamicPOIs, dungeonEntrances = dungeonEntrances, flightMasters = flightMasters, vignetteRares = vignetteRares, } @@ -5376,7 +3408,7 @@ function MapSearch:GetGlobalInstanceCache() if not nameSeen[dNameKey] then nameSeen[dNameKey] = true if not childInfo then childInfo = GetMapInfo(child.mapID) end - tinsert(globalInstanceCache, { + local entry = { name = dInfo.name, category = "delve", icon = nil, @@ -5387,7 +3419,8 @@ function MapSearch:GetGlobalInstanceCache() y = dInfo.position.y, pathPrefix = childInfo and childInfo.name or "", keywords = {"delve", "delves", "instance"}, - }) + } + tinsert(globalInstanceCache, PreparePOI(entry)) end end end @@ -5408,7 +3441,7 @@ function MapSearch:GetGlobalInstanceCache() local nameKey = normalizeName(loc.name) if not nameSeen[nameKey] then nameSeen[nameKey] = true - tinsert(globalInstanceCache, { + local entry = { name = loc.name, category = loc.category, icon = loc.icon, @@ -5419,7 +3452,8 @@ function MapSearch:GetGlobalInstanceCache() y = loc.y, pathPrefix = mapName, keywords = loc.keywords, - }) + } + tinsert(globalInstanceCache, PreparePOI(entry)) end end end @@ -5434,6 +3468,61 @@ function MapSearch:GetGlobalInstanceCache() return globalInstanceCache end +local function BuildPromotedInstanceCache(self) + if promotedInstancePOIs then return promotedInstancePOIs end + + local zones = self:BuildWorldZoneCache() + local instancePOIs = self:GetGlobalInstanceCache() + local pathForMap = {} + local parts = {} + for _, zone in ipairs(zones) do + if zone.path and not pathForMap[zone.mapID] then + wipe(parts) + for _, p in ipairs(zone.path) do + parts[#parts + 1] = p.name + end + parts[#parts + 1] = zone.name + pathForMap[zone.mapID] = tconcat(parts, " > ") + end + end + + promotedInstancePOIs = {} + for _, poi in ipairs(instancePOIs) do + local fullPath = pathForMap[poi.entranceMapID] + local entry = { + name = poi.name, category = poi.category, icon = poi.icon, + isZone = true, isStatic = poi.isStatic, + isDungeonEntrance = poi.isDungeonEntrance, + zoneMapID = poi.entranceMapID, entranceMapID = poi.entranceMapID, + entranceX = poi.x, entranceY = poi.y, + entranceIcon = poi.icon, entranceCategory = poi.category, + pathPrefix = fullPath or poi.pathPrefix, keywords = poi.keywords, + } + promotedInstancePOIs[#promotedInstancePOIs + 1] = PreparePOI(entry) + end + return promotedInstancePOIs +end + +function MapSearch:BuildGlobalSearchCaches() + self:BuildWorldZoneCache() + self:GetGlobalInstanceCache() + BuildPromotedInstanceCache(self) + local mapID = GetBestMapForUnit("player") or (WorldMapFrame and WorldMapFrame:GetMapID()) + if mapID and WorldMapFrame then + GetLocalScans(self, mapID) + self:GetStaticLocations(mapID) + end +end + +function MapSearch:WarmUISearchCaches() + self:BuildWorldZoneCache() + local mapID = GetBestMapForUnit("player") or (WorldMapFrame and WorldMapFrame:GetMapID()) + if mapID and WorldMapFrame then + GetLocalScans(self, mapID) + self:GetStaticLocations(mapID) + end +end + -- Scan flight masters for the given map using the TaxiMap API -- Returns POI-style entries with name, position, and flightmaster category function MapSearch:ScanFlightMasters(mapID) @@ -5485,7 +3574,7 @@ function MapSearch:ScanFlightMasters(mapID) end end if fmInclude then - tinsert(results, { + local entry = { name = node.name .. " (Flight Master)", category = "flightmaster", icon = "atlas:TaxiNode_Neutral", @@ -5498,7 +3587,8 @@ function MapSearch:ScanFlightMasters(mapID) -- one zone doesn't render a pin in another. coordMapID = mapID, keywords = {"flight", "fly", "taxi", "fp", "flight master"}, - }) + } + tinsert(results, PreparePOI(entry)) end end end @@ -5509,8 +3599,6 @@ end -- Scan flight masters across ALL zone-type maps for global search -- Results are cached since flight point positions don't change mid-session -local cachedAllFlightMasters = nil - function MapSearch:ScanAllFlightMasters() if cachedAllFlightMasters then return cachedAllFlightMasters end if not C_TaxiMap or not C_TaxiMap.GetTaxiNodesForMap then return {} end @@ -5567,8 +3655,12 @@ end -- Scan dungeon entrances across ALL zone-type maps for global search. -- Results are cached since instance discovery doesn't change mid-session. function MapSearch:GetStaticLocations(mapID) - mapID = mapID or WorldMapFrame:GetMapID() - if not mapID then return {} end + mapID = mapID or (WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID()) + if not mapID then return emptyStaticLocations end + + local includeDevPOIs = EasyFindDevDB and EasyFindDevDB.rawPOIs + local cached = not includeDevPOIs and staticLocationCache[mapID] + if cached then return cached end local results = {} @@ -5576,7 +3668,7 @@ function MapSearch:GetStaticLocations(mapID) local locations = STATIC_LOCATIONS[mapID] if locations then for _, loc in ipairs(locations) do - tinsert(results, { + local entry = { name = loc.name, category = loc.category, icon = loc.icon, -- nil is fine, GetCategoryIcon will handle it @@ -5584,22 +3676,23 @@ function MapSearch:GetStaticLocations(mapID) x = loc.x, y = loc.y, keywords = loc.keywords, - }) + } + tinsert(results, PreparePOI(entry)) end end -- Also check EasyFindDevDB for dev/testing (raw POIs from recorder) -- Skip dev POIs whose names already exist in built-in static locations - if EasyFindDevDB and EasyFindDevDB.rawPOIs then + if includeDevPOIs then local staticNames = {} if locations then for _, loc in ipairs(locations) do staticNames[slower(loc.name)] = true end end - for _, poi in ipairs(EasyFindDevDB.rawPOIs) do + for _, poi in ipairs(includeDevPOIs) do if poi.mapID == mapID and not staticNames[slower(poi.label or "")] then - tinsert(results, { + local entry = { name = poi.label, category = poi.category or "unknown", icon = nil, -- Let category icon be used @@ -5607,11 +3700,15 @@ function MapSearch:GetStaticLocations(mapID) x = poi.x, y = poi.y, keywords = {}, - }) + } + tinsert(results, PreparePOI(entry)) end end end + if not includeDevPOIs then + staticLocationCache[mapID] = results + end return results end @@ -5637,7 +3734,7 @@ function MapSearch:ScanVignettes(mapID) pos = GetVignettePosition(guid, playerMapID) end if pos then - rares[#rares + 1] = { + local entry = { name = info.name, category = "rare", icon = CATEGORY_ICONS.rare, @@ -5646,6 +3743,7 @@ function MapSearch:ScanVignettes(mapID) vignetteGUID = guid, keywords = {"rare", "rares"}, } + rares[#rares + 1] = PreparePOI(entry) end end end @@ -5656,7 +3754,7 @@ function MapSearch:ScanVignettes(mapID) for _, rare in ipairs(rares) do instances[#instances + 1] = rare end - rares[#rares + 1] = { + local aggregate = { name = "Rares", category = "rare", icon = CATEGORY_ICONS.rare, @@ -5664,6 +3762,7 @@ function MapSearch:ScanVignettes(mapID) isAggregate = true, allInstances = instances, } + rares[#rares + 1] = PreparePOI(aggregate) return rares end @@ -5674,9 +3773,7 @@ function MapSearch:UpdateRareTracking() return end if not WorldMapFrame or not WorldMapFrame:IsShown() then return end - -- Don't override active search - local editBox = searchFrame and searchFrame.editBox - if editBox and editBox:GetText() ~= "" then return end + if activePinState or self._previewing then return end local mapID = WorldMapFrame:GetMapID() if not mapID then return end @@ -5795,7 +3892,7 @@ function MapSearch:ScanMapPOIs(mapID) -- Only add POIs we've explicitly categorized (skips generic landmarks, zone markers, events, etc.) if category then - tinsert(pois, { + local entry = { name = poiInfo.name, pin = nil, -- API-based, no pin reference pinType = category, @@ -5804,7 +3901,8 @@ function MapSearch:ScanMapPOIs(mapID) isStatic = true, x = poiInfo.position.x, y = poiInfo.position.y, - }) + } + tinsert(pois, PreparePOI(entry)) end end end @@ -5817,7 +3915,7 @@ function MapSearch:ScanMapPOIs(mapID) for _, delvePoiID in ipairs(delveIDs) do local dInfo = GetAreaPOIInfo(mapID, delvePoiID) if dInfo and dInfo.name and dInfo.position then - tinsert(pois, { + local entry = { name = dInfo.name, category = "delve", icon = nil, @@ -5826,7 +3924,8 @@ function MapSearch:ScanMapPOIs(mapID) x = dInfo.position.x, y = dInfo.position.y, keywords = {"delve", "delves", "instance"}, - }) + } + tinsert(pois, PreparePOI(entry)) end end end @@ -5867,7 +3966,7 @@ function MapSearch:ScanMapPOIs(mapID) end end end - return deduped + return PreparePOIList(deduped) end -- Generic glow/waypoint atlases stacked on every native pin. Skipped when @@ -6102,37 +4201,6 @@ function MapSearch:GetPinInfo(pin) } end -function MapSearch:OnSearchTextChanged(text) - if not resultsFrame then return end -- floating bars deprecated - if self._suppressTextChanged then - self._suppressTextChanged = nil - return - end - if not text or text == "" or #text < 2 then - self:ClearHighlight() - self:ClearZoneHighlight() - -- Show pinned items only if search bar still has focus - local editBox = activeSearchFrame and activeSearchFrame.editBox - if text == "" and editBox and editBox:HasFocus() then - self:ShowPinnedItems() - else - self:HideResults() - end - -- Resume always-on rare tracking when search is cleared - if EasyFind.db.alwaysShowRares and (not text or text == "") then - C_Timer.After(0, function() self:UpdateRareTracking() end) - end - return - end - - -- Clear any previous zone highlights and POI highlights - self:ClearZoneHighlight() - self:ClearHighlight() - - local results = self:BuildResults(text, isGlobalSearch) - self:ShowResults(results) -end - -- Pure result computation: takes a query and a global-mode flag, returns the -- ranked + filtered + pin-merged result list. No rendering. Safe to call -- from MapTab or any other renderer that wants both local and global sets. @@ -6160,9 +4228,11 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) local parentPath = group.parentPath for _, zone in ipairs(zonesInGroup) do - zoneNames[slower(zone.name)] = true - tinsert(allPOIs, { + zoneNames[GetNameLower(zone)] = true + local entry = { name = zone.name, + nameLower = zone.nameLower, + nameNorm = zone.nameNorm, category = "zone", icon = 237382, isZone = true, @@ -6171,7 +4241,8 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) zoneParentMapID = zone.parentMapID, pathPrefix = parentPath, score = zone.score + 200, - }) + } + allPOIs[#allPOIs + 1] = entry end end @@ -6184,28 +4255,13 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) if isGlobal then local instancePOIs = self:GetGlobalInstanceCache() - -- Build mapID → full path string from the world zones cache - -- (cachedWorldZones is populated by SearchZones above) - local pathForMap = {} - if cachedWorldZones then - for _, zone in ipairs(cachedWorldZones) do - if zone.path and not pathForMap[zone.mapID] then - local parts = {} - for _, p in ipairs(zone.path) do - tinsert(parts, p.name) - end - tinsert(parts, zone.name) - pathForMap[zone.mapID] = tconcat(parts, " > ") - end - end - end - -- Build set of normalized instance names for Dungeon-type zone suppression. -- normalizeName handles hyphens vs spaces ("Nexus-Point" == "Nexus Point"). - local instanceNameNorm = {} + wipe(reuseInstanceNameNorm) + local instanceNameNorm = reuseInstanceNameNorm for _, poi in ipairs(instancePOIs) do if poi.isDungeonEntrance then - instanceNameNorm[normalizeName(poi.name)] = true + instanceNameNorm[GetNameNorm(poi)] = true end end @@ -6216,10 +4272,10 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) for i = #allPOIs, 1, -1 do local poi = allPOIs[i] if poi.isZone and poi.zoneMapType == Enum.UIMapType.Dungeon then - local poiNorm = normalizeName(poi.name) + local poiNorm = GetNameNorm(poi) for instNorm in pairs(instanceNameNorm) do if sfind(instNorm, poiNorm, 1, true) or sfind(poiNorm, instNorm, 1, true) then - zoneNames[slower(poi.name)] = nil + zoneNames[GetNameLower(poi)] = nil zoneNames[poiNorm] = nil tremove(allPOIs, i) break @@ -6232,40 +4288,15 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) local entranceLookup = BuildEntranceLookup(instancePOIs) for _, poi in ipairs(allPOIs) do if poi.isZone and poi.zoneMapID then - local entrance = entranceLookup[slower(poi.name)] + local entrance = entranceLookup[GetNameLower(poi)] if entrance then EnrichZoneWithEntrance(poi, entrance) - zoneNames[slower(entrance.name)] = true - end - end - end - - -- Promote cache entries to zone-style results once, then reuse - -- the promoted tables across every keystroke. Previously each - -- BuildResults call allocated ~300 new tables (14 fields each) - -- which was the dominant per-keystroke cost. - if not promotedInstancePOIs then - promotedInstancePOIs = {} - for _, poi in ipairs(instancePOIs) do - local fullPath = pathForMap[poi.entranceMapID] - promotedInstancePOIs[#promotedInstancePOIs + 1] = { - name = poi.name, - category = poi.category, - icon = poi.icon, - isZone = true, - isStatic = poi.isStatic, - isDungeonEntrance = poi.isDungeonEntrance, - zoneMapID = poi.entranceMapID, - entranceMapID = poi.entranceMapID, - entranceX = poi.x, - entranceY = poi.y, - entranceIcon = poi.icon, - entranceCategory = poi.category, - pathPrefix = fullPath or poi.pathPrefix, - keywords = poi.keywords, - } + zoneNames[GetNameLower(entrance)] = true + end end end + + local promoted = BuildPromotedInstanceCache(self) -- promotedInstancePOIs lives across queries (rebuilt only when -- the global instance cache invalidates). SearchPOIs mutates -- poi.score / duplicateKey / allInstances on every match, and @@ -6274,29 +4305,26 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) -- "raid" or "dungeon" search leaves every instance scored 150, -- and an unrelated query like "tol" inherits the lot. Clear -- before each scan to force fresh scoring and dedup. - for i = 1, #promotedInstancePOIs do - local p = promotedInstancePOIs[i] + for i = 1, #promoted do + local p = promoted[i] p.score = nil p.duplicateKey = nil p.allInstances = nil - if not zoneNames[slower(p.name)] and not zoneNames[normalizeName(p.name)] then + if not zoneNames[GetNameLower(p)] and not zoneNames[GetNameNorm(p)] then allPOIs[#allPOIs + 1] = p end end - -- Flight masters are global too, not just "This Zone". The local - -- pass already includes FMs on the current map; the dedupe in - -- MapTab keys FMs by name so a duplicate doesn't surface in both - -- This Zone and Across the World. Cached after first call — - -- same per-call cleanup as promoted instances. - local allFMs = self:ScanAllFlightMasters() - for i = 1, #allFMs do - local fm = allFMs[i] - fm.score = nil - fm.duplicateKey = nil - fm.allInstances = nil - if not zoneNames[slower(fm.name)] then - allPOIs[#allPOIs + 1] = fm + if MapTabFlightPathsEnabled() then + local allFMs = self:ScanAllFlightMasters() + for i = 1, #allFMs do + local fm = allFMs[i] + fm.score = nil + fm.duplicateKey = nil + fm.allInstances = nil + if not zoneNames[GetNameLower(fm)] then + allPOIs[#allPOIs + 1] = fm + end end end else @@ -6313,14 +4341,15 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) local entranceLookup = BuildEntranceLookup(dungeonEntrances) for _, entrance in ipairs(dungeonEntrances) do - if not zoneNames[slower(entrance.name)] then + local nameLower = GetNameLower(entrance) + if not zoneNames[nameLower] then tinsert(allPOIs, entrance) - existingNames[slower(entrance.name)] = true + existingNames[nameLower] = true end end for _, poi in ipairs(allPOIs) do if poi.isZone and poi.zoneMapID then - local entrance = entranceLookup[slower(poi.name)] + local entrance = entranceLookup[GetNameLower(poi)] if entrance then EnrichZoneWithEntrance(poi, entrance) end @@ -6328,17 +4357,19 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) end for _, fm in ipairs(flightMasters) do - if not zoneNames[slower(fm.name)] and not existingNames[slower(fm.name)] then + local nameLower = GetNameLower(fm) + if not zoneNames[nameLower] and not existingNames[nameLower] then tinsert(allPOIs, fm) - existingNames[slower(fm.name)] = true + existingNames[nameLower] = true end end for _, rare in ipairs(vignetteRares) do if not rare.isAggregate then - if not zoneNames[slower(rare.name)] and not existingNames[slower(rare.name)] then + local nameLower = GetNameLower(rare) + if not zoneNames[nameLower] and not existingNames[nameLower] then tinsert(allPOIs, rare) - existingNames[slower(rare.name)] = true + existingNames[nameLower] = true end else tinsert(allPOIs, rare) @@ -6347,16 +4378,18 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) -- Dynamic pins and static locations added after, skipping duplicates for _, poi in ipairs(dynamicPOIs) do - if not zoneNames[slower(poi.name)] and not existingNames[slower(poi.name)] then + local nameLower = GetNameLower(poi) + if not zoneNames[nameLower] and not existingNames[nameLower] then tinsert(allPOIs, poi) - existingNames[slower(poi.name)] = true + existingNames[nameLower] = true end end for _, loc in ipairs(staticLocations) do -- Don't update existingNames here: multiple static locations with the same -- name but different coords (e.g., two AHs in Orgrimmar) must all reach -- SearchPOIs so the duplicate-tracking system can group and pin them. - if not zoneNames[slower(loc.name)] and not existingNames[slower(loc.name)] then + local nameLower = GetNameLower(loc) + if not zoneNames[nameLower] and not existingNames[nameLower] then tinsert(allPOIs, loc) end end @@ -6458,14 +4491,63 @@ local searchPoisCache = { local_ = { entries = {}, order = {}, lastQuery = "", lastCategory = nil }, global_ = { entries = {}, order = {}, lastQuery = "", lastCategory = nil }, } -local function ResetSearchPoisCache() +function ResetSearchPoisCache() for _, c in pairs(searchPoisCache) do wipe(c.entries); wipe(c.order); c.lastQuery = ""; c.lastCategory = nil end end ns.MapSearch.ResetSearchPoisCache = ResetSearchPoisCache -function MapSearch:SearchPOIs(pois, query) +local function ClearMapSearchScratch() + wipe(reuseAllPOIs) + wipe(reuseZoneNames) + wipe(reuseExistingNames) + wipe(reuseFilteredResults) + wipe(reusePinnedKeys) + wipe(reusePinned) + wipe(reuseFiltered) + wipe(reuseSearchResults) + wipe(reuseSearchSeen) + wipe(reuseSearchDuplicates) + wipe(reuseInstanceNameNorm) + wipe(reuseEntranceLookup) + wipe(reuseUISearchPOIs) + wipe(reuseUISearchExistingNames) + wipe(reuseUISearchZoneNames) + wipe(reuseUISearchInstanceNameNorm) + wipe(reuseUISearchFiltered) + wipe(reuseUISearchResults) + wipe(reuseUISearchResultData) +end + +function MapSearch:TrimSearchMemory() + ResetSearchZonesCache() + ResetSearchPoisCache() + ReleaseGlobalMapCaches() + localScanCache = nil + wipe(staticLocationCache) + ClearMapSearchScratch() + CollectMapGarbage() +end + +function MapSearch:ReleaseIdleSearchMemory() + ReleaseGlobalMapCaches() + localScanCache = nil + wipe(staticLocationCache) + ClearMapSearchScratch() + CollectMapGarbage() +end + +local function POIResultLess(a, b) + if a.score == b.score then + if a.isZone and not b.isZone then return true end + if b.isZone and not a.isZone then return false end + return (a.name or "") < (b.name or "") + end + return a.score > b.score +end + +function MapSearch:SearchPOIs(pois, query, noCache) query = slower(query) wipe(reuseSearchResults) wipe(reuseSearchSeen) @@ -6475,29 +4557,32 @@ function MapSearch:SearchPOIs(pois, query) local duplicates = reuseSearchDuplicates local matchedCategory = self:GetCategoryMatch(query) - local relatedCategories = matchedCategory and self:GetRelatedCategories(matchedCategory) or {} + local relatedCategories = matchedCategory and self:GetRelatedCategories(matchedCategory) or nil -- Query cache: exact hit returns cached results (handles backspace, -- retyping, and repeated same-query calls within one render). -- Extension from lastQuery narrows scoring to its cached matches. - local cacheKey = isGlobalSearch and "global_" or "local_" - local cache = searchPoisCache[cacheKey] - local cachedHit = cache.entries[query] - if cachedHit and cachedHit.matchedCategory == matchedCategory then - cache.lastQuery = query - cache.lastCategory = matchedCategory - return cachedHit.results - end + local cache local candidates = pois local candidatesAreCached = false - if cache.lastQuery ~= "" - and #query > #cache.lastQuery - and query:sub(1, #cache.lastQuery) == cache.lastQuery - and cache.lastCategory == matchedCategory then - local prev = cache.entries[cache.lastQuery] - if prev then - candidates = prev.results - candidatesAreCached = true + if not noCache then + local cacheKey = isGlobalSearch and "global_" or "local_" + cache = searchPoisCache[cacheKey] + local cachedHit = cache.entries[query] + if cachedHit and cachedHit.matchedCategory == matchedCategory then + cache.lastQuery = query + cache.lastCategory = matchedCategory + return cachedHit.results + end + if cache.lastQuery ~= "" + and #query > #cache.lastQuery + and query:sub(1, #cache.lastQuery) == cache.lastQuery + and cache.lastCategory == matchedCategory then + local prev = cache.entries[cache.lastQuery] + if prev then + candidates = prev.results + candidatesAreCached = true + end end end -- Cached candidates carry poi.score from the previous query's @@ -6514,15 +4599,7 @@ function MapSearch:SearchPOIs(pois, query) -- First pass: name matches for _, poi in ipairs(candidates) do - -- Cache nameLower on the POI so repeated keystrokes don't - -- re-lowercase the same name. scan-derived POIs live in the - -- localScanCache (1s TTL) and global instance cache (long-lived) - -- so the cached value sticks for the lifetime of the POI. - local nameLower = poi.nameLower - if not nameLower then - nameLower = slower(poi.name) - poi.nameLower = nameLower - end + local nameLower = GetNameLower(poi) local key = poi.name .. (poi.category or "") .. (poi.isZone and poi.pathPrefix or "") @@ -6534,13 +4611,7 @@ function MapSearch:SearchPOIs(pois, query) score = ns.Database:ScoreName(nameLower, query, #query) if poi.keywords then - if not poi.kwLower then - local lowered = {} - for _, kw in ipairs(poi.keywords) do - lowered[#lowered + 1] = slower(kw) - end - poi.kwLower = lowered - end + if not poi.kwLower then PreparePOI(poi) end score = score + ns.Database:ScoreKeywords(poi.kwLower, query, #query) end -- Instance cache entries promoted to zone-style get the same @@ -6584,10 +4655,12 @@ function MapSearch:SearchPOIs(pois, query) -- Related category match (e.g., search "travel" shows all travel types) if score == 0 then - for _, relCat in ipairs(relatedCategories) do - if poi.category == relCat then - score = 100 - break + if relatedCategories then + for _, relCat in ipairs(relatedCategories) do + if poi.category == relCat then + score = 100 + break + end end end end @@ -6618,15 +4691,9 @@ function MapSearch:SearchPOIs(pois, query) end -- Sort results by score - tsort(results, function(a, b) - -- Zone results come before POI results at equal scores - if a.score == b.score then - if a.isZone and not b.isZone then return true end - if b.isZone and not a.isZone then return false end - return (a.name or "") < (b.name or "") - end - return a.score > b.score - end) + tsort(results, POIResultLess) + + if noCache then return results end -- Snapshot for the query cache. Shallow-copy because `results` -- points at reuseSearchResults which the next call wipes. @@ -6646,483 +4713,6 @@ function MapSearch:SearchPOIs(pois, query) return results end -function MapSearch:ShowResults(results) - if not resultsFrame then return end -- floating bars deprecated - selectedResultIndex = 0 - navBtnFocused = false - self._lastResults = results - if not results or #results == 0 then - self:HideResults() - return - end - - local count = mmin(#results, MAX_RESULTS_POOL) - -- When maximized, bars are at top so results always drop downward - local resultsAbove = EasyFind.db.mapResultsAbove and not mapIsMaximized - - -- Pre-compute whether scrolling will be needed so buttons can be narrower. - -- Must compute maxVisibleHeight (including screen-space clamping) BEFORE the - -- button loop, otherwise willScroll can be false while hasScroll ends up true - -- and the scrollbar overlaps full-width buttons. - local scrollBar = resultsFrame.scrollBar - local maxVisibleHeight = EasyFind.db.mapResultsHeight or 168 - local preAnchor = activeSearchFrame or searchFrame - if isGlobalSearch and globalSearchFrame then - preAnchor = globalSearchFrame - end - local screenH = UIParent:GetHeight() - if resultsAbove then - local available = screenH - (preAnchor:GetTop() or (screenH / 2)) - 16 - if available > 0 and maxVisibleHeight > available then - maxVisibleHeight = available - end - else - local available = (preAnchor:GetBottom() or (screenH / 2)) - 16 - if available > 0 and maxVisibleHeight > available then - maxVisibleHeight = available - end - end - local willScroll = (count * 26 + 12) > maxVisibleHeight - - -- Widen results frame when scrollbar is visible so text isn't overlapped - local baseResultsW = self:GetResultsWidth() - local scrollBarInset = willScroll and 20 or 0 - resultsFrame:SetWidth(baseResultsW + scrollBarInset) - - local wrapX = resultsFrame:GetWidth() * TEXT_WRAP_FRACTION - 10 - - -- NavBtn center = midpoint between wrap edge and scrollbar left edge - local scrollBarLeftX = resultsFrame:GetWidth() * SCROLL_CENTER_FRACTION - scrollBar:GetWidth() / 2 - local navBtnCenterX = wrapX + (scrollBarLeftX - wrapX) * 0.45 - local ROW_INSET = 10 -- row left offset from resultsFrame/scrollChild - - -- Check if the player is in the zone being viewed (for navBtn disabled state) - local viewedMapID = WorldMapFrame:GetMapID() - local playerMapID = GetBestMapForUnit("player") - local playerInZone = viewedMapID and playerMapID and viewedMapID == playerMapID - - local yOffset = -6 -- running vertical offset (top padding) - - for i = 1, MAX_RESULTS_POOL do - local resultRow = resultButtons[i] - if i <= count then - local data = results[i] - resultRow.data = data - - resultRow.icon:ClearAllPoints() - resultRow.icon:SetPoint("LEFT", 5, 0) - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("LEFT", resultRow.icon, "RIGHT", 6, 0) - resultRow.text:SetPoint("RIGHT", resultsFrame, "LEFT", wrapX, 0) - resultRow.icon:Show() - resultRow.icon:SetVertexColor(1, 1, 1) - resultRow.text:SetTextColor(1, 1, 1) - if resultRow.prefixText then resultRow.prefixText:Hide() end - if resultRow.indentLine then resultRow.indentLine:Hide() end - if resultRow.pinIcon then resultRow.pinIcon:Hide() end - if resultRow.pinToggle then resultRow.pinToggle:Hide() end - if resultRow.pinHeaderLine then resultRow.pinHeaderLine:Hide() end - if resultRow.navBtn then resultRow.navBtn:Hide() end - if resultRow.selectionHighlight then resultRow.selectionHighlight:Hide() end - if resultRow.navBtnHighlight then resultRow.navBtnHighlight:Hide() end - - -- Format based on type - if data.isPinHeader then - resultRow.icon:Hide() - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("LEFT", resultRow, "LEFT", 4, 0) - resultRow.text:SetPoint("RIGHT", resultRow.pinToggle, "LEFT", -4, 0) - resultRow.text:SetText("Pinned") - resultRow.text:SetTextColor(0.7, 0.7, 0.7) - local collapsed = EasyFind.db.mapPinsCollapsed - resultRow.pinToggle:SetAtlas(collapsed and "Soulbinds_Collection_CategoryHeader_Expand" or "Soulbinds_Collection_CategoryHeader_Collapse") - resultRow.pinToggle:Show() - resultRow.pinHeaderLine:Show() - - elseif data.isZoneParent then - resultRow.icon:Hide() - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("LEFT", resultRow, "LEFT", 8, 0) - resultRow.text:SetPoint("RIGHT", resultsFrame, "LEFT", wrapX, 0) - resultRow.text:SetText("|cff666666▼ " .. data.name .. "|r") - - elseif data.isZone then - local zoneIcon = data.category ~= "zone" and GetCategoryIcon(data.category) or 237382 - if data.isIndented then - resultRow.icon:ClearAllPoints() - resultRow.icon:SetPoint("LEFT", 25, 0) - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("LEFT", resultRow.icon, "RIGHT", 6, 0) - resultRow.text:SetPoint("RIGHT", resultsFrame, "LEFT", wrapX, 0) - resultRow.text:SetText(data.displayName or data.name) - resultRow.text:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) - SetIconTexture(resultRow.icon, zoneIcon) - resultRow.icon:SetSize(16, 16) - resultRow.icon:Show() - if resultRow.indentLine then resultRow.indentLine:Show() end - - elseif data.pathPrefix and data.pathPrefix ~= "" then - resultRow.text:SetText("|cff666666" .. data.pathPrefix .. " >|r |cffffd100" .. data.name .. "|r") - SetIconTexture(resultRow.icon, zoneIcon) - resultRow.icon:Show() - - else - resultRow.text:SetText(data.name) - resultRow.text:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) - SetIconTexture(resultRow.icon, zoneIcon) - resultRow.icon:Show() - end - - else - local displayText = data.name - if data.isDungeonEntrance and data.pathPrefix and data.pathPrefix ~= "" then - displayText = data.name .. " |cff666666(" .. data.pathPrefix .. ")|r" - end - resultRow.text:SetText(displayText) - resultRow.text:SetTextColor(1, 1, 1) - - local iconTexture = GetCategoryIcon(data.category) - if data.icon then - iconTexture = data.icon - end - SetIconTexture(resultRow.icon, iconTexture) - resultRow.icon:SetSize(RESULT_ICON_SIZE, RESULT_ICON_SIZE) - resultRow.icon:Show() - end - - -- Show navigate button for local search results with coordinates - local hasCoords = not isGlobalSearch - and ((data.x and data.y) or (data.allInstances and #data.allInstances >= 1)) - if hasCoords and resultRow.navBtn then - resultRow.navBtn:ClearAllPoints() - resultRow.navBtn:SetPoint("CENTER", resultRow, "LEFT", navBtnCenterX - ROW_INSET, 0) - resultRow.navBtn.texture:SetTexture(nil) - resultRow.navBtn.texture:SetTexCoord(0, 1, 0, 1) - resultRow.navBtn.texture:SetAtlas("Waypoint-MapPin-Untracked") - resultRow.navBtn.disabled = not playerInZone - resultRow.navBtn.texture:SetDesaturated(not playerInZone) - resultRow.navBtn.texture:SetAlpha(1) - resultRow.navBtn:Show() - end - - if data.isPinned and resultRow.pinIcon then - resultRow.pinIcon:Show() - end - - resultRow:Show() - - -- Separator line after last pinned item before regular results - if data.isPinned and not data.isPinHeader then - local nextData = results[i + 1] - if nextData and not nextData.isPinned and not nextData.isPinHeader then - resultRow.pinHeaderLine:Show() - yOffset = yOffset - 4 - end - end - - local scrollGutter = willScroll and (scrollBar:GetWidth() + 0) or 0 - local rowW = resultsFrame:GetWidth() - ROW_INSET - scrollGutter - resultRow:SetWidth(rowW) - - -- Explicit text width matching anchor-derived width so - -- GetStringHeight calculates correct line count before layout - local textLeftFromFrame = ROW_INSET + 5 + resultRow.icon:GetWidth() + 6 - local wrapWidth = wrapX - textLeftFromFrame - if data.isIndented then - wrapWidth = wrapWidth - 20 - elseif data.isZoneParent then - wrapWidth = rowW - 8 - end - if wrapWidth > 0 then - resultRow.text:SetWidth(wrapWidth) - end - - local textHeight = resultRow.text:GetStringHeight() or 14 - local rowHeight = mmax(24, textHeight + 8) - - resultRow:SetHeight(rowHeight) - resultRow:ClearAllPoints() - resultRow:SetPoint("TOPLEFT", resultsFrame.scrollChild, "TOPLEFT", ROW_INSET, yOffset) - yOffset = yOffset - rowHeight - 2 - else - resultRow:Hide() - end - end - - -- Calculate total content height vs already-clamped maxVisibleHeight - local totalContentHeight = -yOffset + 6 - local hasScroll = totalContentHeight > maxVisibleHeight - local visibleHeight = hasScroll and maxVisibleHeight or totalContentHeight - - -- Size the results frame and scroll child - -- When not scrolling, add 12px for scrollFrame internal padding (6px top + 6px bottom) - -- so scrollChild matches viewport exactly and no unwanted scroll range is created. - -- When scrolling, maxVisibleHeight already includes +12 for this padding. - resultsFrame:SetHeight(visibleHeight + (hasScroll and 0 or 12)) - resultsFrame.scrollChild:SetWidth(resultsFrame:GetWidth()) - resultsFrame.scrollChild:SetHeight(totalContentHeight) - - -- Position scroll frame inside results frame - resultsFrame.scrollFrame:ClearAllPoints() - resultsFrame.scrollFrame:SetPoint("TOPLEFT", resultsFrame, "TOPLEFT", 0, -6) - resultsFrame.scrollFrame:SetPoint("BOTTOMRIGHT", resultsFrame, "BOTTOMRIGHT", 0, 6) - - -- Reset scroll position on new search - resultsFrame.scrollFrame:SetVerticalScroll(0) - - -- Position and show/hide scrollbar centered at SCROLL_CENTER_FRACTION of frame width - if resultsFrame.scrollBar then - resultsFrame.scrollBar:SetShown(hasScroll) - if hasScroll then - local scrollCenterX = resultsFrame:GetWidth() * SCROLL_CENTER_FRACTION - resultsFrame.scrollBar:ClearAllPoints() - resultsFrame.scrollBar:SetPoint("CENTER", resultsFrame, "TOPLEFT", scrollCenterX, -resultsFrame:GetHeight() / 2) - resultsFrame.scrollBar:UpdateThumb(totalContentHeight, visibleHeight) - end - end - - -- Anchor results dropdown to whichever search bar is active - resultsFrame:ClearAllPoints() - if resultsAbove then - resultsFrame:SetPoint("BOTTOMLEFT", preAnchor, "TOPLEFT", 0, -2) - else - resultsFrame:SetPoint("TOPLEFT", preAnchor, "BOTTOMLEFT", 0, 2) - end - - resultsFrame:Show() -end - -function MapSearch:HideResults() - selectedResultIndex = 0 - navBtnFocused = false - self._lastResults = nil - if MapSearch.StopKeyRepeat then MapSearch.StopKeyRepeat() end - if MapSearch.ClearToolbarFocus then MapSearch.ClearToolbarFocus() end - if navFrame then Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) end - if not resultsFrame then return end - resultsFrame:Hide() -end - -function MapSearch:ShowPinnedItems() - if not resultsFrame then return end -- floating bars deprecated - local pins = EasyFind.db.pinnedMapItems - if not pins or #pins == 0 then - self:HideResults() - return - end - local display = {} - tinsert(display, { isPinHeader = true, name = "Pinned" }) - if not EasyFind.db.mapPinsCollapsed then - for _, pin in ipairs(pins) do - local copy = {} - for k, v in pairs(pin) do copy[k] = v end - copy.isPinned = true - tinsert(display, copy) - end - end - self:ShowResults(display) -end - -function MapSearch:SelectFirstResult() - if not resultsFrame:IsShown() then return end - if resultButtons[1]:IsShown() and resultButtons[1].data then - self:SelectResult(resultButtons[1].data) - end -end - -function MapSearch:CountVisibleResults() - local count = 0 - for i = 1, MAX_RESULTS_POOL do - if resultButtons[i]:IsShown() then - count = i - else - break - end - end - return count -end - -function MapSearch:MoveSelection(delta) - local visibleCount = self:CountVisibleResults() - if visibleCount == 0 then return end - - local newIndex = selectedResultIndex + delta - if EasyFind.db.mapResultsAbove and not mapIsMaximized then - -- Above: exit to editbox past last result, clamp at first - if newIndex > visibleCount then newIndex = 0 - elseif newIndex < 1 then newIndex = 1 end - else - -- Below: exit to editbox past first result, clamp at last - if newIndex < 0 then newIndex = 0 - elseif newIndex > visibleCount then newIndex = visibleCount end - end - - selectedResultIndex = newIndex - navBtnFocused = false - self:UpdateSelectionHighlight() -end - -function MapSearch:JumpToStart() - if self:CountVisibleResults() > 0 then - selectedResultIndex = 1 - self:UpdateSelectionHighlight() - end -end - -function MapSearch:JumpToEnd() - local visibleCount = self:CountVisibleResults() - if visibleCount > 0 then - selectedResultIndex = visibleCount - self:UpdateSelectionHighlight() - end -end - -function MapSearch:UpdateSelectionHighlight(skipRefocus) - for i = 1, MAX_RESULTS_POOL do - local resultRow = resultButtons[i] - if resultRow.selectionHighlight then - resultRow.selectionHighlight:SetShown(i == selectedResultIndex and not navBtnFocused) - end - if resultRow.navBtnHighlight then - resultRow.navBtnHighlight:SetShown(i == selectedResultIndex and navBtnFocused) - end - end - local eb = activeSearchFrame and activeSearchFrame.editBox - if selectedResultIndex > 0 then - if resultButtons[selectedResultIndex] then - Utils.ScrollToButton(resultsFrame.scrollFrame, resultButtons[selectedResultIndex]) - end - if eb and eb:HasFocus() then - eb:ClearFocus() - end - Utils.SafeCallMethod(navFrame, "EnableKeyboard", true) - -- Keyboard preview: show pin for selected result - local resultRow = resultButtons[selectedResultIndex] - if resultRow and resultRow.data then - local coords = self:GetPreviewCoords(resultRow.data) - if coords then - if not self._previewing then - self._savedPinState = activePinState - end - self._previewing = true - if coords.instances then - self:ShowMultipleWaypoints(coords.instances) - elseif coords.pin and coords.pin:IsShown() then - self:HighlightPin(coords.pin, coords.x, coords.y, coords.icon, coords.category) - else - self:ShowWaypointAt(coords.x, coords.y, coords.icon, coords.category) - end - activePinState = self._savedPinState - end - end - else - -- Clear keyboard preview when returning to editbox - if self._previewing then - self._previewing = nil - self:ClearHighlight() - local saved = self._savedPinState - self._savedPinState = nil - if saved and saved.mapID == WorldMapFrame:GetMapID() then - if saved.instances then - self:ShowMultipleWaypoints(saved.instances) - else - self:ShowWaypointAt(saved.x, saved.y, saved.icon, saved.category) - end - end - end - local wasNavigating = navFrame:IsKeyboardEnabled() - Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) - if MapSearch.StopKeyRepeat then MapSearch.StopKeyRepeat() end - if wasNavigating and not skipRefocus and eb and not eb:HasFocus() then - eb:SetFocus() - end - end -end - -function MapSearch:ActivateSelected() - if selectedResultIndex > 0 and selectedResultIndex <= MAX_RESULTS_POOL then - local resultRow = resultButtons[selectedResultIndex] - if resultRow:IsShown() and resultRow.data then - if navBtnFocused and resultRow.navBtn and resultRow.navBtn:IsShown() then - resultRow.navBtn:Click() - else - self:SelectResult(resultRow.data) - end - return - end - end - self:SelectFirstResult() -end - -function MapSearch:FocusLocalSearch() - if not searchFrame or not searchFrame.editBox then return end - if EasyFind.db.mapSmartShow then SmartShowFadeIn(searchFrame) end - searchFrame.editBox:SetFocus() -end - --- Run a local search programmatically, bypassing the OnTextChanged --- HasFocus gate. Sets the module-level state (isGlobalSearch, --- activeSearchFrame) and invokes the real search directly. Used by the --- demo's setupAfter functions to restore the "typed + results showing" --- state instantly when the user jumps to a later step. -function MapSearch:RunLocalSearch(text) - if not searchFrame or not searchFrame.editBox then return end - -- No SetFocus: the real editbox must NOT hold keyboard focus or - -- the real user could type alongside the demo. We drive the search - -- directly by setting state and calling OnSearchTextChanged, which - -- skips the HasFocus() gate inside the editbox's OnTextChanged. - searchFrame.editBox:ClearFocus() - searchFrame.editBox:SetText(text or "") - if searchFrame.editBox.placeholder then - if text and text ~= "" then - searchFrame.editBox.placeholder:Hide() - else - searchFrame.editBox.placeholder:Show() - end - end - isGlobalSearch = false - activeSearchFrame = searchFrame - -- SelectResult sets _suppressTextChanged = true and relies on the - -- editbox's OnTextChanged to consume it. When we bypass focus, the - -- HasFocus() gate can leave the flag stuck, which would cause the - -- next OnSearchTextChanged to bail out early. Clear it here so the - -- search always runs. - self._suppressTextChanged = nil - self:OnSearchTextChanged(text or "") -end - -function MapSearch:RunGlobalSearch(text) - if not globalSearchFrame or not globalSearchFrame.editBox then return end - globalSearchFrame.editBox:ClearFocus() - globalSearchFrame.editBox:SetText(text or "") - if globalSearchFrame.editBox.placeholder then - if text and text ~= "" then - globalSearchFrame.editBox.placeholder:Hide() - else - globalSearchFrame.editBox.placeholder:Show() - end - end - isGlobalSearch = true - activeSearchFrame = globalSearchFrame - self._suppressTextChanged = nil - self:OnSearchTextChanged(text or "") -end - -function MapSearch:FocusGlobalSearch() - if not globalSearchFrame or not globalSearchFrame.editBox then return end - if EasyFind.db.mapSmartShow then SmartShowFadeIn(globalSearchFrame) end - globalSearchFrame.editBox:SetFocus() -end - -function MapSearch:UpdateMapModeBtns() - if searchFrame and searchFrame.modeBtn and searchFrame.modeBtn.UpdateVisual then - searchFrame.modeBtn.UpdateVisual() - end - if globalSearchFrame and globalSearchFrame.modeBtn and globalSearchFrame.modeBtn.UpdateVisual then - globalSearchFrame.modeBtn.UpdateVisual() - end -end - -- Shared logic for navigating to an instance entrance. -- If already on the target map, shows waypoint directly. -- Otherwise checks if the entrance is visible on the current map, @@ -7153,32 +4743,10 @@ end -- Guide menu to force breadcrumb/teaching mode regardless of the -- user's default left-click setting. function MapSearch:SelectResult(data, directOverride) - -- Clear preview state so OnLeave doesn't undo the real selection self._previewing = nil self._savedPinState = nil self._suppressTextChanged = true - -- Every MapTab/UI-search click hover-clears now. The legacy - -- "local = tooltip on hover, global = hover clears" split was - -- meaningful only when the floating local + global bars asked the - -- user to pick a scope; with MapTab merging them, the consistent - -- expectation is that hovering off the pin clears it. Pinned - -- waypoints placed via the map are unaffected (they go through a - -- different code path). pinHoverClearsOverride = true - -- Floating bars may be gone (deprecated) - guard the editbox clears. - if searchFrame and searchFrame.editBox then - searchFrame.editBox:SetText("") - searchFrame.editBox:ClearFocus() - if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end - end - if globalSearchFrame and globalSearchFrame.editBox then - self._suppressTextChanged = true - globalSearchFrame.editBox:SetText("") - globalSearchFrame.editBox:ClearFocus() - if globalSearchFrame.editBox.placeholder then globalSearchFrame.editBox.placeholder:Show() end - end - self:HideResults() - if data then DebugPrint("[EasyFind] SelectResult: name=", data.name, "isZone=", data.isZone, "zoneMapID=", data.zoneMapID, @@ -7271,14 +4839,7 @@ function MapSearch:SelectResult(data, directOverride) self:HighlightPin(data.pin) end - -- Fast Mode (local search only): auto-place a SuperTrack waypoint - -- so the minimap glow / guide-circle appears immediately without - -- needing a second click on the pin. Matches HandleUISearchClick's - -- local Fast Mode behavior. - -- Local search POIs generally don't carry their own mapID (the - -- mapID is implicit = currently-viewed map, since the local - -- search is scoped to that map), so we fall back to the viewed - -- map when data.mapID isn't present. + -- Fast Mode local search auto-places a native waypoint. if not isGlobalSearch and directMode then local autoX, autoY, autoMapID local viewedMap = WorldMapFrame and WorldMapFrame:GetMapID() @@ -7302,23 +4863,10 @@ function MapSearch:SelectResult(data, directOverride) SetUserWaypoint(UiMapPoint.CreateFromCoordinates(autoMapID, autoX, autoY)) C_SuperTrack.SetSuperTrackedUserWaypoint(true) efPlacedWaypoint = true - ShowSuperTrackGlow() MapSearch:RefreshAllClearButtons() end end end - - -- Clear search text after pins are placed. Flag persists until - -- OnSearchTextChanged consumes it (OnTextChanged may fire next frame). - -- Floating bars may be retired - guard editbox access. - self._suppressTextChanged = true - if isGlobalSearch and globalSearchFrame and globalSearchFrame.editBox then - globalSearchFrame.editBox:SetText("") - if globalSearchFrame.editBox.placeholder then globalSearchFrame.editBox.placeholder:Show() end - elseif searchFrame and searchFrame.editBox then - searchFrame.editBox:SetText("") - if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end - end end -- Show multiple waypoints for duplicate POIs (e.g., multiple auction houses) @@ -7419,7 +4967,6 @@ function MapSearch:ShowMultipleWaypoints(instances) SetUserWaypoint(UiMapPoint.CreateFromCoordinates(viewingMapID, x, y)) C_SuperTrack.SetSuperTrackedUserWaypoint(true) efPlacedWaypoint = true - ShowSuperTrackGlow() end end if button == "RightButton" then @@ -7513,7 +5060,7 @@ function MapSearch:ShowMultipleWaypoints(instances) if instance.icon then iconTexture = instance.icon end - SetIconTexture(pin.icon, iconTexture) + Utils.SetIconTexture(pin.icon, iconTexture) if pin.glow then pin.glow:SetSize(glowSize, glowSize) @@ -7575,7 +5122,7 @@ function MapSearch:ShowMultipleWaypoints(instances) end end - -- Auto-track on minimap if requested by navigate button + -- Auto-track if requested by navigate button if self.autoTrackNextPin then self.autoTrackNextPin = nil self:TrackActivePin() @@ -7642,7 +5189,7 @@ function MapSearch:ShowWaypointAt(x, y, icon, category, arrowOnly) if icon then iconTexture = icon end - SetIconTexture(waypointPin.icon, iconTexture) + Utils.SetIconTexture(waypointPin.icon, iconTexture) waypointPin:ClearAllPoints() waypointPin:SetPoint("CENTER", canvas, "TOPLEFT", canvasWidth * x, -canvasHeight * y) waypointPin.waypointX = x @@ -7684,7 +5231,7 @@ function MapSearch:ShowWaypointAt(x, y, icon, category, arrowOnly) if highlightFrame.animGroup then highlightFrame.animGroup:Play() end end - -- Auto-track on minimap if requested by navigate button + -- Auto-track if requested by navigate button if self.autoTrackNextPin then self.autoTrackNextPin = nil self:TrackActivePin() @@ -8036,7 +5583,7 @@ function MapSearch:GetPreviewCoords(data) return nil end --- Full clear: map visuals + minimap waypoint tracking +-- Full clear: map visuals + waypoint tracking -- Called by explicit dismiss actions (right-click pin, /ef clear, clear button) -- Returns true if EasyFind currently has any active map navigation: -- an active pin, a SuperTrack waypoint we placed, a zone highlight, @@ -8054,8 +5601,6 @@ end function MapSearch:RefreshAllClearButtons() local frames = { _G["EasyFindSearchFrame"], - _G["EasyFindMapSearchFrame"], - _G["EasyFindMapGlobalSearchFrame"], } for _, f in ipairs(frames) do if f and f.UpdateClearButtonVisibility then @@ -8088,7 +5633,6 @@ function MapSearch:TrackActiveLocation() SetUserWaypoint(UiMapPoint.CreateFromCoordinates(mapID, x, y)) C_SuperTrack.SetSuperTrackedUserWaypoint(true) efPlacedWaypoint = true - ShowSuperTrackGlow() self:RefreshAllClearButtons() return true end @@ -8109,7 +5653,6 @@ function MapSearch:ClearAll() -- Only clear Blizzard waypoint if EasyFind placed it if efPlacedWaypoint then efPlacedWaypoint = false - HideSuperTrackGlow() C_SuperTrack.SetSuperTrackedUserWaypoint(false) if HasUserWaypoint() then ClearUserWaypoint() @@ -8138,8 +5681,6 @@ function MapSearch:GetNearestInstance(instances, mapID) return nearest end --- Auto-track the currently active pin on the minimap via Blizzard waypoint. --- Called by the navigate button to combine select + track in one action. function MapSearch:TrackActivePin() if not activePinState then return end local mapID = activePinState.mapID @@ -8160,82 +5701,6 @@ function MapSearch:TrackActivePin() SetUserWaypoint(UiMapPoint.CreateFromCoordinates(mapID, x, y)) C_SuperTrack.SetSuperTrackedUserWaypoint(true) efPlacedWaypoint = true - ShowSuperTrackGlow() -end - -local BASE_SEARCH_W = 250 -local BASE_RESULTS_W = 300 - -function MapSearch:GetSearchWidth() - return BASE_SEARCH_W * (EasyFind.db.mapSearchWidth or 1.0) -end - -function MapSearch:GetMaxResultsWidth() - local gap = 10 - if searchFrame and globalSearchFrame then - local localLeft = searchFrame:GetLeft() - local globalLeft = globalSearchFrame:GetLeft() - if localLeft and globalLeft then - return globalLeft - localLeft - gap - end - end - if WorldMapFrame and WorldMapFrame.ScrollContainer then - return WorldMapFrame.ScrollContainer:GetWidth() - gap - end - return 600 -end - -function MapSearch:GetResultsWidth() - local w = EasyFind.db.mapResultsWidth - if w and w > 1 then - return mmin(w, self:GetMaxResultsWidth()) - end - return BASE_RESULTS_W * (EasyFind.db.mapSearchWidth or 1.0) -end - -function MapSearch:UpdateWidth() - local w = self:GetSearchWidth() - if searchFrame then searchFrame:SetWidth(w) end - if globalSearchFrame then globalSearchFrame:SetWidth(w) end -end - -function MapSearch:UpdateScale() - local scale = EasyFind.db.mapSearchScale or 1.0 - if searchFrame then - searchFrame:SetScale(scale) - end - if globalSearchFrame then - globalSearchFrame:SetScale(scale) - end - self:UpdateResultsScale() -end - -function MapSearch:UpdateResultsScale() - if resultsFrame then - resultsFrame:SetScale(EasyFind.db.mapResultsScale or 1.0) - end -end - -function MapSearch:UpdateResultsWidth() - if resultsFrame then - local w = EasyFind.db.mapResultsWidth - if w and w > 1 then - resultsFrame:SetWidth(w) - end - end -end - -function MapSearch:RefreshResultsAnchor() - if not resultsFrame or not resultsFrame:IsShown() then return end - local anchor = activeSearchFrame or searchFrame - if isGlobalSearch and globalSearchFrame then anchor = globalSearchFrame end - resultsFrame:ClearAllPoints() - -- When maximized, bars are at top so results always drop downward - if EasyFind.db.mapResultsAbove and not mapIsMaximized then - resultsFrame:SetPoint("BOTTOMLEFT", anchor, "TOPLEFT", 0, -2) - else - resultsFrame:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, 2) - end end function MapSearch:UpdateBlinkingPins() @@ -8277,122 +5742,7 @@ function MapSearch:UpdatePinHighlight() end end -function MapSearch:UpdateOpacity() - local alpha = EasyFind.db.searchBarOpacity or DEFAULT_OPACITY - local frames = {searchFrame, globalSearchFrame} - for _, frame in ipairs(frames) do - if frame then - ns.SetSearchBorderBgAlpha(frame, alpha) - end - end -end - -function MapSearch:UpdateFontSize() - local scale = EasyFind.db.mapFontSize or 1.0 - - local function ScaleFont(fontString, baseFontObject) - local obj = _G[baseFontObject] - if not obj then return end - local path, baseSize, flags = obj:GetFont() - fontString:SetFont(path, baseSize * scale, flags) - fontString:SetJustifyH(fontString:GetJustifyH()) - end - - local barH = ns.SEARCHBAR_HEIGHT * scale - local contentSz = barH * ns.SEARCHBAR_FILL - local iconSz = contentSz * ns.SEARCHBAR_ICON_SCALE - local clearSz = ns.CLEAR_BTN_SIZE * scale - - local function ScaleBar(frame) - if not frame then return end - frame:SetHeight(barH) - frame.editBox:SetHeight(contentSz) - if frame.searchIcon then frame.searchIcon:SetSize(iconSz, iconSz) end - if frame.modeBtn then frame.modeBtn:SetWidth(barH) end - if frame.clearBtn then frame.clearBtn:SetSize(clearSz, clearSz) end - if frame.filterBtn then - frame.filterBtn:SetWidth(barH) - if frame.filterBtn.arrow then - local arrowSz = barH * 0.37 - frame.filterBtn.arrow:SetSize(arrowSz, arrowSz) - end - end - ScaleFont(frame.editBox, ns.SEARCHBAR_FONT) - ScaleFont(frame.editBox.placeholder, ns.SEARCHBAR_FONT) - end - - ScaleBar(searchFrame) - ScaleBar(globalSearchFrame) - self:UpdateSearchBarTheme() - - for _, resultRow in ipairs(resultButtons) do - ScaleFont(resultRow.text, "GameFontNormal") - if resultRow.prefixText then - ScaleFont(resultRow.prefixText, "GameFontNormalSmall") - end - end -end - -function MapSearch:UpdateSearchBarTheme() - local scale = EasyFind.db.mapFontSize or 1.0 - local alpha = EasyFind.db.searchBarOpacity or DEFAULT_OPACITY - local frames = {searchFrame, globalSearchFrame} - for _, frame in ipairs(frames) do - if frame then - frame:SetBackdrop(nil) - ns.SetSearchBorderShown(frame, true) - ns.ScaleSearchBorder(frame, scale) - ns.SetSearchBorderBgAlpha(frame, alpha) - end - end - - if resultsFrame then - resultsFrame:SetBackdrop({ - edgeFile = TOOLTIP_BORDER, - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 }, - }) - resultsFrame:SetBackdropBorderColor(0.50, 0.48, 0.45, 1.0) - if not resultsFrame.bgAtlasTex then - local tex = resultsFrame:CreateTexture(nil, "BACKGROUND", nil, -1) - tex:SetPoint("TOPLEFT", 4, -4) - tex:SetPoint("BOTTOMRIGHT", -4, 4) - resultsFrame.bgAtlasTex = tex - end - resultsFrame.bgAtlasTex:SetAtlas("QuestLog-main-background", false) - resultsFrame.bgAtlasTex:Show() - resultsFrame:SetClipsChildren(true) - end -end - -function MapSearch:ResetPosition() - -- Floating bars are retired so there's nothing to reposition. - EasyFind.db.mapSearchPosition = nil - EasyFind.db.globalSearchPosition = nil - EasyFind.db.mapSearchPositionMax = nil - EasyFind.db.globalSearchPositionMax = nil -end - -function MapSearch:UpdateYOffset() - -- Skip repositioning while maximized; minimize will apply the offset - if mapIsMaximized then return end - local yOff = EasyFind.db.mapSearchYOffset or 0 - if searchFrame then - searchFrame:ClearAllPoints() - local xOff = EasyFind.db.mapSearchPosition or 0 - searchFrame:SetPoint("TOPLEFT", WorldMapFrame.ScrollContainer, "BOTTOMLEFT", xOff, yOff) - end - if globalSearchFrame then - globalSearchFrame:ClearAllPoints() - local xOff = EasyFind.db.globalSearchPosition or 0 - globalSearchFrame:SetPoint("TOPRIGHT", WorldMapFrame.ScrollContainer, "BOTTOMRIGHT", xOff, yOff) - end -end - --- Called when the user changes the icon scale setting. function MapSearch:UpdateIconScales() - -- Updates all visible pins, highlights, and arrows in real-time. - local canvas = WorldMapFrame.ScrollContainer.Child if not canvas then return end @@ -8503,6 +5853,7 @@ end -- runs SearchPOIs with the same scoring, and returns results for UI display. function MapSearch:SearchForUI(query) if not query or query == "" or #query < 2 then return nil end + if not cachedWorldZones then return nil end -- Use the player's current zone as the local anchor so UI-bar map -- results reflect what's actually around them rather than wherever @@ -8513,46 +5864,49 @@ function MapSearch:SearchForUI(query) -- so the UI bar shows results regardless of zone scope — matching -- how the MapTab surfaces both "This Zone" and "Across the World" -- content without asking the user to pick. - local pois = {} - local existingNames = {} + wipe(reuseUISearchPOIs) + wipe(reuseUISearchExistingNames) + local pois = reuseUISearchPOIs + local existingNames = reuseUISearchExistingNames do - local dynamicPOIs = self:ScanMapPOIs(searchMapID) + local dynamicPOIs, dungeonEntrances, flightMasters, vignetteRares = GetLocalScans(self, searchMapID) local staticLocations = self:GetStaticLocations(searchMapID) - local dungeonEntrances = self:ScanDungeonEntrances(searchMapID) - local flightMasters = self:ScanFlightMasters(searchMapID) - local vignetteRares = self:ScanVignettes(searchMapID) for _, entrance in ipairs(dungeonEntrances) do pois[#pois + 1] = entrance - existingNames[slower(entrance.name)] = true + existingNames[GetNameLower(entrance)] = true end for _, fm in ipairs(flightMasters) do - if not existingNames[slower(fm.name)] then + local nameLower = GetNameLower(fm) + if not existingNames[nameLower] then pois[#pois + 1] = fm - existingNames[slower(fm.name)] = true + existingNames[nameLower] = true end end for _, rare in ipairs(vignetteRares) do if not rare.isAggregate then - if not existingNames[slower(rare.name)] then + local nameLower = GetNameLower(rare) + if not existingNames[nameLower] then pois[#pois + 1] = rare - existingNames[slower(rare.name)] = true + existingNames[nameLower] = true end else pois[#pois + 1] = rare end end for _, poi in ipairs(dynamicPOIs) do - if not existingNames[slower(poi.name)] then + local nameLower = GetNameLower(poi) + if not existingNames[nameLower] then pois[#pois + 1] = poi - existingNames[slower(poi.name)] = true + existingNames[nameLower] = true end end for _, loc in ipairs(staticLocations) do - if not existingNames[slower(loc.name)] then + local nameLower = GetNameLower(loc) + if not existingNames[nameLower] then pois[#pois + 1] = loc - existingNames[slower(loc.name)] = true + existingNames[nameLower] = true end end end @@ -8566,101 +5920,79 @@ function MapSearch:SearchForUI(query) local zoneMatches = self:SearchZones(query) isGlobalSearch = savedGlobalFlag local groupedZones = self:GroupZonesByParent(zoneMatches) - local zoneNames = {} + wipe(reuseUISearchZoneNames) + local zoneNames = reuseUISearchZoneNames for _, group in ipairs(groupedZones) do for _, zone in ipairs(group.zones) do - zoneNames[slower(zone.name)] = true - pois[#pois + 1] = { - name = zone.name, category = "zone", icon = 237382, - isZone = true, zoneMapID = zone.mapID, - zoneMapType = zone.mapType, zoneParentMapID = zone.parentMapID, - pathPrefix = group.parentPath, score = zone.score + 200, - } + local nameLower = GetNameLower(zone) + zoneNames[nameLower] = true + if not existingNames[nameLower] then + pois[#pois + 1] = { + name = zone.name, category = "zone", icon = 237382, + nameLower = zone.nameLower, nameNorm = zone.nameNorm, + isZone = true, zoneMapID = zone.mapID, + zoneMapType = zone.mapType, zoneParentMapID = zone.parentMapID, + pathPrefix = group.parentPath, score = zone.score + 200, + } + end end end - local instancePOIs = self:GetGlobalInstanceCache() + if globalInstanceCache then + local instancePOIs = self:GetGlobalInstanceCache() - -- Build mapID → path string for breadcrumbs - local pathForMap = {} - if cachedWorldZones then - for _, zone in ipairs(cachedWorldZones) do - if zone.path and not pathForMap[zone.mapID] then - local parts = {} - for _, p in ipairs(zone.path) do - parts[#parts + 1] = p.name - end - parts[#parts + 1] = zone.name - pathForMap[zone.mapID] = tconcat(parts, " > ") + wipe(reuseUISearchInstanceNameNorm) + local instanceNameNorm = reuseUISearchInstanceNameNorm + for _, poi in ipairs(instancePOIs) do + if poi.isDungeonEntrance then + instanceNameNorm[GetNameNorm(poi)] = true end end - end - - -- Suppress Dungeon-type zones covered by instance cache entries - local instanceNameNorm = {} - for _, poi in ipairs(instancePOIs) do - if poi.isDungeonEntrance then - instanceNameNorm[normalizeName(poi.name)] = true - end - end - for i = #pois, 1, -1 do - local poi = pois[i] - if poi.isZone and poi.zoneMapType == Enum.UIMapType.Dungeon then - local poiNorm = normalizeName(poi.name) - for instNorm in pairs(instanceNameNorm) do - if sfind(instNorm, poiNorm, 1, true) or sfind(poiNorm, instNorm, 1, true) then - zoneNames[slower(poi.name)] = nil - zoneNames[poiNorm] = nil - tremove(pois, i) - break + local writeIdx = 0 + for i = 1, #pois do + local poi = pois[i] + local keep = true + if poi.isZone and poi.zoneMapType == Enum.UIMapType.Dungeon then + local poiNorm = GetNameNorm(poi) + for instNorm in pairs(instanceNameNorm) do + if sfind(instNorm, poiNorm, 1, true) or sfind(poiNorm, instNorm, 1, true) then + zoneNames[GetNameLower(poi)] = nil + zoneNames[poiNorm] = nil + keep = false + break + end end end + if keep then + writeIdx = writeIdx + 1 + pois[writeIdx] = poi + end + end + for i = #pois, writeIdx + 1, -1 do + pois[i] = nil end - end - -- Enrich non-Dungeon zones with entrance data - local entranceLookup = BuildEntranceLookup(instancePOIs) - for _, poi in ipairs(pois) do - if poi.isZone and poi.zoneMapID then - local entrance = entranceLookup[slower(poi.name)] - if entrance then - EnrichZoneWithEntrance(poi, entrance) - zoneNames[slower(entrance.name)] = true + local entranceLookup = BuildEntranceLookup(instancePOIs) + for _, poi in ipairs(pois) do + if poi.isZone and poi.zoneMapID then + local entrance = entranceLookup[GetNameLower(poi)] + if entrance then + EnrichZoneWithEntrance(poi, entrance) + zoneNames[GetNameLower(entrance)] = true + end end end - end - -- Reuse the shared promotedInstancePOIs cache (same lifetime - -- as globalInstanceCache) so we don't re-allocate ~300 tables - -- every keystroke. Build lazily if not yet populated. - if not promotedInstancePOIs then - promotedInstancePOIs = {} - for _, poi in ipairs(instancePOIs) do - local fullPath = pathForMap[poi.entranceMapID] - promotedInstancePOIs[#promotedInstancePOIs + 1] = { - name = poi.name, category = poi.category, icon = poi.icon, - isZone = true, isStatic = poi.isStatic, - isDungeonEntrance = poi.isDungeonEntrance, - zoneMapID = poi.entranceMapID, entranceMapID = poi.entranceMapID, - entranceX = poi.x, entranceY = poi.y, - entranceIcon = poi.icon, entranceCategory = poi.category, - pathPrefix = fullPath or poi.pathPrefix, keywords = poi.keywords, - } - end - end - -- Wipe stale per-POI score/dedupe state. SearchPOIs trusts - -- poi.score on isZone entries as a shortcut (the "zone results - -- already scored by SearchZones" branch), so leftover scores - -- from a prior query (e.g. "raid" scored every instance 150) - -- would otherwise leak into the next unrelated query like "3v". - for i = 1, #promotedInstancePOIs do - local p = promotedInstancePOIs[i] - p.score = nil - p.duplicateKey = nil - p.allInstances = nil - if not zoneNames[slower(p.name)] and not zoneNames[normalizeName(p.name)] then - pois[#pois + 1] = p + local promoted = BuildPromotedInstanceCache(self) + for i = 1, #promoted do + local p = promoted[i] + p.score = nil + p.duplicateKey = nil + p.allInstances = nil + if not zoneNames[GetNameLower(p)] and not zoneNames[GetNameNorm(p)] then + pois[#pois + 1] = p + end end end end @@ -8668,7 +6000,7 @@ function MapSearch:SearchForUI(query) if #pois == 0 then return nil end -- Run the same scoring pipeline as the real map search - local scored = self:SearchPOIs(pois, query) + local scored = self:SearchPOIs(pois, query, true) if not scored or #scored == 0 then return nil end -- Apply the MapTab cog filters so the UI search bar surfaces the @@ -8679,7 +6011,8 @@ function MapSearch:SearchForUI(query) do local mtFilters = EasyFind.db.mapTabFilters if mtFilters then - local filtered = {} + wipe(reuseUISearchFiltered) + local filtered = reuseUISearchFiltered for _, r in ipairs(scored) do local bucket = GetFilterBucket(r) if mtFilters[bucket] ~= false then @@ -8691,29 +6024,36 @@ function MapSearch:SearchForUI(query) end end - -- Convert to UI search format {data, score} - local results = {} - for _, r in ipairs(scored) do + local results = reuseUISearchResults + local resultCap = mmin(#scored, UI_MAP_RESULT_CAP) + for ri = 1, resultCap do + local r = scored[ri] local cat = r.category or "location" - local d = { - name = r.name, - nameLower = slower(r.name), - category = cat, - icon = r.icon or GetCategoryIcon(cat), - mapSearchResult = true, - mapID = r.mapID or r.zoneMapID or r.entranceMapID or searchMapID, - zoneName = r.zoneName or r.pathPrefix, - pathPrefix = r.pathPrefix, - x = r.x or r.entranceX, - y = r.y or r.entranceY, - keywords = r.keywords, - -- Carry the originating query so HandleUISearchClick can - -- pre-populate the MapTab search box. Mirrors what would - -- happen if the user had typed the same query inside the - -- MapTab and clicked the same row. - query = query, - } - -- Preserve fields needed by SelectResult for global results + local d = reuseUISearchResultData[ri] + if not d then + d = {} + reuseUISearchResultData[ri] = d + end + d.name = r.name + d.nameLower = GetNameLower(r) + d.category = cat + d.icon = r.icon or GetCategoryIcon(cat) + d.mapSearchResult = true + d.mapID = r.mapID or r.zoneMapID or r.entranceMapID or searchMapID + d.zoneName = r.zoneName or r.pathPrefix + d.pathPrefix = r.pathPrefix + d.x = r.x or r.entranceX + d.y = r.y or r.entranceY + d.keywords = r.keywords + d.query = query + d.isZone = nil + d.zoneMapID = nil + d.entranceMapID = nil + d.entranceX = nil + d.entranceY = nil + d.entranceIcon = nil + d.entranceCategory = nil + d.isDungeonEntrance = nil if r.isZone then d.isZone = true d.zoneMapID = r.zoneMapID @@ -8729,7 +6069,16 @@ function MapSearch:SearchForUI(query) d.entranceMapID = r.entranceMapID end end - results[#results + 1] = { score = r.score or 50, data = d } + local out = results[ri] + if not out then + out = {} + results[ri] = out + end + out.score = r.score or 50 + out.data = d + end + for i = resultCap + 1, #results do + results[i] = nil end return results @@ -8737,7 +6086,7 @@ end -- Handle click on a map search result from the UI search bar. -- Local results: --- Fast mode: place waypoint + start minimap nav without opening map. +-- Fast mode: place waypoint without opening map. -- Standard mode: guide to open the world map, then show pin. -- Global results (zones, instances): -- Fast mode: open map directly and run SelectResult (highlight/waypoint). diff --git a/MapTab.lua b/MapTab.lua index 93edd70..70289d7 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -5,47 +5,35 @@ ns.MapTab = MapTab local Utils = ns.Utils local SafeAfter = Utils and Utils.SafeAfter or function(delay, fn) C_Timer.After(delay, fn) end -local sfind = Utils and Utils.sfind or string.find -local slower = Utils and Utils.slower or string.lower local tinsert = Utils and Utils.tinsert or table.insert local tremove = table.remove local GOLD_COLOR = ns.GOLD_COLOR or {1.0, 0.82, 0.0} -local TOOLTIP_BORDER = ns.TOOLTIP_BORDER -local DARK_PANEL_BG = ns.DARK_PANEL_BG local CreateFrame = CreateFrame local GameTooltip = GameTooltip local GameTooltip_Hide = GameTooltip_Hide local C_Timer = C_Timer -local GetCursorPosition = GetCursorPosition -local UIParent = UIParent -- --------------------------------------------------------------------------- -- Tab geometry -- --------------------------------------------------------------------------- local TAB_W, TAB_H = 42, 55 -local TAB_BG_W, TAB_BG_H = 51, 59 local TAB_ICON_SIZE = 20 local TAB_ICON_GOLD = {1.00, 0.82, 0.00} local TAB_ICON_DIM = {0.55, 0.45, 0.10} local TAB_STACK_GAP = -3 --- Pin / Guide popup geometry (mirrors UI.lua) local EYE_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\eye" -local PIN_MENU_ROW_H = 22 -local PIN_MENU_WIDTH = 96 -- Result row layout local ROW_HEIGHT = 24 local ROW_ICON_SIZE = 18 local SECTION_HEADER_H = 22 --- Pool grows as needed and is reused across queries; the cap exists --- only to prevent unbounded growth on pathological inputs. Global --- searches that match a category (e.g. "fp" → every flight master) --- can easily produce 200+ rows, so the cap needs headroom well past --- the worst real query. -local MAX_ROW_POOL = 1000 +local MAX_ROW_POOL = 300 +local ROW_POOL_RETAIN = 80 +local HEADER_POOL_RETAIN = 40 +local SECTION_POOL_RETAIN = 12 -- Roots stripped from pathPrefix display. Every zone in WoW descends -- from "World", and the vast majority descend from "Azeroth", so those @@ -218,10 +206,13 @@ local tabFrame -- resolves the name as a global and later fails with "attempt to call -- global 'RefreshCurrentSearch' (a nil value)". local RefreshCurrentSearch +local ReleaseMapTabMemory local panel local selectedIsOurs = false local rowPool = {} local headerPool = {} +local rowPoolCursor = 1 +local headerPoolCursor = 1 -- Ephemeral collapse state: scoped to the current query text. Reset -- when the search text changes so fresh matches always default to -- expanded (auto-expand on parent match). User clicks on +/- within @@ -364,109 +355,23 @@ local function HideOurPanel() if ns.MapSearch.ClearHighlight then ns.MapSearch:ClearHighlight() end end end + if ReleaseMapTabMemory then ReleaseMapTabMemory(true) end RefreshSelectGlows() end --- --------------------------------------------------------------------------- --- Pin / Guide right-click popup (mirrors UI.lua) --- --------------------------------------------------------------------------- -local pinPopup - -local function CreateMenuRow(parent) - local row = CreateFrame("Button", nil, parent) - row:SetHeight(PIN_MENU_ROW_H) - row:RegisterForClicks("LeftButtonUp") - local label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal") - label:SetPoint("LEFT", row, "LEFT", 8, 0) - row.label = label - local icon = row:CreateTexture(nil, "OVERLAY") - icon:SetSize(14, 14) - icon:SetPoint("RIGHT", row, "RIGHT", -8, 0) - icon:Hide() - row.icon = icon - row:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD") - return row -end - -local function MenuRowOnLeave() - if pinPopup and not pinPopup:IsMouseOver() then pinPopup:Hide() end -end - local function ShowPopup(isPinned, onPin, onGuide) - if not pinPopup then - pinPopup = CreateFrame("Frame", "EasyFindMapTabPopup", UIParent, "BackdropTemplate") - pinPopup:SetFrameStrata("TOOLTIP") - pinPopup:SetFrameLevel(10000) - pinPopup:SetBackdrop({ - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", - edgeFile = TOOLTIP_BORDER, - tile = true, tileSize = 16, edgeSize = 12, - insets = { left = 2, right = 2, top = 2, bottom = 2 } - }) - if DARK_PANEL_BG then - pinPopup:SetBackdropColor(DARK_PANEL_BG[1], DARK_PANEL_BG[2], DARK_PANEL_BG[3], DARK_PANEL_BG[4]) - end - pinPopup.guideRow = CreateMenuRow(pinPopup) - pinPopup.guideRow.label:SetText("Guide") - pinPopup.guideRow.icon:SetTexture(EYE_ICON_TEX) - pinPopup.guideRow.icon:Show() - pinPopup.pinRow = CreateMenuRow(pinPopup) - pinPopup.pinRow:SetScript("OnLeave", MenuRowOnLeave) - pinPopup.guideRow:SetScript("OnLeave", MenuRowOnLeave) - -- Dismiss on outside click (any button). Same pattern used by - -- the MapSearch filter dropdown. The grace period avoids a race - -- where the press that opened the popup (RightButtonDown) is - -- still registering as held during the first OnUpdate tick — - -- without it, IsMouseButtonDown stays true and the popup hides - -- itself before the cursor settles inside. - pinPopup:SetScript("OnUpdate", function(self) - if not self:IsShown() then return end - if self._openedAt and GetTime() - self._openedAt < 0.15 then return end - if (IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton")) - and not self:IsMouseOver() then - self:Hide() - end - end) - end - - pinPopup.pinRow:Show() - pinPopup.pinRow.label:SetText(isPinned and "Unpin" or "Pin") - pinPopup.pinRow:SetScript("OnClick", function() - pinPopup:Hide() - if onPin then onPin() end - end) - pinPopup.pinRow:ClearAllPoints() - + local rows = {} if onGuide then - pinPopup.guideRow:ClearAllPoints() - pinPopup.guideRow:SetPoint("TOPLEFT", pinPopup, "TOPLEFT", 4, -4) - pinPopup.guideRow:SetPoint("TOPRIGHT", pinPopup, "TOPRIGHT", -4, -4) - pinPopup.guideRow:Show() - pinPopup.guideRow:SetScript("OnClick", function() - pinPopup:Hide() - onGuide() - end) - pinPopup.pinRow:SetPoint("TOPLEFT", pinPopup.guideRow, "BOTTOMLEFT", 0, 0) - pinPopup.pinRow:SetPoint("TOPRIGHT", pinPopup.guideRow, "BOTTOMRIGHT", 0, 0) - pinPopup:SetSize(PIN_MENU_WIDTH, PIN_MENU_ROW_H * 2 + 8) - else - pinPopup.guideRow:Hide() - pinPopup.guideRow:SetScript("OnClick", nil) - pinPopup.pinRow:SetPoint("TOPLEFT", pinPopup, "TOPLEFT", 4, -4) - pinPopup.pinRow:SetPoint("TOPRIGHT", pinPopup, "TOPRIGHT", -4, -4) - pinPopup:SetSize(PIN_MENU_WIDTH, PIN_MENU_ROW_H + 8) - end - - local scale = UIParent:GetEffectiveScale() - local x, y = GetCursorPosition() - pinPopup:ClearAllPoints() - -- Offset so the cursor lands ~8px inside the popup rather than at - -- its bottom-left corner. Without the offset, IsMouseOver flickers - -- at the boundary pixel and the auto-dismiss handler can hide the - -- popup on the same frame it appears. - pinPopup:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", x / scale - 8, y / scale - 8) - pinPopup._openedAt = GetTime() - pinPopup:Show() + rows[#rows + 1] = { text = "Guide", icon = EYE_ICON_TEX, onClick = onGuide } + end + rows[#rows + 1] = { text = isPinned and "Unpin" or "Pin", onClick = onPin } + Utils.ShowCursorMenu("EasyFindMapTabPopup", rows, { + width = 96, + rowHeight = 22, + offsetX = -8, + offsetY = -8, + clickGrace = 0.15, + }) end -- --------------------------------------------------------------------------- @@ -556,25 +461,7 @@ local function SetRowIcon(row, data) if icon == nil and data.category and ns.MapSearch and ns.MapSearch.GetCategoryIcon then icon = ns.MapSearch.GetCategoryIcon(data.category) end - row.icon:SetTexCoord(0, 1, 0, 1) - if type(icon) == "string" and icon:sub(1, 6) == "atlas:" then - row.icon:SetTexture(nil) - row.icon:SetAtlas(icon:sub(7), false) - elseif type(icon) == "number" then - row.icon:SetAtlas(nil) - row.icon:SetTexture(icon) - elseif type(icon) == "string" then - row.icon:SetAtlas(nil) - row.icon:SetTexture(icon) - elseif type(icon) == "table" and icon.file then - row.icon:SetAtlas(nil) - row.icon:SetTexture(icon.file) - if icon.coords then - row.icon:SetTexCoord(icon.coords[1], icon.coords[2], icon.coords[3], icon.coords[4]) - end - else - row.icon:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark") - end + Utils.SetIconTexture(row.icon, icon) end -- Timestamp of the most recent keystroke in the search box. Hover @@ -715,20 +602,24 @@ local function RowOnLeave(row) end local function AcquireRow(parent) - for i = 1, #rowPool do - local row = rowPool[i] - if not row:IsShown() then row:SetParent(parent); return row end + local row = rowPool[rowPoolCursor] + if row then + row:SetParent(parent) + rowPoolCursor = rowPoolCursor + 1 + return row end if #rowPool >= MAX_ROW_POOL then return nil end - local row = CreateResultRow(parent) + row = CreateResultRow(parent) row:SetScript("OnClick", RowOnClick) row:SetScript("OnEnter", RowOnEnter) row:SetScript("OnLeave", RowOnLeave) tinsert(rowPool, row) + rowPoolCursor = rowPoolCursor + 1 return row end local function ReleaseAllRows() + rowPoolCursor = 1 for i = 1, #rowPool do rowPool[i]:Hide(); rowPool[i].data = nil end end @@ -754,22 +645,23 @@ local function CreateSectionLabel(parent) end local sectionLabelPool = {} +local sectionLabelPoolCursor = 1 local function AcquireSectionLabel(parent, labelText) - local hdr - for i = 1, #sectionLabelPool do - local h = sectionLabelPool[i] - if not h:IsShown() then hdr = h; h:SetParent(parent); break end - end + local hdr = sectionLabelPool[sectionLabelPoolCursor] if not hdr then hdr = CreateSectionLabel(parent) tinsert(sectionLabelPool, hdr) + else + hdr:SetParent(parent) end + sectionLabelPoolCursor = sectionLabelPoolCursor + 1 hdr.label:SetText(labelText or "") return hdr end local function ReleaseAllSectionLabels() + sectionLabelPoolCursor = 1 for i = 1, #sectionLabelPool do sectionLabelPool[i]:Hide() end end @@ -881,15 +773,14 @@ end -- onRightClick: called when the header body is right-clicked. Used to -- open the pin/unpin popup for the header's navigateData. local function AcquireGroupHeader(parent, labelText, groupKey, count, collapsed, navigateData, hasChildren, onToggle, onRightClick) - local hdr - for i = 1, #headerPool do - local h = headerPool[i] - if not h:IsShown() then hdr = h; h:SetParent(parent); break end - end + local hdr = headerPool[headerPoolCursor] if not hdr then hdr = CreateGroupHeader(parent) tinsert(headerPool, hdr) + else + hdr:SetParent(parent) end + headerPoolCursor = headerPoolCursor + 1 hdr.label:SetText(labelText or "") hdr.toggleBtn.icon:SetAtlas(collapsed and "QuestLog-icon-expand" or "QuestLog-icon-shrink") -- Hide the toggle button entirely when there are no children to @@ -928,10 +819,79 @@ local function AcquireGroupHeader(parent, labelText, groupKey, count, collapsed, end local function ReleaseAllHeaders() + headerPoolCursor = 1 for i = 1, #headerPool do headerPool[i]:Hide() end ReleaseAllSectionLabels() end +local function DetachFrame(frame) + if frame and frame.SetParent then + pcall(frame.SetParent, frame, nil) + end +end + +local function TrimRowPool() + for i = #rowPool, ROW_POOL_RETAIN + 1, -1 do + local row = rowPool[i] + if row then + row:Hide() + row.data = nil + DetachFrame(row) + end + rowPool[i] = nil + end + rowPoolCursor = 1 +end + +local function TrimHeaderPool() + for i = #headerPool, HEADER_POOL_RETAIN + 1, -1 do + local hdr = headerPool[i] + if hdr then + hdr:Hide() + hdr.navigateData = nil + hdr.groupKey = nil + DetachFrame(hdr) + end + headerPool[i] = nil + end + for i = #sectionLabelPool, SECTION_POOL_RETAIN + 1, -1 do + local hdr = sectionLabelPool[i] + if hdr then + hdr:Hide() + DetachFrame(hdr) + end + sectionLabelPool[i] = nil + end + headerPoolCursor = 1 + sectionLabelPoolCursor = 1 +end + +ReleaseMapTabMemory = function(trimFrames) + if pendingSearchTimer then + pendingSearchTimer:Cancel() + pendingSearchTimer = nil + end + ReleaseAllRows() + ReleaseAllHeaders() + if panel and panel.scrollChild then panel.scrollChild:SetHeight(1) end + if panel and panel.emptyMsg then panel.emptyMsg:Hide() end + if panel then + panel.topResultName = nil + panel.topResultCandidates = nil + end + navRowIndex = 0 + if visibleNavRows then wipe(visibleNavRows) end + if navFrame then Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) end + if navKeyRepeat then navKeyRepeat.Stop() end + if trimFrames then + TrimRowPool() + TrimHeaderPool() + end + if ns.MapSearch and ns.MapSearch.ReleaseIdleSearchMemory then + ns.MapSearch:ReleaseIdleSearchMemory() + end +end + -- --------------------------------------------------------------------------- -- Render -- --------------------------------------------------------------------------- @@ -943,13 +903,6 @@ local function BuildPinnedSection() return pins end --- Group a result list by pathPrefix (immediate parent zone name). --- Groups with <2 members render flat (pathPrefix stays inline); groups --- with 2+ members promote to a collapsible parent header with indented --- children (pathPrefix suppressed on the children). --- Returns an ordered array: { {type="flat", data=r} | {type="group", name=p, items={...}} } -local GROUP_THRESHOLD = 2 - local function GroupBySharedParent(results) if not results or #results == 0 then return {} end local order, groups = {}, {} @@ -2062,17 +2015,7 @@ local function CreateSearchBox(parent) -- is truly empty after pressing X. if editBox.clearButton then editBox.clearButton:HookScript("OnClick", function() - if pendingSearchTimer then - pendingSearchTimer:Cancel(); pendingSearchTimer = nil - end - ReleaseAllRows() - ReleaseAllHeaders() - if panel and panel.scrollChild then panel.scrollChild:SetHeight(1) end - if panel and panel.emptyMsg then panel.emptyMsg:Hide() end - navRowIndex = 0 - if visibleNavRows then wipe(visibleNavRows) end - if navFrame then Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) end - if navKeyRepeat then navKeyRepeat.Stop() end + if ReleaseMapTabMemory then ReleaseMapTabMemory(false) end end) end @@ -2098,105 +2041,25 @@ local function CreateSearchBox(parent) end editBox.FindPrefixCandidate = FindPrefixCandidate - -- Inline autocomplete using SetText + HighlightText (Chrome-style). - -- The displayed text is the full suggestion, the cursor sits at the - -- end of the user's typed prefix, and the trailing suggestion is - -- selected. WoW's native caret renders at the cursor position with - -- nothing layered over it, so the caret is always visible at the - -- end of what the user typed. Tab confirms; typing replaces the - -- selection (advancing through the suggestion); backspace deletes - -- the selection without re-applying autocomplete. - -- - -- typedText: the user's actual typed prefix (search query) - -- programmatic: re-entrancy guard for our own SetText calls - -- lastWasAddition: true if the last text change extended the typed - -- prefix; gates whether we re-apply autocomplete - -- after the next RunSearch settles - local typedText = "" - local programmatic = false - local lastWasAddition = false - - local function StripAutocomplete() - local current = editBox:GetText() or "" - if current == typedText then return end - programmatic = true - editBox:SetText(typedText) - editBox:SetCursorPosition(#typedText) - editBox:HighlightText(0, 0) - programmatic = false - end - - local function ApplyAutocomplete() - if programmatic or typedText == "" or not editBox:HasFocus() then - return - end - local candidate = FindPrefixCandidate(typedText) - if not candidate or candidate:lower() == typedText:lower() then - StripAutocomplete() - return - end - local suffix = candidate:sub(#typedText + 1):lower() - if suffix == "" then StripAutocomplete(); return end - local fullText = typedText .. suffix - if editBox:GetText() == fullText then - -- Already showing this suggestion. Just reassert the highlight - -- in case focus loss / regain dropped it. - editBox:SetCursorPosition(#typedText) - editBox:HighlightText(#typedText, #fullText) - return - end - programmatic = true - editBox:SetText(fullText) - editBox:SetCursorPosition(#typedText) - editBox:HighlightText(#typedText, #fullText) - programmatic = false - end - editBox.UpdateAutocomplete = ApplyAutocomplete - editBox.GetTypedText = function() return typedText end - - editBox:HookScript("OnTextChanged", function(self) - if programmatic then return end - -- Hover-preview suppression: re-rendering rows under a stationary - -- cursor would otherwise fire spurious OnEnter events as the user - -- types. - lastTypeTime = GetTime() - UpdateClear(self) - - local current = self:GetText() or "" - local cursorPos = self:GetCursorPosition() - -- The "typed" prefix is everything up to the cursor; anything - -- after is autocomplete suffix the user hasn't accepted. - local typed = current:sub(1, cursorPos) - -- Pressing an arrow key while the autocomplete suffix is - -- highlighted collapses (or deletes) the selection without - -- changing the user's typed prefix. WoW fires OnTextChanged - -- on that selection edit, and if we let it reschedule a - -- search, RunSearch fires on the next frame, RenderRows - -- resets navRowIndex to 0, and the row we just navigated to - -- with HandleNavKey loses its highlight. Bail when the prefix - -- is unchanged: there's nothing new to search. - if typed == typedText then return end - lastWasAddition = #typed > #typedText - typedText = typed - - if pendingSearchTimer then pendingSearchTimer:Cancel(); pendingSearchTimer = nil end - local snapshot = typed - pendingSearchTimer = C_Timer.NewTimer(0, function() - pendingSearchTimer = nil - MapTab:RunSearch(snapshot) - -- Re-apply autocomplete only when the user added characters. - -- For deletions / no-ops, leave the text alone so backspace - -- actually removes the suggestion instead of re-conjuring it. - if lastWasAddition then ApplyAutocomplete() end - lastWasAddition = false - end) - end) + Utils.AttachAutocomplete(editBox, { + findCandidate = FindPrefixCandidate, + onTypedChanged = function(self, typed, _, grew) + lastTypeTime = GetTime() + UpdateClear(self) + if pendingSearchTimer then pendingSearchTimer:Cancel(); pendingSearchTimer = nil end + pendingSearchTimer = C_Timer.NewTimer(0, function() + pendingSearchTimer = nil + MapTab:RunSearch(typed) + if grew and self.UpdateAutocomplete then self:UpdateAutocomplete() end + end) + end, + onAccepted = function(text) + MapTab:PushRecentSearch(text) + end, + }) editBox:HookScript("OnEditFocusGained", UpdateClear) editBox:HookScript("OnEditFocusLost", function(self) UpdateClear(self) - -- Strip any pending autocomplete suffix so the editbox shows the - -- user's actual typed text when unfocused. - StripAutocomplete() end) -- Keyboard nav: consume arrow / Ctrl+J/K / Esc while the editbox @@ -2258,33 +2121,16 @@ local function CreateSearchBox(parent) -- If a row is highlighted, HandleNavKey already activated it on -- OnKeyDown(ENTER) and we shouldn't also commit to recents. if navRowIndex > 0 then return end - -- Enter accepts any active autocomplete suggestion the same way - -- Tab does, then commits the full text. local current = self:GetText() or "" - if current ~= "" and current ~= typedText then - programmatic = true - self:SetCursorPosition(#current) - self:HighlightText(0, 0) - programmatic = false - typedText = current - end - MapTab:PushRecentSearch(current) + local typed = self.GetTypedText and self:GetTypedText() or current + local accepted = false + if current ~= "" and current ~= typed and self.AcceptAutocomplete then + accepted = self:AcceptAutocomplete() + current = self:GetText() or "" + end + if not accepted then MapTab:PushRecentSearch(current) end self:ClearFocus() end) - -- Tab confirms the highlighted autocomplete: cursor jumps to the end - -- of the full text and the highlight is cleared, locking in the - -- suggestion. The typed prefix is updated to match the now-accepted - -- text so subsequent typing extends it cleanly. - editBox:HookScript("OnTabPressed", function(self) - local current = self:GetText() or "" - if current == "" or current == typedText then return end - programmatic = true - self:SetCursorPosition(#current) - self:HighlightText(0, 0) - programmatic = false - typedText = current - MapTab:PushRecentSearch(current) - end) return editBox end @@ -2550,15 +2396,15 @@ local function CreatePanel(qmf) searchBox:SetPoint("RIGHT", cog, "LEFT", -6, 0) p.searchBox = searchBox - -- Filter dropdown anchored to the cog. Skip the legacy MapSearch - -- refresh path (searchEditBox=nil) since we're not using the - -- deprecated floating dropdown; our onChanged callback handles it. if ns.MapSearch and ns.MapSearch.CreateFilterDropdown then local dropdown dropdown = ns.MapSearch:CreateFilterDropdown( "EasyFindMapTabFilterDropdown", FILTER_OPTIONS, - "mapTabFilters", cog, outer, nil, + "mapTabFilters", cog, outer, function(key) + if key == "flightpath" and ns.MapSearch and ns.MapSearch.ReleaseIdleSearchMemory then + ns.MapSearch:ReleaseIdleSearchMemory() + end RefreshCurrentSearch() if key == "rares" and dropdown.UpdateAutoTrackRow then dropdown:UpdateAutoTrackRow() @@ -2774,12 +2620,6 @@ function MapTab:Focus() end) end --- Activate the MapTab and populate the search box with `query`, leaving --- the editbox unfocused. Used by the floating UI search bar so clicking --- a map result there mirrors what would happen if the same query had --- been typed and submitted from inside the MapTab. SetText fires --- OnTextChanged, which schedules RunSearch on the next frame, so result --- rows render automatically. function MapTab:OpenWithQuery(query) if not initialized then self:Initialize() end if not WorldMapFrame or not WorldMapFrame:IsShown() then @@ -2926,6 +2766,13 @@ function MapTab:Initialize() end) end end) + WorldMapFrame:HookScript("OnHide", function() + if selectedIsOurs then + HideOurPanel() + elseif ReleaseMapTabMemory then + ReleaseMapTabMemory(true) + end + end) end -- Hide our tab in fullscreen map (matches Blizzard's tab behavior). diff --git a/Options.lua b/Options.lua index b207991..c929852 100644 --- a/Options.lua +++ b/Options.lua @@ -16,6 +16,7 @@ local DARK_PANEL_BG = ns.DARK_PANEL_BG local optionsFrame local isInitialized = false +local blizzardRegistered = false local FRAME_BACKDROP = { edgeFile = TOOLTIP_BORDER, @@ -23,6 +24,194 @@ local FRAME_BACKDROP = { insets = { left = 4, right = 4, top = 4, bottom = 4 }, } +local NIL = {} +local DEFAULT_UI_FILTERS = { + ui = true, achievements = true, currencies = true, reputations = true, + collections = true, mounts = true, toys = true, pets = true, + outfits = true, heirlooms = true, loot = true, appearanceSets = true, + bags = true, macros = true, options = true, abilities = true, + bosses = true, map = true, +} +local DEFAULT_GLOBAL_SEARCH_FILTERS = { zones = true, dungeons = true, raids = true, delves = true } +local DEFAULT_LOCAL_SEARCH_FILTERS = { instances = true, travel = true, services = true, rares = true } + +local UI_DEFAULTS = { + smartShow = false, + autoHide = true, + lockPosition = false, + staticOpacity = false, + uiResultsAbove = false, + fontSize = 0.9, + uiSearchScale = 1.0, + uiSearchWidth = 1.54, + uiResultsScale = 1.0, + uiResultsWidth = 350, + uiSearchPosition = NIL, + uiResultsHeight = 280, + uiSearchFilters = DEFAULT_UI_FILTERS, + lootSpecs = NIL, + lootSearchSlots = true, + lootSearchStats = true, + lootUpgradesOnly = false, + lootDifficulty = "normal", + appearanceSetClass = NIL, + appearanceSetCollected = true, + appearanceSetNotCollected = true, + appearanceSetPvE = true, + appearanceSetPvP = true, + uiMapSearchLocal = true, +} + +local MAP_DEFAULTS = { + iconScale = 0.8, + mapPinHighlight = true, + blinkingPins = false, + autoPinClear = true, + autoTrackPins = true, + globalSearchFilters = DEFAULT_GLOBAL_SEARCH_FILTERS, + localSearchFilters = DEFAULT_LOCAL_SEARCH_FILTERS, + alwaysShowRares = false, +} + +local GENERAL_DEFAULTS = { + searchBarOpacity = DEFAULT_OPACITY, + tutorialDone = false, + resultsTheme = "Modern", + font = "Default", + indicatorStyle = "EasyFind Arrow", + indicatorColor = "Yellow", + showLoginMessage = true, + showMinimapButton = true, + minimapButtonAngle = 200, + panelOpacity = 0.9, + visible = true, + enableUISearch = true, + enableMapSearch = true, + nativePinScale = 1.5, + pinnedUIItems = {}, + pinnedUIItemsPerChar = {}, + pinnedMapItems = {}, + optionsPosition = NIL, +} + +local UI_POSITION_DEFAULTS = { + uiSearchPosition = NIL, + uiSearchScale = 1.0, + uiSearchWidth = 1.54, + uiResultsScale = 1.0, + uiResultsWidth = 350, +} + +local function CloneTable(src) + local dst = {} + for k, v in pairs(src) do + dst[k] = type(v) == "table" and CloneTable(v) or v + end + return dst +end + +local function ApplyDefaults(defaults) + for key, value in pairs(defaults) do + if value == NIL then + EasyFind.db[key] = nil + elseif type(value) == "table" then + EasyFind.db[key] = CloneTable(value) + else + EasyFind.db[key] = value + end + end +end + +local function ResetOptionsPosition() + EasyFind.db.optionsPosition = nil + if optionsFrame then + optionsFrame:ClearAllPoints() + optionsFrame:SetPoint("TOP", UIParent, "TOP", 0, -100) + end +end + +local function ClearMapRuntime() + if not ns.MapSearch then return end + pcall(ns.MapSearch.ClearAll, ns.MapSearch) + pcall(ns.MapSearch.ClearZoneHighlight, ns.MapSearch) + ns.MapSearch.pendingWaypoint = nil +end + +local function RefreshUIRuntime(resetPosition) + if ns.Highlight and ns.Highlight.ClearAll then pcall(ns.Highlight.ClearAll, ns.Highlight) end + if not (_G["EasyFindSearchFrame"] and ns.UI) then return end + if resetPosition and ns.UI.ResetPosition then ns.UI:ResetPosition() end + if ns.UI.UpdateScale then ns.UI:UpdateScale() end + if ns.UI.UpdateWidth then ns.UI:UpdateWidth() end + if ns.UI.UpdateOpacity then ns.UI:UpdateOpacity() end + if ns.UI.UpdateSmartShow then ns.UI:UpdateSmartShow() end + if ns.UI.UpdateFontSize then ns.UI:UpdateFontSize() end + if ns.UI.RefreshResults then ns.UI:RefreshResults() end +end + +local function RefreshMapRuntime() + ClearMapRuntime() + if ns.MapSearch then + if ns.MapSearch.UpdateIconScales then ns.MapSearch:UpdateIconScales() end + if ns.MapSearch.RefreshIndicators then ns.MapSearch:RefreshIndicators() end + end + local uiInd = _G["EasyFindIndicatorFrame"] + if uiInd then uiInd:SetScale(EasyFind.db.iconScale or 0.8) end +end + +local function ClearBinding(action) + local old1, old2 = GetBindingKey(action) + if old1 then SetBinding(old1) end + if old2 then SetBinding(old2) end +end + +local function SyncOptionControls() + if not optionsFrame then return end + + if optionsFrame.panelOpacitySlider then optionsFrame.panelOpacitySlider:SetValue(EasyFind.db.panelOpacity or 0.9) end + if optionsFrame.opacitySlider then optionsFrame.opacitySlider:SetValue(EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) end + if optionsFrame.uiFontSlider then optionsFrame.uiFontSlider:SetValue(EasyFind.db.fontSize or 0.9) end + if optionsFrame.mapIconSlider then optionsFrame.mapIconSlider:SetValue(EasyFind.db.iconScale or 0.8) end + + if optionsFrame.autoHideCheckbox then optionsFrame.autoHideCheckbox:SetChecked(EasyFind.db.autoHide ~= false) end + if optionsFrame.smartShowCheckbox then + local autoOn = EasyFind.db.autoHide ~= false + optionsFrame.smartShowCheckbox:SetChecked(EasyFind.db.smartShow or false) + optionsFrame.smartShowCheckbox:SetEnabled(not autoOn) + local lbl = optionsFrame.smartShowCheckbox:GetFontString() + if lbl then + local g = autoOn and 0.5 or 1 + lbl:SetTextColor(g, g, g) + end + end + if optionsFrame.staticOpacityCheckbox then optionsFrame.staticOpacityCheckbox:SetChecked(EasyFind.db.staticOpacity or false) end + if optionsFrame.lockPositionCheckbox then optionsFrame.lockPositionCheckbox:SetChecked(EasyFind.db.lockPosition or false) end + if optionsFrame.loginMessageCheckbox then optionsFrame.loginMessageCheckbox:SetChecked(EasyFind.db.showLoginMessage ~= false) end + if optionsFrame.uiResultsAboveCheckbox then optionsFrame.uiResultsAboveCheckbox:SetChecked(EasyFind.db.uiResultsAbove or false) end + if optionsFrame.minimapBtnCheckbox then optionsFrame.minimapBtnCheckbox:SetChecked(EasyFind.db.showMinimapButton ~= false) end + if optionsFrame.rareTrackCheckbox then optionsFrame.rareTrackCheckbox:SetChecked(EasyFind.db.alwaysShowRares or false) end + + if optionsFrame.mapPinGroup then optionsFrame.mapPinGroup:UpdateVisuals() end + if optionsFrame.automationGroup then optionsFrame.automationGroup:UpdateVisuals() end + if optionsFrame.UpdateUIToggleVisual then optionsFrame.UpdateUIToggleVisual() end + if optionsFrame.UpdateMapToggleVisual then optionsFrame.UpdateMapToggleVisual() end + + if optionsFrame.indicatorBtnText then optionsFrame.indicatorBtnText:SetText(EasyFind.db.indicatorStyle or "EasyFind Arrow") end + if optionsFrame.fontBtnText then optionsFrame.fontBtnText:SetText(EasyFind.db.font or "Default") end + + local clr = EasyFind.db.indicatorColor or "Yellow" + local rgb = ns.INDICATOR_COLORS[clr] or ns.INDICATOR_COLORS.Yellow + if optionsFrame.colorBtnText then + optionsFrame.colorBtnText:SetText(clr) + optionsFrame.colorBtnText:SetTextColor(rgb[1], rgb[2], rgb[3]) + end + if optionsFrame.colorSwatch then optionsFrame.colorSwatch:SetColorTexture(rgb[1], rgb[2], rgb[3], 1) end + + if optionsFrame.toggleFocusBtn then optionsFrame.toggleFocusBtn:SetText(GetBindingKey("EASYFIND_TOGGLE_FOCUS") or "Not Bound") end + if optionsFrame.mapFocusBtn then optionsFrame.mapFocusBtn:SetText(GetBindingKey("EASYFIND_MAP_FOCUS") or "Not Bound") end + if optionsFrame.clearBtn then optionsFrame.clearBtn:SetText(GetBindingKey("EASYFIND_CLEAR") or "Not Bound") end +end + -- Shared backdrop for selector buttons and flyout panels local SELECTOR_BACKDROP = { bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", @@ -961,9 +1150,6 @@ function Options:Initialize() if ns.UI and ns.UI.UpdateOpacity then ns.UI:UpdateOpacity() end - if ns.MapSearch and ns.MapSearch.UpdateOpacity then - ns.MapSearch:UpdateOpacity() - end end) optionsFrame.opacitySlider = opacitySlider @@ -1038,7 +1224,7 @@ function Options:Initialize() optionsFrame.autoHideCheckbox = autoHideCheckbox local smartShowCheckbox = CreateCheckbox(sec1, "SmartShow", "Smart Show", - "When enabled, the UI search bar hides itself until you move your mouse near its position.\n\nThe bar reappears when your mouse enters the area and fades away when you move away.\n\nIgnored when Auto-Hide is enabled. A separate Smart Show toggle for map search bars is available in the Map Search section.") + "When enabled, the UI search bar hides itself until you move your mouse near its position.\n\nThe bar reappears when your mouse enters the area and fades away when you move away.\n\nIgnored when Auto-Hide is enabled.") smartShowCheckbox:SetPoint("TOPLEFT", autoHideCheckbox, "BOTTOMLEFT", 0, -2) smartShowCheckbox:SetChecked(EasyFind.db.smartShow or false) smartShowCheckbox:SetScript("OnClick", function(self) @@ -1084,22 +1270,9 @@ function Options:Initialize() end) optionsFrame.uiResultsAboveCheckbox = uiResultsAboveCheckbox - local hideHeadersCheckbox = CreateCheckbox(sec1, "HideHeaders", "Flat Results (no headers)", - "Show results as a single flat list with no category headers. Each row displays the entry name with its full path as subtext underneath.") - hideHeadersCheckbox:SetPoint("TOPLEFT", uiResultsAboveCheckbox, "BOTTOMLEFT", 0, -2) - hideHeadersCheckbox:SetChecked(EasyFind.db.uiHideHeaders ~= false) - hideHeadersCheckbox:SetScript("OnClick", function(self) - EasyFind.db.uiHideHeaders = self:GetChecked() - -- Re-run the search so results rebuild in the chosen layout mode. - if ns.UI and ns.UI.RebuildOpenResults then - ns.UI:RebuildOpenResults() - end - end) - optionsFrame.hideHeadersCheckbox = hideHeadersCheckbox - local uiFontSlider = CreateSlider(sec1, "UIFontSize", "Font Size|cffff3333*|r", 0.5, 2.0, 0.1, "Changing font size also affects search bar height and results window sizing.", nil, 1.0) - uiFontSlider:SetPoint("TOPLEFT", hideHeadersCheckbox, "BOTTOMLEFT", 4, -20) + uiFontSlider:SetPoint("TOPLEFT", uiResultsAboveCheckbox, "BOTTOMLEFT", 4, -20) uiFontSlider:SetValue(EasyFind.db.fontSize or 1.0) uiFontSlider:HookScript("OnValueChanged", function(self, value) EasyFind.db.fontSize = value @@ -1125,14 +1298,14 @@ function Options:Initialize() StaticPopup_Show("EASYFIND_RESET_UI_POS") end) - uiControls = { resizeUIBtn, resetUIBtn, resetUIPosBtn, uiFontSlider, autoHideCheckbox, smartShowCheckbox, staticOpacityCheckbox, lockPositionCheckbox, uiResultsAboveCheckbox, hideHeadersCheckbox } + uiControls = { resizeUIBtn, resetUIBtn, resetUIPosBtn, uiFontSlider, autoHideCheckbox, smartShowCheckbox, staticOpacityCheckbox, lockPositionCheckbox, uiResultsAboveCheckbox } UpdateUIToggleVisual() -- SECTION 3: Map Search local sec2 = CreateTab("Map") local mapEnableCheckbox = CreateCheckbox(sec2, "EnableMap", "Enable Map Search Module", - "Uncheck to disable map search bars, pins, and all map overlay features.\n\nRequires a UI reload to take effect.") + "Uncheck to disable map search, pins, and map overlay features.\n\nRequires a UI reload to take effect.") mapEnableCheckbox:SetPoint("TOPLEFT", sec2, "TOPLEFT", COL_LEFT, -6) mapEnableCheckbox:SetChecked(EasyFind.db.enableMapSearch ~= false) @@ -1177,9 +1350,6 @@ function Options:Initialize() end) optionsFrame.rareTrackCheckbox = rareTrackCheckbox - -- Resize Map Search button removed: floating map search bars are retired - -- and the new MapTab uses the Blizzard quest-log-tab sizing. - local FLYOUT_W = 260 local mapTabGroup = CreateMultiSelectDropdown(sec2, "Map Tab", { @@ -1240,57 +1410,19 @@ function Options:Initialize() end) optionsFrame.mapIconSlider = mapIconSlider - local minimapGroup = CreateMultiSelectDropdown(sec2, "Minimap", { - { label = "Arrow Glow", shortLabel = "Arrow", dbKey = "minimapArrowGlow", default = false, - tooltip = "A pulsing glow highlights the minimap perimeter arrow that points toward your active map pin.\nDisable if you find the glow distracting." }, - { label = "Only EasyFind Pins", dbKey = "glowOnlyEasyFind", default = false, indent = true, hiddenFromSummary = true, parentDbKey = "minimapArrowGlow", - tooltip = "When enabled, the arrow glow only appears for waypoints placed by EasyFind (clicking search results or navigation pins).\nWaypoints placed by Ctrl+clicking the map or other means will not show the glow." }, - { label = "Guide Circle", shortLabel = "Circle", dbKey = "minimapGuideCircle", default = false, - tooltip = "A directional ring and arrow appears around your character on the minimap when a map pin is nearby, pointing toward the destination.\nDisable if you prefer only the default minimap pin." }, - { label = "Only EasyFind Pins", dbKey = "circleOnlyEasyFind", default = false, indent = true, hiddenFromSummary = true, parentDbKey = "minimapGuideCircle", - tooltip = "When enabled, the guide circle only appears for waypoints placed by EasyFind.\nWaypoints placed by Ctrl+clicking the map or other means will not show the circle." }, - { label = "Pin Glow", shortLabel = "Glow", dbKey = "minimapPinGlow", default = false, indent = true, parentDbKey = "minimapGuideCircle", - tooltip = "A pulsing glow appears on the minimap pin when the guide circle shrinks onto it.\nDisable if you find the glow distracting." }, - }, nil, FLYOUT_W) - minimapGroup:SetPoint("TOPLEFT", mapPinGroup, "BOTTOMLEFT", 0, -6) - minimapGroup:SetPoint("RIGHT", sec2, "RIGHT", -8, 0) - minimapGroup.label:SetWidth(85) - minimapGroup.label:SetJustifyH("LEFT") - minimapGroup.button:SetPoint("LEFT", minimapGroup.label, "RIGHT", 6, 0) - optionsFrame.minimapGroup = minimapGroup - - local circleScaleSlider = minimapGroup:AddSlider("CircleScale", "Guide Circle Size", 0.5, 2.0, 0.1, - "Adjusts the size of the minimap guide circle and arrow that appears when tracking a map pin.", - nil, 1.0, nil, "minimapGuideCircle") - circleScaleSlider:SetValue(EasyFind.db.guideCircleScale or 1.0) - circleScaleSlider:HookScript("OnValueChanged", function(self, value) - EasyFind.db.guideCircleScale = value - end) - optionsFrame.circleScaleSlider = circleScaleSlider - local automationGroup = CreateMultiSelectDropdown(sec2, "Map Pins", { { label = "Auto Track Map Pins", shortLabel = "Track", dbKey = "autoTrackPins", default = true, - tooltip = "Placing a map pin (Ctrl+Click) automatically starts tracking it on the minimap.\nWhen disabled, you must click the pin to start tracking." }, + tooltip = "Placing a map pin (Ctrl+Click) automatically starts Blizzard waypoint tracking.\nWhen disabled, you must click the pin to start tracking." }, { label = "Auto Clear Map Pins", shortLabel = "Clear", dbKey = "autoPinClear", default = true, - tooltip = "Your map pin is automatically cleared when you arrive at the destination.\nDisable if you prefer to clear pins manually." }, + tooltip = "Your map pin is automatically cleared when Blizzard reports arrival.\nDisable if you prefer to clear pins manually." }, }, nil, FLYOUT_W) - automationGroup:SetPoint("TOPLEFT", minimapGroup, "BOTTOMLEFT", 0, -6) + automationGroup:SetPoint("TOPLEFT", mapPinGroup, "BOTTOMLEFT", 0, -6) automationGroup:SetPoint("RIGHT", sec2, "RIGHT", -8, 0) automationGroup.label:SetWidth(85) automationGroup.label:SetJustifyH("LEFT") automationGroup.button:SetPoint("LEFT", automationGroup.label, "RIGHT", 6, 0) optionsFrame.automationGroup = automationGroup - local arrivalSlider = automationGroup:AddSlider("ArrivalDist", "Arrival Distance", 3, 50, 1, - "How close (in yards) you must be to a tracked location before the waypoint auto-clears.", - function(val) return tostring(mfloor(val + 0.5)) .. "yd" end, 10, "yd", "autoPinClear") - arrivalSlider:SetValue(EasyFind.db.arrivalDistance or 10) - arrivalSlider:HookScript("OnValueChanged", function(self, value) - value = mfloor(value + 0.5) - EasyFind.db.arrivalDistance = value - end) - optionsFrame.arrivalSlider = arrivalSlider - local resetMapBtn = CreateFrame("Button", nil, sec2, "UIPanelButtonTemplate") resetMapBtn:SetSize(RESET_BTN_W, 20) resetMapBtn:SetPoint("BOTTOMLEFT", sec2, "BOTTOMLEFT", 8, 8) @@ -1299,17 +1431,9 @@ function Options:Initialize() StaticPopup_Show("EASYFIND_RESET_MAP") end) - local resetMapPosBtn = CreateFrame("Button", nil, sec2, "UIPanelButtonTemplate") - resetMapPosBtn:SetSize(RESET_BTN_W, 20) - resetMapPosBtn:SetPoint("LEFT", resetMapBtn, "RIGHT", 8, 0) - resetMapPosBtn:SetText("Reset Positions") - resetMapPosBtn:SetScript("OnClick", function() - StaticPopup_Show("EASYFIND_RESET_MAP_POS") - end) - mapControls = { - resetMapBtn, resetMapPosBtn, rareTrackCheckbox, - mapPinGroup, minimapGroup, automationGroup + resetMapBtn, rareTrackCheckbox, + mapPinGroup, automationGroup } UpdateMapToggleVisual() @@ -1331,8 +1455,7 @@ function Options:Initialize() .. "|cFF00FF00Up/Down|r or |cFF00FF00Ctrl+K/J|r Move through results\n" .. "|cFF00FF00Tab/Shift+Tab|r or |cFF00FF00Ctrl+L/H|r Cycle focus to nav buttons\n" .. "|cFF00FF00PgUp/PgDn|r jump 5 |cFF00FF00Home/End|r first/last\n" - .. "|cFF00FF00Shift+Up/Down|r or |cFF00FF00Ctrl+Shift+K/J|r jump section\n" - .. "|cFF00FF00Ctrl+Tab|r switch local/global map search bar\n\n" + .. "|cFF00FF00Shift+Up/Down|r or |cFF00FF00Ctrl+Shift+K/J|r jump section\n\n" .. "|cFFFFD100Other:|r\n" .. "|cFF00FF00Shift+Drag|r reposition |cFF00FF00Right-click|r pin/unpin\n" .. "|cFF00FF00/ef toggle|r toggle the search bar\n" @@ -1498,7 +1621,7 @@ function Options:Initialize() } StaticPopupDialogs["EASYFIND_DISABLE_MAP_SEARCH"] = { - text = "Disable Map Search?\n\nThis will remove map search bars, pins, and all map overlay features. You can re-enable it later from options.", + text = "Disable Map Search?\n\nThis will remove map search, pins, and map overlay features. You can re-enable it later from options.", button1 = "Disable", button2 = "Cancel", OnAccept = function() @@ -1507,9 +1630,6 @@ function Options:Initialize() if ns.MapSearch then pcall(ns.MapSearch.ClearAll, ns.MapSearch) pcall(ns.MapSearch.ClearZoneHighlight, ns.MapSearch) - if ns.MapSearch.HideSuperTrackGlow then - pcall(ns.MapSearch.HideSuperTrackGlow) - end end StaticPopup_Show("EASYFIND_RELOAD_PROMPT") end, @@ -1563,17 +1683,6 @@ function Options:Initialize() preferredIndex = 3, } - StaticPopupDialogs["EASYFIND_RESET_MAP_POS"] = { - text = "Reset Map Search positions to defaults?", - button1 = "Reset", - button2 = "Cancel", - OnAccept = function() Options:DoResetMapPositions() end, - timeout = 0, - whileDead = true, - hideOnEscape = true, - preferredIndex = 3, - } - -- Reset buttons inside General tab (below last slider) local resetAllBtn = CreateFrame("Button", nil, sec3, "UIPanelButtonTemplate") resetAllBtn:SetSize(RESET_BTN_W, 20) @@ -1670,185 +1779,27 @@ end function Options:DoResetPositions() EasyFind.db.uiSearchPosition = nil - EasyFind.db.mapSearchPosition = nil - EasyFind.db.globalSearchPosition = nil - EasyFind.db.mapSearchPositionMax = nil - EasyFind.db.globalSearchPositionMax = nil - EasyFind.db.optionsPosition = nil if ns.UI and ns.UI.ResetPosition then ns.UI:ResetPosition() end - if ns.MapSearch and ns.MapSearch.ResetPosition then ns.MapSearch:ResetPosition() end - if optionsFrame then - optionsFrame:ClearAllPoints() - optionsFrame:SetPoint("TOP", UIParent, "TOP", 0, -100) - end + ResetOptionsPosition() end function Options:DoResetAll() local needsReload = (EasyFind.db.enableUISearch == false) or (EasyFind.db.enableMapSearch == false) - EasyFind.db.iconScale = 0.8 - EasyFind.db.uiSearchScale = 1.0 - EasyFind.db.mapSearchScale = 1.0 - EasyFind.db.mapSearchWidth = 0.88 - EasyFind.db.uiSearchWidth = 0.88 - EasyFind.db.uiResultsScale = 1.0 - EasyFind.db.uiResultsWidth = 350 - EasyFind.db.mapResultsScale = 1.0 - EasyFind.db.mapResultsWidth = 300 - EasyFind.db.searchBarOpacity = DEFAULT_OPACITY - EasyFind.db.fontSize = 0.9 - EasyFind.db.mapFontSize = 0.9 - EasyFind.db.uiSearchPosition = nil - EasyFind.db.mapSearchPosition = nil - EasyFind.db.globalSearchPosition = nil - EasyFind.db.mapSearchPositionMax = nil - EasyFind.db.globalSearchPositionMax = nil - EasyFind.db.mapSearchYOffset = 0 - EasyFind.db.smartShow = false - EasyFind.db.autoHide = true - EasyFind.db.lockPosition = false - EasyFind.db.tutorialDone = false - EasyFind.db.resultsTheme = "Modern" - EasyFind.db.font = "Default" - EasyFind.db.uiResultsHeight = 280 - EasyFind.db.mapResultsHeight = 168 - EasyFind.db.pinsCollapsed = false - EasyFind.db.staticOpacity = false - EasyFind.db.indicatorStyle = "EasyFind Arrow" - EasyFind.db.indicatorColor = "Yellow" - EasyFind.db.blinkingPins = false - EasyFind.db.mapPinHighlight = true - EasyFind.db.showLoginMessage = true - EasyFind.db.uiResultsAbove = false - EasyFind.db.mapResultsAbove = false - EasyFind.db.uiHideHeaders = true - EasyFind.db.showMinimapButton = true - EasyFind.db.minimapButtonAngle = 200 - EasyFind.db.arrivalDistance = 10 - EasyFind.db.panelOpacity = 0.9 - EasyFind.db.minimapArrowGlow = false - EasyFind.db.glowOnlyEasyFind = false - EasyFind.db.circleOnlyEasyFind = false - EasyFind.db.minimapGuideCircle = false - EasyFind.db.autoPinClear = true - EasyFind.db.autoTrackPins = true - EasyFind.db.minimapPinGlow = false - EasyFind.db.guideCircleScale = 1.0 - EasyFind.db.mapSmartShow = false - EasyFind.db.hideSearchBarsMaximized = true - EasyFind.db.visible = true - EasyFind.db.enableUISearch = true - EasyFind.db.enableMapSearch = true - EasyFind.db.globalSearchFilters = { zones = true, dungeons = true, raids = true, delves = true } - EasyFind.db.localSearchFilters = { instances = true, travel = true, services = true, rares = true } - EasyFind.db.uiSearchFilters = { ui = true, achievements = true, currencies = true, reputations = true, collections = true, mounts = true, toys = true, pets = true, outfits = true, heirlooms = true, loot = true, appearanceSets = true, bags = true, macros = true, options = true, abilities = true, bosses = true, map = true } - EasyFind.db.lootSpecs = nil -- nil = current spec only - EasyFind.db.lootSearchSlots = true - EasyFind.db.lootSearchStats = true - EasyFind.db.lootUpgradesOnly = false - EasyFind.db.lootDifficulty = "normal" - EasyFind.db.appearanceSetClass = nil -- nil = player class - EasyFind.db.appearanceSetCollected = true - EasyFind.db.appearanceSetNotCollected = true - EasyFind.db.appearanceSetPvE = true - EasyFind.db.appearanceSetPvP = true - EasyFind.db.nativePinScale = 1.5 - EasyFind.db.pinnedUIItems = {} - EasyFind.db.pinnedUIItemsPerChar = {} - EasyFind.db.pinnedMapItems = {} - EasyFind.db.uiMapSearchLocal = true - EasyFind.db.alwaysShowRares = false - EasyFind.db.optionsPosition = nil - - if optionsFrame then - optionsFrame:ClearAllPoints() - optionsFrame:SetPoint("TOP", UIParent, "TOP", 0, -100) - end - - if ns.Highlight and ns.Highlight.ClearAll then - pcall(ns.Highlight.ClearAll, ns.Highlight) - end - if ns.MapSearch then - if ns.MapSearch.HideSuperTrackGlow then - pcall(ns.MapSearch.HideSuperTrackGlow) - end - if _G["EasyFindMapSearchFrame"] then - pcall(ns.MapSearch.ClearAll, ns.MapSearch) - pcall(ns.MapSearch.ClearZoneHighlight, ns.MapSearch) - end - ns.MapSearch.pendingWaypoint = nil - end - - local old1, old2 = GetBindingKey("EASYFIND_TOGGLE_FOCUS") - if old1 then SetBinding(old1) end - if old2 then SetBinding(old2) end - - old1, old2 = GetBindingKey("EASYFIND_MAP_FOCUS") - if old1 then SetBinding(old1) end - if old2 then SetBinding(old2) end - - old1, old2 = GetBindingKey("EASYFIND_CLEAR") - if old1 then SetBinding(old1) end - if old2 then SetBinding(old2) end + ApplyDefaults(UI_DEFAULTS) + ApplyDefaults(MAP_DEFAULTS) + ApplyDefaults(GENERAL_DEFAULTS) + ResetOptionsPosition() + ClearMapRuntime() + + ClearBinding("EASYFIND_TOGGLE_FOCUS") + ClearBinding("EASYFIND_MAP_FOCUS") + ClearBinding("EASYFIND_CLEAR") SaveBindings(GetCurrentBindingSet()) - optionsFrame.mapIconSlider:SetValue(0.8) - optionsFrame.panelOpacitySlider:SetValue(0.9) - optionsFrame.opacitySlider:SetValue(DEFAULT_OPACITY) - optionsFrame.uiFontSlider:SetValue(0.9) - optionsFrame.smartShowCheckbox:SetChecked(false) - if optionsFrame.autoHideCheckbox then - optionsFrame.autoHideCheckbox:SetChecked(true) - end - if optionsFrame.lockPositionCheckbox then - optionsFrame.lockPositionCheckbox:SetChecked(false) - end - optionsFrame.staticOpacityCheckbox:SetChecked(false) - optionsFrame.loginMessageCheckbox:SetChecked(true) - optionsFrame.uiResultsAboveCheckbox:SetChecked(false) - if optionsFrame.hideHeadersCheckbox then optionsFrame.hideHeadersCheckbox:SetChecked(true) end - optionsFrame.minimapBtnCheckbox:SetChecked(true) - if optionsFrame.rareTrackCheckbox then optionsFrame.rareTrackCheckbox:SetChecked(false) end - if optionsFrame.mapPinGroup then optionsFrame.mapPinGroup:UpdateVisuals() end - if optionsFrame.minimapGroup then optionsFrame.minimapGroup:UpdateVisuals() end - if optionsFrame.automationGroup then optionsFrame.automationGroup:UpdateVisuals() end - if optionsFrame.UpdateUIToggleVisual then optionsFrame.UpdateUIToggleVisual() end - if optionsFrame.UpdateMapToggleVisual then optionsFrame.UpdateMapToggleVisual() end - optionsFrame.arrivalSlider:SetValue(10) - optionsFrame.circleScaleSlider:SetValue(1.0) - optionsFrame.indicatorBtnText:SetText("EasyFind Arrow") - optionsFrame.colorBtnText:SetText("Yellow") - if optionsFrame.fontBtnText then optionsFrame.fontBtnText:SetText("Default") end + SyncOptionControls() if ns.RefreshAddonFont then ns.RefreshAddonFont() end - local defaultRGB = ns.INDICATOR_COLORS["Yellow"] - optionsFrame.colorBtnText:SetTextColor(defaultRGB[1], defaultRGB[2], defaultRGB[3]) - optionsFrame.colorSwatch:SetColorTexture(defaultRGB[1], defaultRGB[2], defaultRGB[3], 1) - optionsFrame.keybindBtn:SetText("Not Bound") - optionsFrame.focusBtn:SetText("Not Bound") - optionsFrame.toggleFocusBtn:SetText("Not Bound") - - if _G["EasyFindSearchFrame"] and ns.UI then - if ns.UI.ResetPosition then ns.UI:ResetPosition() end - if ns.UI.UpdateScale then ns.UI:UpdateScale() end - if ns.UI.UpdateWidth then ns.UI:UpdateWidth() end - if ns.UI.UpdateOpacity then ns.UI:UpdateOpacity() end - if ns.UI.UpdateSmartShow then ns.UI:UpdateSmartShow() end - if ns.UI.UpdateFontSize then ns.UI:UpdateFontSize() end - if ns.UI.RefreshResults then ns.UI:RefreshResults() end - end - if _G["EasyFindMapSearchFrame"] and ns.MapSearch then - if ns.MapSearch.UpdateSearchBarTheme then ns.MapSearch:UpdateSearchBarTheme() end - if ns.MapSearch.ResetPosition then ns.MapSearch:ResetPosition() end - if ns.MapSearch.UpdateScale then ns.MapSearch:UpdateScale() end - if ns.MapSearch.UpdateWidth then ns.MapSearch:UpdateWidth() end - if ns.MapSearch.UpdateResultsWidth then ns.MapSearch:UpdateResultsWidth() end - if ns.MapSearch.UpdateFontSize then ns.MapSearch:UpdateFontSize() end - if ns.MapSearch.UpdateIconScales then ns.MapSearch:UpdateIconScales() end - if ns.MapSearch.RefreshIndicators then ns.MapSearch:RefreshIndicators() end - if ns.MapSearch.UpdateOpacity then ns.MapSearch:UpdateOpacity() end - if ns.MapSearch.UpdateMapSmartShow then ns.MapSearch:UpdateMapSmartShow() end - end - local uiInd = _G["EasyFindIndicatorFrame"] - if uiInd then uiInd:SetScale(1.0) end + RefreshUIRuntime(true) + RefreshMapRuntime() if _G["EasyFindSearchFrame"] and ns.UI and ns.UI.Show then ns.UI:Show() end EasyFind:UpdateMinimapButton() @@ -1858,146 +1809,27 @@ function Options:DoResetAll() end function Options:DoResetUI() - EasyFind.db.smartShow = false - EasyFind.db.autoHide = true - EasyFind.db.lockPosition = false - EasyFind.db.staticOpacity = false - EasyFind.db.uiResultsAbove = false - EasyFind.db.fontSize = 0.9 - EasyFind.db.uiSearchScale = 1.0 - EasyFind.db.uiSearchWidth = 0.88 - EasyFind.db.uiResultsScale = 1.0 - EasyFind.db.uiResultsWidth = 350 - EasyFind.db.uiSearchPosition = nil - EasyFind.db.uiResultsHeight = 280 - EasyFind.db.uiSearchFilters = { ui = true, achievements = true, currencies = true, reputations = true, collections = true, mounts = true, toys = true, pets = true, outfits = true, heirlooms = true, loot = true, appearanceSets = true, bags = true, macros = true, options = true, abilities = true, bosses = true, map = true } - EasyFind.db.lootSpecs = nil - EasyFind.db.lootSearchSlots = true - EasyFind.db.lootSearchStats = true - EasyFind.db.lootUpgradesOnly = false - EasyFind.db.lootDifficulty = "normal" - EasyFind.db.appearanceSetClass = nil - EasyFind.db.appearanceSetCollected = true - EasyFind.db.appearanceSetNotCollected = true - EasyFind.db.appearanceSetPvE = true - EasyFind.db.appearanceSetPvP = true - EasyFind.db.uiMapSearchLocal = true - - optionsFrame.smartShowCheckbox:SetChecked(false) - if optionsFrame.autoHideCheckbox then - optionsFrame.autoHideCheckbox:SetChecked(true) - end - if optionsFrame.lockPositionCheckbox then - optionsFrame.lockPositionCheckbox:SetChecked(false) - end - optionsFrame.staticOpacityCheckbox:SetChecked(false) - optionsFrame.uiResultsAboveCheckbox:SetChecked(false) - optionsFrame.uiFontSlider:SetValue(0.9) - - ns.Highlight:ClearAll() - if _G["EasyFindSearchFrame"] and ns.UI then - if ns.UI.ResetPosition then ns.UI:ResetPosition() end - if ns.UI.UpdateScale then ns.UI:UpdateScale() end - if ns.UI.UpdateWidth then ns.UI:UpdateWidth() end - if ns.UI.UpdateSmartShow then ns.UI:UpdateSmartShow() end - if ns.UI.UpdateFontSize then ns.UI:UpdateFontSize() end - if ns.UI.RefreshResults then ns.UI:RefreshResults() end - end + ApplyDefaults(UI_DEFAULTS) + SyncOptionControls() + RefreshUIRuntime(true) end function Options:DoResetMap() - EasyFind.db.mapSmartShow = false - EasyFind.db.hideSearchBarsMaximized = true - EasyFind.db.mapResultsAbove = false - EasyFind.db.mapFontSize = 0.9 - EasyFind.db.mapSearchYOffset = 0 - EasyFind.db.iconScale = 0.8 - EasyFind.db.mapSearchScale = 1.0 - EasyFind.db.mapSearchWidth = 0.88 - EasyFind.db.mapResultsScale = 1.0 - EasyFind.db.mapResultsWidth = 300 - EasyFind.db.mapSearchPosition = nil - EasyFind.db.globalSearchPosition = nil - EasyFind.db.mapSearchPositionMax = nil - EasyFind.db.globalSearchPositionMax = nil - EasyFind.db.mapResultsHeight = 168 - EasyFind.db.mapPinHighlight = true - EasyFind.db.blinkingPins = false - EasyFind.db.minimapArrowGlow = false - EasyFind.db.glowOnlyEasyFind = false - EasyFind.db.circleOnlyEasyFind = false - EasyFind.db.minimapGuideCircle = false - EasyFind.db.minimapPinGlow = false - EasyFind.db.guideCircleScale = 1.0 - EasyFind.db.autoPinClear = true - EasyFind.db.autoTrackPins = true - EasyFind.db.arrivalDistance = 10 - EasyFind.db.pinsCollapsed = false - EasyFind.db.globalSearchFilters = { zones = true, dungeons = true, raids = true, delves = true } - EasyFind.db.localSearchFilters = { instances = true, travel = true, services = true } - EasyFind.db.alwaysShowRares = false - - if optionsFrame.rareTrackCheckbox then optionsFrame.rareTrackCheckbox:SetChecked(false) end - if optionsFrame.mapPinGroup then optionsFrame.mapPinGroup:UpdateVisuals() end - if optionsFrame.minimapGroup then optionsFrame.minimapGroup:UpdateVisuals() end - if optionsFrame.automationGroup then optionsFrame.automationGroup:UpdateVisuals() end - optionsFrame.mapIconSlider:SetValue(0.8) - optionsFrame.arrivalSlider:SetValue(10) - optionsFrame.circleScaleSlider:SetValue(1.0) - - if ns.MapSearch then - if ns.MapSearch.HideSuperTrackGlow then pcall(ns.MapSearch.HideSuperTrackGlow) end - if _G["EasyFindMapSearchFrame"] then - pcall(ns.MapSearch.ClearAll, ns.MapSearch) - pcall(ns.MapSearch.ClearZoneHighlight, ns.MapSearch) - end - ns.MapSearch.pendingWaypoint = nil - end - if _G["EasyFindMapSearchFrame"] and ns.MapSearch then - if ns.MapSearch.ResetPosition then ns.MapSearch:ResetPosition() end - if ns.MapSearch.UpdateScale then ns.MapSearch:UpdateScale() end - if ns.MapSearch.UpdateWidth then ns.MapSearch:UpdateWidth() end - if ns.MapSearch.UpdateResultsWidth then ns.MapSearch:UpdateResultsWidth() end - if ns.MapSearch.UpdateFontSize then ns.MapSearch:UpdateFontSize() end - if ns.MapSearch.UpdateIconScales then ns.MapSearch:UpdateIconScales() end - if ns.MapSearch.RefreshIndicators then ns.MapSearch:RefreshIndicators() end - if ns.MapSearch.UpdateMapSmartShow then ns.MapSearch:UpdateMapSmartShow() end - end - local uiInd = _G["EasyFindIndicatorFrame"] - if uiInd then uiInd:SetScale(0.8) end + ApplyDefaults(MAP_DEFAULTS) + SyncOptionControls() + RefreshMapRuntime() end function Options:DoResetUIPositions() - EasyFind.db.uiSearchPosition = nil - EasyFind.db.uiSearchScale = 1.0 - EasyFind.db.uiSearchWidth = 0.88 - EasyFind.db.uiResultsScale = 1.0 - EasyFind.db.uiResultsWidth = 350 - if _G["EasyFindSearchFrame"] and ns.UI then - if ns.UI.ResetPosition then ns.UI:ResetPosition() end - if ns.UI.UpdateScale then ns.UI:UpdateScale() end - if ns.UI.UpdateWidth then ns.UI:UpdateWidth() end - end -end - -function Options:DoResetMapPositions() - EasyFind.db.mapSearchPosition = nil - EasyFind.db.globalSearchPosition = nil - EasyFind.db.mapSearchPositionMax = nil - EasyFind.db.globalSearchPositionMax = nil - EasyFind.db.mapSearchScale = 1.0 - EasyFind.db.mapSearchWidth = 0.88 - EasyFind.db.mapResultsScale = 1.0 - EasyFind.db.mapResultsWidth = 300 - EasyFind.db.mapSearchYOffset = 0 - if _G["EasyFindMapSearchFrame"] and ns.MapSearch then - if ns.MapSearch.ResetPosition then ns.MapSearch:ResetPosition() end - if ns.MapSearch.UpdateScale then ns.MapSearch:UpdateScale() end - if ns.MapSearch.UpdateWidth then ns.MapSearch:UpdateWidth() end - end + ApplyDefaults(UI_POSITION_DEFAULTS) + SyncOptionControls() + RefreshUIRuntime(true) end function Options:RegisterWithBlizzardOptions() + if blizzardRegistered then return end + blizzardRegistered = true + local panel = CreateFrame("Frame") panel.name = "EasyFind" @@ -2073,7 +1905,6 @@ function Options:Show() if optionsFrame.opacitySlider then optionsFrame.opacitySlider:SetValue(EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) end if optionsFrame.uiFontSlider then optionsFrame.uiFontSlider:SetValue(EasyFind.db.fontSize or 0.9) end if optionsFrame.mapIconSlider then optionsFrame.mapIconSlider:SetValue(EasyFind.db.iconScale or 0.8) end - if optionsFrame.arrivalSlider then optionsFrame.arrivalSlider:SetValue(EasyFind.db.arrivalDistance or 10) end if optionsFrame.autoHideCheckbox then optionsFrame.autoHideCheckbox:SetChecked(EasyFind.db.autoHide ~= false) end @@ -2095,7 +1926,6 @@ function Options:Show() optionsFrame.uiResultsAboveCheckbox:SetChecked(EasyFind.db.uiResultsAbove or false) optionsFrame.minimapBtnCheckbox:SetChecked(EasyFind.db.showMinimapButton or false) if optionsFrame.mapPinGroup then optionsFrame.mapPinGroup:UpdateVisuals() end - if optionsFrame.minimapGroup then optionsFrame.minimapGroup:UpdateVisuals() end if optionsFrame.automationGroup then optionsFrame.automationGroup:UpdateVisuals() end optionsFrame.indicatorBtnText:SetText(EasyFind.db.indicatorStyle or "EasyFind Arrow") if optionsFrame.fontBtnText then @@ -2167,5 +1997,3 @@ function Options:Toggle() self:Show() end end - --- Options:Initialize() is called from Core.lua OnPlayerLogin diff --git a/README.md b/README.md index 1b58058..767993c 100644 --- a/README.md +++ b/README.md @@ -132,14 +132,7 @@ The global bar finds zones, dungeons, raids, and delves with full breadcrumb pat * **Fuzzy and Abbreviation Matching**: Vowel-stripped abbreviations like `qtr` find "quartermaster". Multi-word queries like `twlght hghlnds` find "Twilight Highlands". * **Zone Abbreviations**: Type common shortcuts like `sw` (Stormwind), `dal` (Dalaran), `org` (Orgrimmar), `if` (Ironforge), `orib` (Oribos), and more. * **Keyboard Navigation**: Arrow keys, Tab, and Enter to browse and select results without a mouse. -* **Click-to-Navigate**: Click any local search pin on the map to place a native WoW waypoint with minimap supertrack arrow. The waypoint auto-clears when you arrive (configurable arrival distance). -* **Minimap Guide Circle**: When navigating to a nearby POI, a shrinking ring and directional arrow appear around your character on the minimap, guiding you to the exact spot. - -

-minimap glow -minimap guide circle
-Minimap glow arrow (far away) and guide circle (close up) -

+* **Click-to-Navigate**: Click any local search pin on the map to place and track a native WoW waypoint. The waypoint auto-clears when Blizzard reports arrival. ### Two Navigation Modes @@ -240,7 +233,7 @@ EasyFind provides customizable keybinds: * **UI Results Above**: Show search results above the bar instead of below, useful for bottom-of-screen placement. * **UI Font Size**: Scale text and row height for the UI search bar independently. * **Background Opacity**: Adjust search bar transparency. -* **Visual Rescaler**: Drag handles on search bars and results panels to resize width, row count, and font size interactively. +* **Visual Rescaler**: Drag handles on the search bar and results panel to resize width, row count, and font size interactively.

visual rescaler
@@ -249,33 +242,23 @@ EasyFind provides customizable keybinds: ### Map Search * **Enable Map Search Module**: Toggle the entire map search feature on or off (requires reload). -* **Navigate Zones Directly**: Selecting a zone jumps straight to it instead of stepping through parent zones. Off by default. -* **Map Results Above**: Show map search results above the bar instead of below. -* **Map Font Size**: Scale text and row height for the map search bars independently. +* **Map Search Tab**: Search zones, dungeons, raids, delves, services, travel points, and rares from the world map. * **Icon Size**: Scale map indicator icons (default 80%). -* **Arrival Distance**: How close (in yards) before a waypoint auto-clears. Default 10. -* **Guide Circle Size**: Scale the minimap guide circle ring and arrow. -* **Map Smart Show**: Auto-hide map search bars until you hover over them. -* **Hide Bars in Full Screen Map**: Hide both map search bars when the map is maximized. Bars reappear in windowed mode. * **Blinking Map Pins**: Pins and highlight boxes pulse in sync with the indicator arrow. Off by default. The indicator arrow always bobs regardless. * **Pin Highlight Box**: Toggle the yellow highlight box around map pins. Indicator arrow and pin icon remain visible either way. -* **Minimap Arrow Glow**: Pulsing glow effect on the minimap perimeter arrow during navigation. Sub-option: **Only EasyFind Pins** — restrict the glow to waypoints placed by EasyFind, ignoring others. -* **Minimap Guide Circle**: Ring and directional arrow around your character on the minimap when navigating nearby. Sub-option: **Only EasyFind Pins** — restrict the guide circle to waypoints placed by EasyFind, ignoring others. -* **Map Pin Glow**: Pulsing glow on the map pin when the guide circle shrinks onto it. * **Auto Map Pin Clear**: Automatically clear map pins when you arrive at the destination. -* **Auto Track Map Pins**: Automatically supertrack newly placed map pins for minimap arrow guidance. -* **Map Search Y-Offset**: Adjust vertical position of map search bars relative to the map bottom edge. -* **Category Filters**: Filter global results by zones, dungeons, raids, and delves. Filter local results by instances, travel, and services. +* **Auto Track Map Pins**: Automatically supertrack newly placed map pins through Blizzard's waypoint system. +* **Category Filters**: Filter map results by zones, instances, travel, services, flight paths, and rares. -Map Search options are organized into grouped dropdowns (Search Bars, Map Pins, Minimap, Automation) to keep related settings together. +Map Search options are organized into grouped dropdowns to keep related settings together. ### Reset * **Reset Settings**: Return all settings to defaults. * **Reset Positions**: Return search bar positions to defaults. -## Moving the Search Bars +## Moving the Search Bar -Both search bars can be repositioned by holding **Shift** and dragging. The map search bar stays constrained to the bottom of the map frame. +The search bar can be repositioned by holding **Shift** and dragging. ## Feedback diff --git a/Rescaler.lua b/Rescaler.lua index d2efae4..f84046b 100644 --- a/Rescaler.lua +++ b/Rescaler.lua @@ -24,8 +24,7 @@ local MAX_HEIGHT = 700 local MIN_FONT = 0.5 local MAX_FONT = 2.0 --- Active rescaler state -local activeMode = nil -- "ui" or "map" +local activeMode = nil local activeSearchBar = nil -- the search bar being rescaled local backdrop = nil -- full-screen dim local barOverlay = nil -- glow around search bar @@ -34,37 +33,28 @@ local donePanel = nil -- instruction + Done button local previewResults = nil -- fake results frame for preview local function GetResultsHeight() - if activeMode == "ui" then return EasyFind.db.uiResultsHeight or 280 end - return EasyFind.db.mapResultsHeight or 168 + return EasyFind.db.uiResultsHeight or 280 end local function SetResultsHeight(h) h = mmax(MIN_HEIGHT, mmin(MAX_HEIGHT, mfloor(h + 0.5))) - if activeMode == "ui" then EasyFind.db.uiResultsHeight = h - else EasyFind.db.mapResultsHeight = h end + EasyFind.db.uiResultsHeight = h end local function GetDefaultResultsHeight() - if activeMode == "ui" then return 280 end - return 168 + return 280 end local function GetFontScale() - if activeMode == "ui" then return EasyFind.db.fontSize or 1.0 end - return EasyFind.db.mapFontSize or 1.0 + return EasyFind.db.fontSize or 1.0 end local function SetFontScale(val) - if activeMode == "ui" then EasyFind.db.fontSize = val - else EasyFind.db.mapFontSize = val end + EasyFind.db.fontSize = val end local function ApplyFontUpdate() - if activeMode == "ui" then - if ns.UI and ns.UI.UpdateFontSize then ns.UI:UpdateFontSize() end - else - if ns.MapSearch and ns.MapSearch.UpdateFontSize then ns.MapSearch:UpdateFontSize() end - end + if ns.UI and ns.UI.UpdateFontSize then ns.UI:UpdateFontSize() end end -- Helpers @@ -374,45 +364,6 @@ local function SetupWidthDrag(handle, getWidth, setWidth, widthLabel, side) end) end --- Corner drag handler (width + height combo) - -local function SetupCornerDrag(handle, preview, getWidth, setWidth, widthLabel, heightLabel, anchorAbove) - handle:SetScript("OnDragStart", function(self) - self.dragging = true - local cx, cy = GetCursorPosition() - local es = UIParent:GetEffectiveScale() - self.startX = cx / es - self.startY = cy / es - self.startWidth = getWidth() - self.startHeight = GetResultsHeight() - self.maxH = GetScreenMaxHeight(anchorAbove) - end) - handle:SetScript("OnDragStop", function(self) - self.dragging = false - end) - handle:SetScript("OnUpdate", function(self) - if not self.dragging then return end - local cx, cy = GetCursorPosition() - local es = UIParent:GetEffectiveScale() - cx = cx / es - cy = cy / es - - local dx = cx - self.startX - local newW = ClampWidth(self.startWidth + dx * 2) - setWidth(newW) - if widthLabel and not widthLabel:HasFocus() then - widthLabel:SetText(mfloor(newW + 0.5)) - end - - local dy = self.startY - cy - if anchorAbove then dy = -dy end - local newH = mmax(MIN_HEIGHT, mmin(self.maxH, mfloor(self.startHeight + dy + 0.5))) - SetResultsHeight(newH) - preview:SetPreviewHeight(newH) - if heightLabel and not heightLabel:HasFocus() then heightLabel:SetText(newH) end - end) -end - -- Height drag handler local function SetupHeightDrag(handle, preview, heightBox, anchorAbove) @@ -491,7 +442,7 @@ local function SetupFontDrag(handle, fontLabel, preview) local optPanel = _G["EasyFindOptionsFrame"] if optPanel then - local slider = activeMode == "ui" and optPanel.uiFontSlider or optPanel.mapFontSlider + local slider = optPanel.uiFontSlider if slider then slider:SetValue(newFont) end end end) @@ -506,7 +457,7 @@ local function BuildBarOverlay(parent, targetFrame, mode) local label = overlay:CreateFontString(nil, "OVERLAY", "GameFontNormal") label:SetPoint("CENTER") - label:SetText(mode == "ui" and "UI Search Bar" or "Map Search Bar") + label:SetText("UI Search Bar") label:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.7) local widthBox = CreateDimLabel(overlay, "LEFT", "RIGHT", 8, 0, "Width:") @@ -687,85 +638,38 @@ function Rescaler:Enter(mode) if activeMode then self:Exit() end + if mode ~= "ui" then return end activeMode = mode local searchBar, resultsFrame local getBarWidth, setBarWidth - local getResultsWidth, setResultsWidth, getResultsScale - - if mode == "ui" then - searchBar = _G["EasyFindSearchFrame"] - resultsFrame = _G["EasyFindResultsFrame"] + local getResultsWidth, getResultsScale - if not searchBar then - activeMode = nil - return - end + searchBar = _G["EasyFindSearchFrame"] + resultsFrame = _G["EasyFindResultsFrame"] - -- Force visible - searchBar:Show() - searchBar:SetAlpha(1.0) - - -- UI mode is the unified rounded-pill design: bar + results - -- share one rounded silhouette and one width. Both setters - -- below drive the same width so the bar overlay and results - -- overlay handles all stay in sync. - local function setUiWidth(w) - w = ClampWidth(w) - searchBar:SetWidth(w) - EasyFind.db.uiSearchWidth = w / 250 - EasyFind.db.uiResultsWidth = w - if resultsFrame then resultsFrame:SetWidth(w) end - end - - getBarWidth = function() return searchBar:GetWidth() end - setBarWidth = setUiWidth - - getResultsScale = function() return EasyFind.db.uiResultsScale or 1.0 end - - getResultsWidth = function() return searchBar:GetWidth() end - setResultsWidth = setUiWidth - - elseif mode == "map" then - -- Open the world map if not already visible (search bars are anchored to it) - if not WorldMapFrame or not WorldMapFrame:IsShown() then - ToggleWorldMap() - end - - searchBar = _G["EasyFindMapGlobalSearchFrame"] - resultsFrame = _G["EasyFindMapResultsFrame"] - - if not searchBar then - activeMode = nil - return - end - - getBarWidth = function() return searchBar:GetWidth() end - setBarWidth = function(w) - w = ClampWidth(w) - EasyFind.db.mapSearchWidth = w / 250 - searchBar:SetWidth(w) - local localBar = _G["EasyFindMapSearchFrame"] - if localBar then localBar:SetWidth(w) end - end + if not searchBar then + activeMode = nil + return + end - getResultsScale = function() return EasyFind.db.mapResultsScale or 1.0 end + searchBar:Show() + searchBar:SetAlpha(1.0) - getResultsWidth = function() - if resultsFrame then return resultsFrame:GetWidth() end - return 300 * (EasyFind.db.mapSearchWidth or 1.0) - end - setResultsWidth = function(w) - w = ClampWidth(w) - if ns.MapSearch and ns.MapSearch.GetMaxResultsWidth then - w = mmin(w, ns.MapSearch:GetMaxResultsWidth()) - end - EasyFind.db.mapResultsWidth = w - if resultsFrame then resultsFrame:SetWidth(w) end - end + local function setUiWidth(w) + w = ClampWidth(w) + searchBar:SetWidth(w) + EasyFind.db.uiSearchWidth = w / 250 + EasyFind.db.uiResultsWidth = w + if resultsFrame then resultsFrame:SetWidth(w) end end + getBarWidth = function() return searchBar:GetWidth() end + setBarWidth = setUiWidth + getResultsScale = function() return EasyFind.db.uiResultsScale or 1.0 end + getResultsWidth = function() return searchBar:GetWidth() end + -- Hide options panel local optPanel = _G["EasyFindOptionsFrame"] if optPanel and optPanel:IsShown() then @@ -786,15 +690,10 @@ function Rescaler:Enter(mode) SafeCallMethod(bg, "EnableKeyboard", true) -- Preview results (fake rows so user sees the results area) - local resultsAbove = (mode == "ui" and EasyFind.db.uiResultsAbove) - or (mode == "map" and EasyFind.db.mapResultsAbove) + local resultsAbove = EasyFind.db.uiResultsAbove local previewW = getResultsWidth() local currentH = mmin(GetResultsHeight(), GetScreenMaxHeight(resultsAbove)) - local leftAligned = (mode == "map") - -- UI mode is the unified pill: dock the preview flush against the - -- bar (zero gap) so width changes look continuous. - local flushDock = (mode == "ui") - previewResults = CreatePreviewResults(bg, searchBar, previewW, currentH, resultsAbove, leftAligned, flushDock) + previewResults = CreatePreviewResults(bg, searchBar, previewW, currentH, resultsAbove, false, true) previewResults:SetScale(getResultsScale()) previewResults:Show() @@ -825,7 +724,7 @@ function Rescaler:Enter(mode) -- area; resize it in lockstep so the unified pill stays continuous. local function setBarWidthAndPreview(w) setBarWidth(w) - if mode == "ui" then previewResults:SetWidth(searchBar:GetWidth()) end + previewResults:SetWidth(searchBar:GetWidth()) end SetupWidthDrag(barOverlay.leftHandle, getBarWidth, setBarWidthAndPreview, barOverlay.widthBox, "LEFT") SetupWidthDrag(barOverlay.rightHandle, getBarWidth, setBarWidthAndPreview, barOverlay.widthBox, "RIGHT") @@ -863,37 +762,11 @@ function Rescaler:Enter(mode) end) SetupFontDrag(barOverlay.bottomHandle, barOverlay.fontBox, previewResults) - -- Wire results width drag. UI mode locks results width to bar - -- width (single rounded pill), so the results overlay's width - -- handles + width box are redundant -- hide them entirely. The - -- bar's width handles already update the preview width via - -- setBarWidth above. - if mode == "ui" then - resultsOverlay.leftHandle:Hide() - resultsOverlay.rightHandle:Hide() - resultsOverlay.widthBox:Hide() - if resultsOverlay.widthBox.prefix then resultsOverlay.widthBox.prefix:Hide() end - if resultsOverlay.widthBox.suffix then resultsOverlay.widthBox.suffix:Hide() end - else - SetupWidthDrag(resultsOverlay.leftHandle, getResultsWidth, function(w) - setResultsWidth(w) - previewResults:SetWidth(w) - end, resultsOverlay.widthBox, "LEFT") - SetupWidthDrag(resultsOverlay.rightHandle, getResultsWidth, function(w) - setResultsWidth(w) - previewResults:SetWidth(w) - end, resultsOverlay.widthBox, "RIGHT") - WireDimLabel(resultsOverlay.widthBox, getResultsWidth, function(v) - setResultsWidth(v) - previewResults:SetWidth(v) - end) - AddResetButton(resultsOverlay.widthBox, function() - local defW = 300 - setResultsWidth(defW) - previewResults:SetWidth(defW) - resultsOverlay.widthBox:SetText(mfloor(defW + 0.5)) - end) - end + resultsOverlay.leftHandle:Hide() + resultsOverlay.rightHandle:Hide() + resultsOverlay.widthBox:Hide() + if resultsOverlay.widthBox.prefix then resultsOverlay.widthBox.prefix:Hide() end + if resultsOverlay.widthBox.suffix then resultsOverlay.widthBox.suffix:Hide() end -- Wire results corner (width + height combo) resultsOverlay.heightBox:SetText(currentH) @@ -929,27 +802,14 @@ function Rescaler:Enter(mode) heightReset:SetPoint("BOTTOM", resultsOverlay.heightBox, "TOP", 0, 2) end end - if mode == "ui" then - -- Width is locked to the bar in UI mode; the corner handle - -- only changes height. Reuse the height-only handler. - SetupHeightDrag(resultsOverlay.scaleHandle, previewResults, resultsOverlay.heightBox, resultsAbove) - else - SetupCornerDrag(resultsOverlay.scaleHandle, previewResults, getResultsWidth, function(w) - setResultsWidth(w) - previewResults:SetWidth(w) - end, resultsOverlay.widthBox, resultsOverlay.heightBox, resultsAbove) - end + SetupHeightDrag(resultsOverlay.scaleHandle, previewResults, resultsOverlay.heightBox, resultsAbove) -- Wire results bottom edge (height) SetupHeightDrag(resultsOverlay.heightHandle, previewResults, resultsOverlay.heightBox, resultsAbove) -- Done panel donePanel = CreateDonePanel(bg) - if mode == "map" then - donePanel:SetPoint("BOTTOM", barOverlay, "TOP", 0, 0) - else - donePanel:SetPoint("TOP", resultsOverlay, "BOTTOM", 0, -50) - end + donePanel:SetPoint("TOP", resultsOverlay, "BOTTOM", 0, -50) donePanel.doneBtn:SetScript("OnClick", function() Rescaler:Exit() end) @@ -998,19 +858,10 @@ function Rescaler:Exit(reopenOptions) activeSearchBar = nil end - -- Apply final values - if activeMode == "ui" then - if ns.UI then - if ns.UI.UpdateScale then ns.UI:UpdateScale() end - if ns.UI.UpdateWidth then ns.UI:UpdateWidth() end - if ns.UI.RefreshResults then ns.UI:RefreshResults() end - end - elseif activeMode == "map" then - if ns.MapSearch then - if ns.MapSearch.UpdateScale then ns.MapSearch:UpdateScale() end - if ns.MapSearch.UpdateWidth then ns.MapSearch:UpdateWidth() end - if ns.MapSearch.UpdateResultsWidth then ns.MapSearch:UpdateResultsWidth() end - end + if ns.UI then + if ns.UI.UpdateScale then ns.UI:UpdateScale() end + if ns.UI.UpdateWidth then ns.UI:UpdateWidth() end + if ns.UI.RefreshResults then ns.UI:RefreshResults() end end activeMode = nil diff --git a/StaticLocations.lua b/StaticLocations.lua index 3438bce..b9316eb 100644 --- a/StaticLocations.lua +++ b/StaticLocations.lua @@ -1,5 +1,5 @@ -- EasyFind Static Locations (auto-generated 2026-04-09 03:37) -local ADDON_NAME, ns = ... +local _, ns = ... ns.STATIC_LOCATIONS = { [18] = { -- Tirisfal Glades { name = "Portal to Stranglethorn Vale", category = "portal", x = 0.6142756296653066, y = 0.5879643697403164, keywords = {"portal", "teleport", "mage"} }, diff --git a/UI.lua b/UI.lua index 13f03da..70ce78d 100644 --- a/UI.lua +++ b/UI.lua @@ -4,6 +4,7 @@ local UI = {} ns.UI = UI local Utils = ns.Utils +local UIPins = ns.UIPins local GetButtonText = Utils.GetButtonText local SearchFrameTreeFuzzy = Utils.SearchFrameTreeFuzzy local ClickButton = Utils.ClickButton @@ -17,7 +18,6 @@ local sformat = Utils.sformat local GOLD_COLOR = ns.GOLD_COLOR local DEFAULT_OPACITY = ns.DEFAULT_OPACITY local TOOLTIP_BORDER = ns.TOOLTIP_BORDER -local DARK_PANEL_BG = ns.DARK_PANEL_BG local CreateFrame = CreateFrame local C_Timer = C_Timer @@ -27,7 +27,6 @@ local GameTooltip_Hide = GameTooltip_Hide local IsShiftKeyDown = IsShiftKeyDown local GetCursorPosition = GetCursorPosition local InCombatLockdown = InCombatLockdown -local HideUIPanel = HideUIPanel local wipe = wipe local EYE_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\eye" @@ -43,9 +42,11 @@ local resultsFrame local containerFrame local resultButtons = {} local MAX_BUTTON_POOL = 50 -- Maximum buttons (scroll handles overflow beyond this) +local EnsureResultButton local inCombat = false local selectingResult = false -- guard: suppress OnTextChanged re-renders during SelectResult local deferredRepRefreshPending = false -- deferred re-render to let IsTruncated() settle +local idleTrimSerial = 0 local outfitCdStart, outfitCdDuration = 0, 0 -- shared outfit swap cooldown local lastEquippedOutfitID -- tracks most recent equip for immediate green tint @@ -57,61 +58,6 @@ local lastEquippedOutfitID -- tracks most recent equip for i local historyIndex = 0 local historyDraft = "" -- User's in-flight text, restored when stepping back to index 0 --- PIN HELPERS - -local function GetUIPinKey(data) - if not data or not data.name then return "" end - return data.name .. "|" .. tconcat(data.path or {}, ">") -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", "petID", "speciesID", "outfitID", "heirloomItemID", - "macroIndex", "macroIsChar", "bagID", "bagSlot", "bagItemLink", - "itemID", "encounterID", "instanceID", "lootSlotName", "lootSourceName", "lootInstanceName", "lootSourceType", - "transmogSetID", - "factionID", "hasRepBar", "canQueue", "isPvP", "isPvE"} --- Tables to copy. Some are arrays (path, steps, keywords) and some are --- string-keyed maps (lootItemLinks = {[difficulty] = link}), so iterate --- with pairs rather than ipairs. -local CLEAN_TABLE_FIELDS = {"path", "steps", "keywords", "keywordsLower", "lootItemLinks"} - -local function CleanUIForStorage(data) - local clean = {} - for fi = 1, #CLEAN_SIMPLE_FIELDS do - local k = CLEAN_SIMPLE_FIELDS[fi] - local v = data[k] - if v ~= nil then clean[k] = v end - end - for fi = 1, #CLEAN_TABLE_FIELDS do - local k = CLEAN_TABLE_FIELDS[fi] - local v = data[k] - if v then - local copy = {} - for k2, v2 in pairs(v) do - if type(v2) == "table" then - local sub = {} - for sk, sv in pairs(v2) do sub[sk] = sv end - copy[k2] = sub - else - copy[k2] = v2 - end - end - clean[k] = copy - end - end - return clean -end - --- 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 - or data.heirloomItemID - or (data.itemID and data.category == "Loot")) -end - -- LEFT-side category icons for flat mode. Collection items (mounts, toys, -- etc.) push their item-specific icon to the right side of the row, leaving -- the left empty; we fill it with the same icon used in the filter dropdown @@ -318,89 +264,10 @@ local function ApplyActionHint(row) actionHintRow = row 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 - -local function PinUIItem(data) - if IsUIItemPinned(data) then return end - local clean = CleanUIForStorage(data) - clean.isPinned = true - 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 - tremove(items, i) - 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 +local GetAllPins = UIPins.GetAll +local IsUIItemPinned = UIPins.IsPinned +local PinUIItem = UIPins.Pin +local UnpinUIItem = UIPins.Unpin UI.PinUIItem = PinUIItem UI.UnpinUIItem = UnpinUIItem @@ -425,196 +292,48 @@ function UI:DressUpAppearanceSet(setID) 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 - --- Right-click context menu with Pin/Unpin and optional Guide row. --- Anchored BOTTOMLEFT at cursor so it opens above the pointer. -local EYE_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\eye" -local PIN_MENU_ROW_H = 22 -local PIN_MENU_WIDTH = 96 -local pinPopup - -local function CreatePinMenuRow(parent) - local row = CreateFrame("Button", nil, parent) - row:SetHeight(PIN_MENU_ROW_H) - row:RegisterForClicks("LeftButtonUp") - local label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal") - label:SetPoint("LEFT", row, "LEFT", 8, 0) - row.label = label - local icon = row:CreateTexture(nil, "OVERLAY") - icon:SetSize(14, 14) - icon:SetPoint("RIGHT", row, "RIGHT", -8, 0) - icon:Hide() - row.icon = icon - row:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD") - return row + UIPins.SyncOutfits() end --- Hide when the cursor leaves the popup's bounding box. A child row's OnLeave -local function ShowPinPopup(btn, isPinned, onPinAction, onGuide, onAddAlias) - if not pinPopup then - pinPopup = CreateFrame("Frame", "EasyFindPinPopup", UIParent, "BackdropTemplate") - pinPopup:SetFrameStrata("FULLSCREEN_DIALOG") - pinPopup:SetToplevel(true) - pinPopup:EnableMouse(true) - pinPopup:SetBackdrop({ - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", - edgeFile = TOOLTIP_BORDER, - tile = true, tileSize = 16, edgeSize = 12, - insets = { left = 2, right = 2, top = 2, bottom = 2 } - }) - pinPopup:SetBackdropColor(DARK_PANEL_BG[1], DARK_PANEL_BG[2], DARK_PANEL_BG[3], DARK_PANEL_BG[4]) - pinPopup.guideRow = CreatePinMenuRow(pinPopup) - pinPopup.guideRow.label:SetText("Guide") - pinPopup.guideRow.icon:SetTexture(EYE_ICON_TEX) - pinPopup.guideRow.icon:Show() - pinPopup.pinRow = CreatePinMenuRow(pinPopup) - pinPopup.aliasRow = CreatePinMenuRow(pinPopup) - pinPopup.aliasRow.label:SetText("Add Alias") - - -- Continuous outside-cursor poll: hides the popup once the - -- cursor has been outside for a short grace window. Combined - -- with GLOBAL_MOUSE_DOWN/UP for instant click-out dismissal - -- (with a small post-show grace so the right-click that - -- opened the popup doesn't immediately close it). - pinPopup:SetScript("OnShow", function(self) - self._showedAt = GetTime() - self._outsideSince = nil - self._hasEntered = false - self:RegisterEvent("GLOBAL_MOUSE_DOWN") - self:RegisterEvent("GLOBAL_MOUSE_UP") - end) - pinPopup:SetScript("OnHide", function(self) - self._outsideSince = nil - self._hasEntered = false - self:UnregisterEvent("GLOBAL_MOUSE_DOWN") - self:UnregisterEvent("GLOBAL_MOUSE_UP") - end) - pinPopup:SetScript("OnUpdate", function(self) - if self:IsMouseOver() then - self._outsideSince = nil - self._hasEntered = true - return - end - -- Don't start the outside timer until the cursor has reached - -- the popup at least once. Otherwise the popup can close - -- before the user has a chance to move the mouse onto it. - if not self._hasEntered then return end - local now = GetTime() - if not self._outsideSince then - self._outsideSince = now - return - end - if now - self._outsideSince > 0.3 then - self:Hide() - end - end) - pinPopup:SetScript("OnEvent", function(self, event) - if event ~= "GLOBAL_MOUSE_DOWN" and event ~= "GLOBAL_MOUSE_UP" then - return - end - if self._showedAt and (GetTime() - self._showedAt) < 0.05 then - return - end - if not self:IsMouseOver() then - self:Hide() - end - end) - end - - pinPopup.pinRow:Show() - pinPopup.pinRow.label:SetText(isPinned and "Unpin" or "Pin") - pinPopup.pinRow:SetScript("OnClick", function() - pinPopup:Hide() - if onPinAction then onPinAction() end - end) - - -- Stack rows top-to-bottom in this order: Guide (if applicable), - -- Pin/Unpin, Add Alias (if applicable). Each anchor chains off - -- the previous visible row so dropping one shifts the rest up. - local rowsShown = 0 - local lastStackedRow - local function StackRow(row) - row:ClearAllPoints() - if rowsShown == 0 then - row:SetPoint("TOPLEFT", pinPopup, "TOPLEFT", 4, -4) - row:SetPoint("TOPRIGHT", pinPopup, "TOPRIGHT", -4, -4) - else - row:SetPoint("TOPLEFT", lastStackedRow, "BOTTOMLEFT", 0, 0) - row:SetPoint("TOPRIGHT", lastStackedRow, "BOTTOMRIGHT", 0, 0) - end - row:Show() - rowsShown = rowsShown + 1 - lastStackedRow = row - end - +local function ShowPinPopup(_, isPinned, onPinAction, onGuide, onAddAlias) + local rows = {} if onGuide then - pinPopup.guideRow:SetScript("OnClick", function() - pinPopup:Hide() - onGuide() - end) - StackRow(pinPopup.guideRow) - else - pinPopup.guideRow:Hide() - pinPopup.guideRow:SetScript("OnClick", nil) + rows[#rows + 1] = { text = "Guide", icon = EYE_ICON_TEX, onClick = onGuide } end - - StackRow(pinPopup.pinRow) - + rows[#rows + 1] = { text = isPinned and "Unpin" or "Pin", onClick = onPinAction } if onAddAlias then - pinPopup.aliasRow:SetScript("OnClick", function() - pinPopup:Hide() - onAddAlias() - end) - StackRow(pinPopup.aliasRow) - else - pinPopup.aliasRow:Hide() - pinPopup.aliasRow:SetScript("OnClick", nil) + rows[#rows + 1] = { text = "Add Alias", onClick = onAddAlias } end + Utils.ShowCursorMenu("EasyFindPinPopup", rows, { + strata = "FULLSCREEN_DIALOG", + width = 96, + rowHeight = 22, + }) +end - pinPopup:SetSize(PIN_MENU_WIDTH, PIN_MENU_ROW_H * rowsShown + 8) +local unearnedTooltip - local scale = UIParent:GetEffectiveScale() - local x, y = GetCursorPosition() - pinPopup:ClearAllPoints() - pinPopup:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", x / scale, y / scale) - pinPopup:Show() +local function ClearResultTooltips() + if unearnedTooltip then unearnedTooltip:Hide() end + if BattlePetTooltip then BattlePetTooltip:Hide() end + if GameTooltip then + for i = 1, #resultButtons do + local row = resultButtons[i] + if row then + if row.toyTooltipTicker then + row.toyTooltipTicker:Cancel() + row.toyTooltipTicker = nil + end + if GameTooltip:IsOwned(row) then + GameTooltip:Hide() + end + end + end + end + if ns.MapSearch and ns.MapSearch.ClearUIPreview then + ns.MapSearch:ClearUIPreview() + end end -- Centralized icon setter - resets texture state before applying to prevent @@ -677,7 +396,6 @@ 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 local activeKeybindBtn -- THEME DEFINITIONS @@ -748,6 +466,27 @@ local function GetActiveTheme() return THEMES["Modern"] end +local function ScaleFont(fontString, baseFontObject) + if not fontString then return end + local obj = _G[baseFontObject] + if not obj then return end + local path, baseSize, flags = obj:GetFont() + fontString:SetFont(path, baseSize * (EasyFind.db.fontSize or 1.0), flags) + fontString:SetJustifyH(fontString:GetJustifyH()) +end + +local function ApplyResultRowFonts(row, theme) + if not row then return end + theme = theme or GetActiveTheme() + ScaleFont(row.text, theme.leafFont) + ScaleFont(row.tabText, theme.pathFont) + ScaleFont(row.sectionLabelText, "GameFontNormalSmall") + ScaleFont(row.pathSubtext, theme.leafFont) + ScaleFont(row.amountText, "GameFontNormalSmall") + ScaleFont(row.repBarText, "GameFontNormalSmall") + ScaleFont(row.settingSliderValue, "GameFontNormalSmall") +end + function UI:CreateUnearnedTooltip() -- Create simple tooltip frame unearnedTooltip = CreateFrame("Frame", "EasyFindUnearnedTooltip", UIParent, "BackdropTemplate") @@ -832,15 +571,12 @@ function UI:Initialize() end) end - -- Prewarm: 3s after login (after dynamic data has finished loading) - -- run a throwaway search so the first-char inverted index gets - -- built and the per-bucket scratch tables get allocated. Without - -- this, the user's first real keystroke pays a 100ms+ cold tax. - C_Timer.After(3, function() - if ns.Database and ns.Database.SearchUI then - ns.Database:SearchUI("zz") - end - end) +end + +function UI:WarmSearchHotPath() + for i = 1, MAX_BUTTON_POOL do + EnsureResultButton(i):Hide() + end end function UI:RegisterCombatEvents() @@ -890,7 +626,6 @@ function UI:CreateSearchFrame() end local theme = GetActiveTheme() - local WHITE8x8 = "Interface\\BUTTONS\\WHITE8x8" ns.CreateSearchBorder(searchFrame) -- Combined visual frame: 9-slice rounded rect that morphs from a @@ -948,10 +683,7 @@ function UI:CreateSearchFrame() searchIcon:SetAtlas("common-search-magnifyingglass") iconHolder.icon = searchIcon searchFrame.searchIcon = searchIcon - -- Kept for Demo.lua / legacy references; no longer a clickable toggle. searchFrame.modeBtn = iconHolder - -- No-op shim so legacy pcalls from Demo.lua succeed. - ns.UpdateModeButtonVisual = ns.UpdateModeButtonVisual or function() end -- Editbox local editBox = CreateFrame("EditBox", "EasyFindSearchBox", searchFrame) @@ -2131,8 +1863,8 @@ function UI:BuildAppearanceSetOptionsPopup(StylePopup, CreateRadioTexture, end local function ApplyFilterSelection() - if ns.Database and ns.Database.PopulateDynamicTransmogSets then - ns.Database:PopulateDynamicTransmogSets() + if ns.Database and ns.Database.RefreshDynamicCategory then + ns.Database:RefreshDynamicCategory("transmogSets") end if searchEditBox and searchEditBox:GetText() ~= "" then UI:OnSearchTextChanged(searchEditBox:GetText()) @@ -2869,9 +2601,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) dropdown:HookScript("OnHide", function() popup:Hide() end) end - -- Map Search: was a local/global radio pair, but the MapTab - -- model shows both scopes together and that's what UI search - -- now does too — the toggle above is just on/off. -- Loot/Gear: side popup with difficulty + spec selector + iLvl -- upgrades checkbox. Opens to the right of the Gear filter row @@ -2997,8 +2726,8 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) EasyFind.db.lootDifficulty = def.key UpdateDiffLabel() diffPopup:Hide() - if ns.Database and ns.Database.PopulateDynamicLoot then - ns.Database:PopulateDynamicLoot() + if ns.Database and ns.Database.RefreshDynamicCategory then + ns.Database:RefreshDynamicCategory("loot") end if searchEditBox:GetText() ~= "" then UI:OnSearchTextChanged(searchEditBox:GetText()) @@ -3097,8 +2826,8 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- Apply single selection and rebuild from cache local function ApplyFilterSelection() if ns.Database then - if ns.Database.PopulateDynamicLoot then - ns.Database:PopulateDynamicLoot() + if ns.Database.RefreshDynamicCategory then + ns.Database:RefreshDynamicCategory("loot") end ns.Database:SyncEJLootFilter() end @@ -3816,8 +3545,8 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) or beforeNotCollected ~= db.appearanceSetNotCollected or beforePvE ~= db.appearanceSetPvE or beforePvP ~= db.appearanceSetPvP - if changed and ns.Database.PopulateDynamicTransmogSets then - ns.Database:PopulateDynamicTransmogSets() + if changed and ns.Database.RefreshDynamicCategory then + ns.Database:RefreshDynamicCategory("transmogSets") if searchEditBox and searchEditBox:GetText() ~= "" then UI:OnSearchTextChanged(searchEditBox:GetText()) end @@ -3909,6 +3638,15 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) dropdown.checkRows = checkRows end +function EnsureResultButton(index) + local row = resultButtons[index] + if not row then + row = UI:CreateResultButton(index) + resultButtons[index] = row + end + return row +end + function UI:CreateResultsFrame() resultsFrame = CreateFrame("Frame", "EasyFindResultsFrame", searchFrame, "BackdropTemplate") resultsFrame:SetWidth(380) -- Wide to accommodate tree indentation @@ -3923,6 +3661,16 @@ function UI:CreateResultsFrame() insets = { left = 5, right = 5, top = 5, bottom = 5 } }) + local theme = GetActiveTheme() + local bgAtlasTex = resultsFrame:CreateTexture(nil, "BACKGROUND", nil, -1) + bgAtlasTex:SetPoint("TOPLEFT", resultsFrame, "TOPLEFT", 4, -4) + bgAtlasTex:SetPoint("BOTTOMRIGHT", resultsFrame, "BOTTOMRIGHT", -4, 4) + if theme.resultsBgAtlas then + bgAtlasTex:SetAtlas(theme.resultsBgAtlas, false) + end + bgAtlasTex:Hide() + resultsFrame.bgAtlasTex = bgAtlasTex + resultsFrame:Hide() -- Click-outside-to-close: hides the results frame on any click that @@ -3982,11 +3730,6 @@ function UI:CreateResultsFrame() -- Minimal retail-style scrollbar (overlays right edge, no content squish) resultsFrame.scrollBar = ns.Utils.CreateMinimalScrollBar(scrollFrame, resultsFrame) - for i = 1, MAX_BUTTON_POOL do - local resultRow = self:CreateResultButton(i) - resultButtons[i] = resultRow - end - -- Pin section separator line (golden, shown between pinned items and search results) local pinSeparator = scrollChild:CreateTexture(nil, "ARTWORK") pinSeparator:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.4) @@ -4027,116 +3770,45 @@ local collapsedNodes = {} -- key = "name_depth", value = true local cachedHierarchical -- last full hierarchical list for re-rendering after toggle 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, groupLoot, groupAppearanceSets, groupMap = {}, {}, {}, {}, {}, {}, {}, {} -local groupAchievements, groupCurrencies, groupReputations = {}, {}, {} -local groupBags, groupOptions = {}, {} -local groupHeirlooms = {} -local uiSectionHeader = { - name = "UI Elements", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} -local achievementSectionHeader = { - name = "Achievements", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} -local currencySectionHeader = { - name = "Currencies", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} -local reputationSectionHeader = { - name = "Reputations", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} -local mountSectionHeader = { - name = "Mounts", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} -local toySectionHeader = { - name = "Toys", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} -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 heirloomSectionHeader = { - name = "Heirlooms", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} -local lootSectionHeader = { - name = "Gear", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} -local appearanceSetSectionHeader = { - name = "Appearance Sets", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} -local mapSectionHeader = { - name = "Map Search", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} -local bagsSectionHeader = { - name = "Bags", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} -local optionsSectionHeader = { - name = "Game Options", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} - --- Flat-list mode scratch: reused entry pool keeps per-keystroke allocations --- low when uiHideHeaders is on. flatEntries holds recyclable entry tables; --- flatCombined is the merged-results buffer that gets sorted by score. local flatEntries = {} local flatCombined = {} - -local PB = { - ui = {}, ach = {}, cur = {}, rep = {}, - mounts = {}, toys = {}, pets = {}, - outfits = {}, loot = {}, appsets = {}, - bags = {}, options = {}, heirlooms = {}, -} +local pinnedSearchEntries = {} +local pinnedSearchPinEntries = {} +local pinnedOnlyEntries = {} local SCRATCH = { visible = {}, isLastChild = {}, catSepYPositions = {}, - bestCatScore = {}, - catGroups = {}, + aliasSeen = {}, + filteredResults = {}, + skipCategories = {}, } --- Exposed for /efd mem diagnostics so we can see whether per-search --- scratch tables, per-bucket result tables, or the button pool are --- responsible for the post-search memory growth. UI._flatEntries = flatEntries UI._flatCombined = flatCombined -UI._PB = PB +UI._pinnedSearchEntries = pinnedSearchEntries +UI._pinnedSearchPinEntries = pinnedSearchPinEntries UI._SCRATCH = SCRATCH UI._resultButtons = resultButtons -local function CatGroupCompare(a, b) - if a.score ~= b.score then return a.score > b.score end - return a.key < b.key -end +local heavySearchLoading = false -local function BuildBucketInto(group, bucketResults) - if #bucketResults == 0 then return end - local hier = ns.Database:BuildHierarchicalResults(bucketResults) - for hi = 1, #hier do - local entry = hier[hi] - if entry.isContainer then - collapsedNodes[entry.name .. "_" .. (entry.depth or 0)] = true +local function MaybeLoadHeavySearchData(text, needsHeavy) + if heavySearchLoading or not ns.Database or not ns.Database.LoadHeavyDynamicSearchData then return end + if not needsHeavy then return end + heavySearchLoading = true + local started = ns.Database:LoadHeavyDynamicSearchData(function() + heavySearchLoading = false + local currentText = searchFrame and searchFrame.editBox and searchFrame.editBox:GetText() + if searchFrame and searchFrame.editBox and searchFrame.editBox:HasFocus() + and currentText and currentText ~= "" then + UI:OnSearchTextChanged(currentText, true) end - group[#group + 1] = entry - end + end) + if not started then heavySearchLoading = false end end - -- Within-group ordering for flat-list mode. Score-first so the best -- matches stay at the top — alphabetical was burying high-scoring -- prefix matches (e.g. "Skull Bash" for query "skull") below low- @@ -4280,10 +3952,7 @@ function UI:CreateResultButton(index) toggleBtn:SetScript("OnClick", function(self) local row = self:GetParent():GetParent() if row.isPinHeader then - EasyFind.db.pinsCollapsed = not EasyFind.db.pinsCollapsed - if cachedHierarchical then - UI:ShowHierarchicalResults(cachedHierarchical, true) - end + return elseif row.isPathNode then local key = (row.pathNodeName or "") .. "_" .. (row.pathNodeDepth or 0) local wasCollapsed = collapsedNodes[key] @@ -5183,12 +4852,7 @@ function UI:CreateResultButton(index) return end - -- Pin header: toggle collapse if self.isPinHeader then - EasyFind.db.pinsCollapsed = not EasyFind.db.pinsCollapsed - if cachedHierarchical then - UI:ShowHierarchicalResults(cachedHierarchical, true) - end return end @@ -5331,6 +4995,7 @@ function UI:CreateResultButton(index) local link = C_PetJournal and C_PetJournal.GetBattlePetLink and C_PetJournal.GetBattlePetLink(self.icon.petID) if link and BattlePetToolTip_ShowLink then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") BattlePetToolTip_ShowLink(link) elseif link then GameTooltip:SetOwner(self, "ANCHOR_RIGHT") @@ -5434,6 +5099,7 @@ function UI:CreateResultButton(index) end end) + ApplyResultRowFonts(resultRow) resultRow:Hide() return resultRow end @@ -5563,7 +5229,11 @@ function UI:OnSearchTextChanged(text, force) end -- Treat whitespace-only as empty (pins show on focus, not on blank spaces) if text then text = strtrim(text) end + ClearResultTooltips() if not text or text == "" then + if ns.Database and ns.Database.CancelDynamicWarmup then + ns.Database:CancelDynamicWarmup() + end -- Only show pins if the editbox still has focus (avoid re-showing -- after SelectResult clears the text) if searchFrame and searchFrame.editBox and searchFrame.editBox:HasFocus() then @@ -5574,13 +5244,14 @@ function UI:OnSearchTextChanged(text, force) return end - -- Clear collapse state so every new search starts fully expanded - collapsedNodes = {} - expandedContainers = {} - - if ns.Database.ResetHierEntryPool then - ns.Database:ResetHierEntryPool() + wipe(collapsedNodes) + wipe(expandedContainers) + local needsHeavy = ns.Database and ns.Database.QueryNeedsHeavySearchData + and ns.Database:QueryNeedsHeavySearchData(text) + if not force and not needsHeavy and ns.Database and ns.Database.CancelDynamicWarmup then + ns.Database:CancelDynamicWarmup() end + MaybeLoadHeavySearchData(text, needsHeavy) -- Build skip set from filters so SearchUI avoids scoring/copying filtered categories. -- Collection items (mounts/toys/pets/outfits/appearance sets) are @@ -5609,7 +5280,8 @@ function UI:OnSearchTextChanged(text, force) if mountsOff or toysOff or petsOff or outfitsOff or lootOff or appsetsOff or bagsOff or macrosOff or gameOptOff or addonOptOff or abilitiesOff or bossesOff or heirloomsOff or titlesOff or gearSetsOff then - skipCategories = {} + skipCategories = SCRATCH.skipCategories + wipe(skipCategories) if mountsOff then skipCategories["Mount"] = true end if toysOff then skipCategories["Toy"] = true end if petsOff then skipCategories["Pet"] = true end @@ -5638,7 +5310,8 @@ function UI:OnSearchTextChanged(text, force) if ns.Aliases then local aliasMatches = ns.Aliases:GetMatches(text:lower()) if aliasMatches then - local seen = {} + wipe(SCRATCH.aliasSeen) + local seen = SCRATCH.aliasSeen for _, r in ipairs(results) do seen[r.data] = true end for i = #aliasMatches, 1, -1 do local hit = aliasMatches[i] @@ -5647,6 +5320,7 @@ function UI:OnSearchTextChanged(text, force) seen[hit.data] = true end end + wipe(seen) end end @@ -5661,7 +5335,8 @@ function UI:OnSearchTextChanged(text, force) or filters.bags == false or filters.macros == false or filters.options == false or filters.gameOptions == false or filters.addonOptions == false) then - local filtered = {} + wipe(SCRATCH.filteredResults) + local filtered = SCRATCH.filteredResults local fi = 0 for ri = 1, #results do local r = results[ri] @@ -5679,6 +5354,7 @@ function UI:OnSearchTextChanged(text, force) end end end + for i = fi + 1, #filtered do filtered[i] = nil end results = filtered end @@ -5688,66 +5364,24 @@ function UI:OnSearchTextChanged(text, force) mapResults = ns.MapSearch:SearchForUI(text) end - wipe(SCRATCH.bestCatScore) - local bestCatScore = SCRATCH.bestCatScore - for ri = 1, #results do - local r = results[ri] - 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.heirloomItemID then cat = "heirlooms" - elseif d.itemID and d.category == "Loot" then cat = "loot" - elseif d.transmogSetID then cat = "appearanceSets" - else cat = GetUIBucket(d) or "ui" - end - if s > (bestCatScore[cat] or 0) then bestCatScore[cat] = s end - end + wipe(flatCombined) + local combined = flatCombined + for ri = 1, #results do combined[#combined + 1] = results[ri] end if mapResults then - for ri = 1, #mapResults do - local s = mapResults[ri].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 hideHeaders = EasyFind.db.uiHideHeaders - local hierarchical - if hideHeaders then - -- Flat-list mode: single score-sorted list. UI results, map - -- results, collections, settings — everything gets ranked - -- together purely on score. Category clustering (used to - -- live here) hid genuinely better matches behind weaker ones - -- in higher-priority buckets, e.g. a name-prefix UI hit - -- lost to a same-bucket map result that only matched via - -- initials, because the map bucket sorted alphabetically - -- by zone before yielding to the next bucket. - wipe(flatCombined) - local combined = flatCombined - for ri = 1, #results do combined[#combined + 1] = results[ri] end - if mapResults then - for ri = 1, #mapResults do combined[#combined + 1] = mapResults[ri] end - end - if #combined > 1 then tsort(combined, FlatNameLess) end - - local n = 0 - for ri = 1, #combined do + for ri = 1, #mapResults do combined[#combined + 1] = mapResults[ri] end + end + if #combined > 1 then tsort(combined, FlatNameLess) end + + local n = 0 + for ri = 1, #combined do + local d = combined[ri] and combined[ri].data + if d then n = n + 1 local e = flatEntries[n] if not e then e = {} flatEntries[n] = e end - local d = combined[ri].data e.name = d.name e.depth = 0 e.isPathNode = false @@ -5756,290 +5390,44 @@ function UI:OnSearchTextChanged(text, force) e.flatCatKey = nil e.data = d end - for i = n + 1, #flatEntries do - flatEntries[i] = nil - end - hierarchical = flatEntries - else - - local pb = PB - wipe(pb.ui); wipe(pb.ach); wipe(pb.cur); wipe(pb.rep) - wipe(pb.mounts); wipe(pb.toys); wipe(pb.pets) - wipe(pb.outfits); wipe(pb.loot); wipe(pb.appsets) - wipe(pb.bags); wipe(pb.options); wipe(pb.heirlooms) - for ri = 1, #results do - local r = results[ri] - local d = r.data - if d.mountID then - pb.mounts[#pb.mounts + 1] = r - elseif d.toyItemID then - pb.toys[#pb.toys + 1] = r - elseif d.petID then - pb.pets[#pb.pets + 1] = r - elseif d.outfitID then - pb.outfits[#pb.outfits + 1] = r - elseif d.heirloomItemID then - pb.heirlooms[#pb.heirlooms + 1] = r - elseif d.itemID and d.category == "Loot" then - pb.loot[#pb.loot + 1] = r - elseif d.transmogSetID then - pb.appsets[#pb.appsets + 1] = r - else - local bucket = UI_BUCKET_BY_CATEGORY[d.category] - if bucket == "achievements" then - pb.ach[#pb.ach + 1] = r - elseif bucket == "currencies" then - pb.cur[#pb.cur + 1] = r - elseif bucket == "reputations" then - pb.rep[#pb.rep + 1] = r - elseif bucket == "bags" then - pb.bags[#pb.bags + 1] = r - elseif bucket == "options" then - pb.options[#pb.options + 1] = r - else - pb.ui[#pb.ui + 1] = r - end - end - end - - -- Reuse module-level group tables to hold the per-bucket hierarchies. - wipe(groupUI); wipe(groupAchievements); wipe(groupCurrencies); wipe(groupReputations) - wipe(groupMounts); wipe(groupToys); wipe(groupPets) - wipe(groupOutfits); wipe(groupLoot); wipe(groupAppearanceSets); wipe(groupMap) - wipe(groupBags); wipe(groupOptions); wipe(groupHeirlooms) - - BuildBucketInto(groupUI, pb.ui) - BuildBucketInto(groupAchievements, pb.ach) - BuildBucketInto(groupCurrencies, pb.cur) - BuildBucketInto(groupReputations, pb.rep) - BuildBucketInto(groupBags, pb.bags) - BuildBucketInto(groupOptions, pb.options) - BuildBucketInto(groupMounts, pb.mounts) - BuildBucketInto(groupToys, pb.toys) - BuildBucketInto(groupPets, pb.pets) - BuildBucketInto(groupOutfits, pb.outfits) - BuildBucketInto(groupHeirlooms, pb.heirlooms) - BuildBucketInto(groupLoot, pb.loot) - BuildBucketInto(groupAppearanceSets, pb.appsets) - -- Keep `hierarchical` defined for the rest of the function; - -- it gets rebuilt below by appending each section in catGroups order. - hierarchical = nil - - -- Map results: group by top-ancestor continent so parent/children - -- nest with collapsible headers (matches MapTab's layout). A group - -- with only one entry renders flat — no point wrapping a single - -- result in its own header. Groups with the parent zone itself as - -- a match promote the parent entry to the header's navigate target. - if mapResults then - local getAncestor = ns.MapTab and ns.MapTab.GetTopAncestor - if not getAncestor then - -- Fallback if MapTab isn't loaded yet (unlikely but safe). - for _, r in ipairs(mapResults) do - groupMap[#groupMap + 1] = { - name = r.data.name, depth = 1, - isPathNode = false, isMatch = true, data = r.data, - } - end - else - local groupsByAncestor = {} - local ancestorOrder = {} - for _, r in ipairs(mapResults) do - local d = r.data - local mapID = d.mapID or d.zoneMapID or d.entranceMapID - local ancestor = mapID and getAncestor(ns.MapTab, mapID) - if not ancestor or ancestor == "" then ancestor = "Other" end - local g = groupsByAncestor[ancestor] - if not g then - g = { items = {}, bestScore = 0, selfMatch = nil } - groupsByAncestor[ancestor] = g - ancestorOrder[#ancestorOrder + 1] = ancestor - end - -- Detect "the zone IS its own top ancestor" (Eastern - -- Kingdoms matching while all its child zones also - -- match) so the parent header can point at it rather - -- than a synthesized one. - if d.name == ancestor and d.isZone then - g.selfMatch = r - else - g.items[#g.items + 1] = r - end - local s = r.score or 0 - if s > g.bestScore then g.bestScore = s end - end - for _, ancestor in ipairs(ancestorOrder) do - local g = groupsByAncestor[ancestor] - local total = #g.items + (g.selfMatch and 1 or 0) - if total > 1 then - -- Parent header, then children at depth 2 - local headerData = g.selfMatch and g.selfMatch.data - or { name = ancestor, mapSearchResult = true, isZone = true } - groupMap[#groupMap + 1] = { - name = ancestor, depth = 1, - isPathNode = true, isMatch = true, - data = headerData, - } - for _, r in ipairs(g.items) do - groupMap[#groupMap + 1] = { - name = r.data.name, depth = 2, - isPathNode = false, isMatch = true, - data = r.data, - } - end - else - -- Single-item group: render flat, no wrapping header. - local r = g.selfMatch or g.items[1] - if r then - groupMap[#groupMap + 1] = { - name = r.data.name, depth = 1, - isPathNode = false, isMatch = true, - data = r.data, - } - end - end - end - end end - -- Sort categories by best match score so the most relevant category appears first - wipe(SCRATCH.catGroups) - local catGroups = SCRATCH.catGroups - local n = 0 - if #groupUI > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "ui"; g.score = bestCatScore.ui or 0 end - if #groupAchievements > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "achievements"; g.score = bestCatScore.achievements or 0 end - if #groupCurrencies > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "currencies"; g.score = bestCatScore.currencies or 0 end - if #groupReputations > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "reputations"; g.score = bestCatScore.reputations or 0 end - if #groupBags > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "bags"; g.score = bestCatScore.bags or 0 end - if #groupOptions > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "options"; g.score = bestCatScore.options or 0 end - if #groupMounts > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "mounts"; g.score = bestCatScore.mounts or 0 end - if #groupToys > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "toys"; g.score = bestCatScore.toys or 0 end - if #groupPets > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "pets"; g.score = bestCatScore.pets or 0 end - if #groupOutfits > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "outfits"; g.score = bestCatScore.outfits or 0 end - if #groupHeirlooms > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "heirlooms"; g.score = bestCatScore.heirlooms or 0 end - if #groupLoot > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "loot"; g.score = bestCatScore.loot or 0 end - if #groupAppearanceSets > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "appearanceSets"; g.score = bestCatScore.appearanceSets or 0 end - if #groupMap > 0 then n = n + 1; local g = catGroups[n] or {}; catGroups[n] = g; g.key = "map"; g.score = bestCatScore.map or 0 end - tsort(catGroups, CatGroupCompare) - - hierarchical = {} - 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 == "achievements" then - hierarchical[#hierarchical + 1] = achievementSectionHeader - for _, e in ipairs(groupAchievements) do hierarchical[#hierarchical + 1] = e end - elseif cat.key == "currencies" then - hierarchical[#hierarchical + 1] = currencySectionHeader - for _, e in ipairs(groupCurrencies) do hierarchical[#hierarchical + 1] = e end - elseif cat.key == "reputations" then - hierarchical[#hierarchical + 1] = reputationSectionHeader - for _, e in ipairs(groupReputations) do hierarchical[#hierarchical + 1] = e end - elseif cat.key == "bags" then - hierarchical[#hierarchical + 1] = bagsSectionHeader - for _, e in ipairs(groupBags) do hierarchical[#hierarchical + 1] = e end - elseif cat.key == "options" then - hierarchical[#hierarchical + 1] = optionsSectionHeader - for _, e in ipairs(groupOptions) 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 == "heirlooms" then - hierarchical[#hierarchical + 1] = heirloomSectionHeader - for _, e in ipairs(groupHeirlooms) 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 - slotGroups[slot][#slotGroups[slot] + 1] = e - end - for _, slot in ipairs(slotOrder) do - hierarchical[#hierarchical + 1] = { - name = slot, depth = 1, isPathNode = true, - isMatch = false, isSectionHeader = false, - } - 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 - elseif cat.key == "appearanceSets" then - hierarchical[#hierarchical + 1] = appearanceSetSectionHeader - for _, e in ipairs(groupAppearanceSets) do e.depth = 1; hierarchical[#hierarchical + 1] = e end - elseif cat.key == "map" then - hierarchical[#hierarchical + 1] = mapSectionHeader - for _, e in ipairs(groupMap) do hierarchical[#hierarchical + 1] = e end - end + for i = n + 1, #flatEntries do + flatEntries[i] = nil end - end -- end if hideHeaders / else branch - - -- Prepend pinned items at the top (always visible regardless of query). - -- In flat-headerless mode, skip the "Pinned Paths" header but still show pins - -- on top so quick-launches stay accessible. + local hierarchical = flatEntries local pins = GetAllPins() if #pins > 0 then - local pinnedEntries = {} - if not hideHeaders then - pinnedEntries[#pinnedEntries + 1] = { - isPinHeader = true, - name = "Pinned Paths", - depth = 0, - isPathNode = true, - isMatch = false, - } - end + wipe(pinnedSearchEntries) + local pinnedEntries = pinnedSearchEntries + local pcount = 0 for _, pin in ipairs(pins) do - tinsert(pinnedEntries, { - name = pin.name, - depth = 0, - isPathNode = false, - isMatch = true, - isPinned = true, - isFlat = hideHeaders or nil, - data = pin, - }) + pcount = pcount + 1 + local e = pinnedSearchPinEntries[pcount] + if not e then + e = {} + pinnedSearchPinEntries[pcount] = e + end + e.name = pin.name + e.depth = 0 + e.isPathNode = false + e.isMatch = true + e.isPinned = true + e.isFlat = true + e.data = pin + pinnedEntries[pcount] = e + end + for i = 1, #hierarchical do + pcount = pcount + 1 + pinnedEntries[pcount] = hierarchical[i] end - -- Combine: pinned header + pins first, then all search results - -- (pinned items may also appear in results - intentional so the user - -- can see where the path stands in the full hierarchy) - for _, entry in ipairs(hierarchical) do - tinsert(pinnedEntries, entry) + for i = pcount + 1, #pinnedEntries do + pinnedEntries[i] = nil end hierarchical = pinnedEntries + else + wipe(pinnedSearchEntries) end - local _perfTBuild = ns.PERF and debugprofilestop() or 0 self:ShowHierarchicalResults(hierarchical) if ns.PERF then @@ -6111,7 +5499,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- Render-skip: if the input list is identical (same length, same -- data refs and same depth at every index) AND the relevant view - -- state (theme, collapse state, hide-headers, results-above) hasn't + -- state (theme, collapse state, results-above) hasn't -- changed since the last render, the visible output would be byte- -- for-byte identical. Skip the entire per-row layout pass — this is -- the typical case during typing once the top results stabilize. @@ -6121,17 +5509,14 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- typing. Snapshot a single key (or nil if empty) — a click on -- a collapse toggle adds or removes a key, which we'll see. local theme = EasyFind.db.resultsTheme - local hideHeaders = EasyFind.db.uiHideHeaders local above = EasyFind.db.uiResultsAbove local collapsedKey = next(collapsedNodes) local n = #hierarchical local last = self._lastRenderSig local same = last and last.n == n and last.theme == theme - and last.hideHeaders == hideHeaders and last.above == above and last.collapsedKey == collapsedKey - and last.pinsCollapsed == EasyFind.db.pinsCollapsed and resultsFrame:IsShown() if same then for hi = 1, n do @@ -6150,10 +5535,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if not last then last = {}; self._lastRenderSig = last end last.n = n last.theme = theme - last.hideHeaders = hideHeaders last.above = above last.collapsedKey = collapsedKey - last.pinsCollapsed = EasyFind.db.pinsCollapsed for hi = 1, n do local e = hierarchical[hi] last[hi * 2 - 1] = e.data @@ -6162,6 +5545,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) for i = n * 2 + 1, #last do last[i] = nil end end + ClearResultTooltips() + local theme = GetActiveTheme() local rowH = theme.rowHeight local indPx = theme.indentPx @@ -6200,14 +5585,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultsFrame:SetWidth((customW and customW > 1) and customW or theme.resultsWidth) end - -- Apply background atlas if specified (e.g. quest log background) - if not resultsFrame.bgAtlasTex then - local tex = resultsFrame:CreateTexture(nil, "BACKGROUND", nil, -1) - -- Stretch horizontally to fill frame, but keep native height (clipped by frame) - tex:SetPoint("TOPLEFT", resultsFrame, "TOPLEFT", 4, -4) - tex:SetPoint("BOTTOMRIGHT", resultsFrame, "BOTTOMRIGHT", -4, 4) - resultsFrame.bgAtlasTex = tex - end if theme.resultsBgAtlas then resultsFrame.bgAtlasTex:SetAtlas(theme.resultsBgAtlas, false) resultsFrame.bgAtlasTex:Show() @@ -6240,11 +5617,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) visibleN = visibleN + 1 visible[visibleN] = entry - if entry.isPinHeader then - if EasyFind.db.pinsCollapsed then - skipPins = true - end - elseif entry.isPathNode then + if entry.isPathNode then local key = entry.name .. "_" .. d if collapsedNodes[key] then skipBelowDepth = d @@ -6288,7 +5661,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- Determine pin separator placement local PIN_SEP_HEIGHT = 9 -- 4px gap + 1px line + 4px gap - local CAT_SEP_HEIGHT = 9 -- same dimensions as pin separator local lastPinIndex = 0 local hasResultsAfterPins = false for i = 1, count do @@ -6300,20 +5672,14 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) hasResultsAfterPins = true end - -- Section labels carry their own pair of gold rules, so the - -- separator-above-label was just doubling up on the visual cut. - -- Keep the table around for the render loop's lookup but never - -- populate it. - local catSepBeforeIndex = {} - local yOffset = 0 local pinEndYOffset = 0 wipe(SCRATCH.catSepYPositions) local catSepYPositions = SCRATCH.catSepYPositions local hasSideBySideRepBar = false for i = 1, MAX_BUTTON_POOL do - local resultRow = resultButtons[i] - if i <= count then + local resultRow = i <= count and EnsureResultButton(i) or resultButtons[i] + if resultRow and i <= count then local entry = visible[i] local data = entry.data local depth = entry.depth or 0 @@ -6324,12 +5690,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) yOffset = yOffset + PIN_SEP_HEIGHT end - -- Category separator gap (between UI, Mount, and Toy groups) - if catSepBeforeIndex[i] then - catSepYPositions[#catSepYPositions + 1] = yOffset - yOffset = yOffset + CAT_SEP_HEIGHT - end - -- Small gap between pinned items (not after pin header) if entry.isPinned and i > 1 and visible[i - 1] and not visible[i - 1].isPinHeader then yOffset = yOffset + 4 @@ -6466,10 +5826,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- Pin header: plain text + toggle icon + underline (no tab/gradient) resultRow.headerTab:Hide() resultRow.headerGrad:Hide() - local isCollapsed = EasyFind.db.pinsCollapsed - local expandAtlas = theme.expandAtlas or "QuestLog-icon-expand" local collapseAtlas = theme.collapseAtlas or "QuestLog-icon-shrink" - resultRow.pinToggle:SetAtlas(isCollapsed and expandAtlas or collapseAtlas) + resultRow.pinToggle:SetAtlas(collapseAtlas) resultRow.pinToggle:Show() resultRow.pinHeaderLine:Show() -- Position text: left-aligned, right-bounded by toggle @@ -7557,7 +6915,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) yOffset = yOffset + actualH resultRow:Show() - else + elseif resultRow then resultRow:Hide() resultRow.isPinHeader = false if not InCombatLockdown() then @@ -7702,9 +7060,35 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end function UI:ShowResults(results) - -- Legacy function, redirects to hierarchical - local hierarchical = ns.Database:BuildHierarchicalResults(results) - self:ShowHierarchicalResults(hierarchical) + if not results or #results == 0 then + self:HideResults() + return + end + + local n = 0 + for ri = 1, #results do + local r = results[ri] + local d = r and (r.data or r) + if d then + n = n + 1 + local e = flatEntries[n] + if not e then + e = {} + flatEntries[n] = e + end + e.name = d.name + e.depth = 0 + e.isPathNode = false + e.isMatch = true + e.isFlat = true + e.flatCatKey = nil + e.data = d + end + end + for i = n + 1, #flatEntries do + flatEntries[i] = nil + end + self:ShowHierarchicalResults(flatEntries) end function UI:RefreshResults() @@ -7894,6 +7278,25 @@ function UI:HideResults() selectedIndex = 0 toggleFocused = false self:UpdateSelectionHighlight(true) + + if ns.Database and ns.Database.CancelDynamicWarmup then + ns.Database:CancelDynamicWarmup() + end + + idleTrimSerial = idleTrimSerial + 1 + local serial = idleTrimSerial + if C_Timer and C_Timer.After then + C_Timer.After(60, function() + if serial ~= idleTrimSerial then return end + if resultsFrame and resultsFrame:IsShown() then return end + if ns.Database and ns.Database.TrimSearchMemory then + ns.Database:TrimSearchMemory() + end + if ns.MapSearch and ns.MapSearch.TrimSearchMemory then + ns.MapSearch:TrimSearchMemory() + end + end) + end end function UI:ShowPinnedItems() @@ -7904,46 +7307,42 @@ function UI:ShowPinnedItems() return end - -- Build synthetic hierarchical entries and delegate to the same renderer - -- used during search, so pinned items look identical in both cases. - collapsedNodes = {} - expandedContainers = {} - local hideHeaders = EasyFind.db.uiHideHeaders - local entries = {} - if not hideHeaders then - entries[#entries + 1] = { - isPinHeader = true, - name = "Pinned Paths", - depth = 0, - isPathNode = true, - isMatch = false, - } + wipe(collapsedNodes) + wipe(expandedContainers) + local entries = pinnedOnlyEntries + for i, pin in ipairs(pins) do + local e = entries[i] + if not e then + e = {} + entries[i] = e + end + e.name = pin.name + e.depth = 0 + e.isPathNode = false + e.isMatch = true + e.isPinned = true + e.isFlat = true + e.data = pin end - for _, pin in ipairs(pins) do - tinsert(entries, { - name = pin.name, - depth = 0, - isPathNode = false, - isMatch = true, - isPinned = true, - isFlat = hideHeaders or nil, - data = pin, - }) + for i = #pins + 1, #entries do + entries[i] = nil end self:ShowHierarchicalResults(entries) end function UI:SelectFirstResult() -- Only select if results are visible and there's actual data - if resultsFrame:IsShown() and resultButtons[1]:IsShown() and resultButtons[1].data then - self:SelectResult(resultButtons[1].data) + local first = resultButtons[1] + if resultsFrame:IsShown() and first and first:IsShown() and first.data then + self:SelectResult(first.data) end end function UI:CountVisibleResults() local count = 0 for i = 1, MAX_BUTTON_POOL do - if resultButtons[i]:IsShown() then + local row = resultButtons[i] + if row and row:IsShown() then count = i else break @@ -8058,9 +7457,6 @@ function UI:UpdateSelectionHighlight(skipRefocus) resultRow:UnlockHighlight() end end - -- Retired blue glow: keyboard selection now uses the same - -- tabHoverOverlay the mouse-hover path does so the two paths - -- look identical. Keep the texture reference silent. if resultRow.tabSelectionHighlight then resultRow.tabSelectionHighlight:Hide() end @@ -8148,23 +7544,13 @@ end function UI:ActivateSelected() if selectedIndex > 0 and selectedIndex <= MAX_BUTTON_POOL then local resultRow = resultButtons[selectedIndex] - if resultRow:IsShown() then + if resultRow and resultRow:IsShown() then -- Don't allow activating unearned currencies if resultRow.isUnearnedCurrency then return end - -- Pin header: toggle collapse if resultRow.isPinHeader then - EasyFind.db.pinsCollapsed = not EasyFind.db.pinsCollapsed - if cachedHierarchical then - local savedIndex = selectedIndex - local savedToggle = toggleFocused - self:ShowHierarchicalResults(cachedHierarchical, true) - selectedIndex = savedIndex - toggleFocused = savedToggle - self:UpdateSelectionHighlight() - end return end @@ -8604,6 +7990,59 @@ function UI:SelectResult(data, forceGuide) end end +local EncounterDataMatches +EncounterDataMatches = function(value, targetID, targetName) + if not value then return false end + if targetID and (value.encounterID == targetID or value.journalEncounterID == targetID or value.id == targetID) then + return true + end + local name = value.name or value.title or value.text + if targetName and name and slower(name) == targetName then return true end + local nested = value.data or value.elementData + if nested and nested ~= value then + return EncounterDataMatches(nested, targetID, targetName) + end + return false +end + +local function GetEJBossesScrollBox() + local infoFrame = _G["EncounterJournalEncounterFrameInfo"] + return infoFrame and infoFrame.BossesScrollBox +end + +local function EncounterFrameMatches(btn, targetID, targetName) + local edata = btn.GetElementData and btn:GetElementData() + if EncounterDataMatches(edata, targetID, targetName) then return true end + if targetID and (btn.encounterID == targetID or btn.journalEncounterID == targetID or btn.id == targetID) then + return true + end + local text = GetButtonText(btn) + return targetName and text and slower(text) == targetName +end + +local function RevealEJEncounter(step) + local targetID = step.ejEncounterID + local targetName = step.ejBoss and slower(step.ejBoss) + local function reveal() + local scrollBox = GetEJBossesScrollBox() + if not scrollBox then return end + Utils.ScrollBoxScrollTo(scrollBox, function(edata) + return EncounterDataMatches(edata, targetID, targetName) + end) + local bossBtn = Utils.ScrollBoxFindButton(scrollBox, function(btn) + return EncounterFrameMatches(btn, targetID, targetName) + end) + if bossBtn and bossBtn.GetElementData and scrollBox.ScrollToElementData then + local edata = bossBtn:GetElementData() + if edata then + scrollBox:ScrollToElementData(edata, ScrollBoxConstants and ScrollBoxConstants.AlignCenter) + end + end + end + reveal() + if C_Timer and C_Timer.After then C_Timer.After(0.05, reveal) end +end + -- Direct open mode - programmatically navigates to the target as far as possible. -- Executes ALL steps that represent clickable navigation (tabs, categories, buttons). -- Only falls back to highlighting when the final step is a non-navigable UI region @@ -8886,17 +8325,21 @@ function UI:DirectOpen(data) local displayEncounter = _G["EncounterJournal_DisplayEncounter"] if step.ejEncounterID and displayEncounter then pcall(displayEncounter, step.ejEncounterID) + RevealEJEncounter(step) else local infoFrame = _G["EncounterJournalEncounterFrameInfo"] local scrollBox = infoFrame and infoFrame.BossesScrollBox if scrollBox then local targetName = slower(step.ejBoss) + Utils.ScrollBoxScrollTo(scrollBox, function(edata) + return EncounterDataMatches(edata, step.ejEncounterID, targetName) + end) local bossBtn = Utils.ScrollBoxFindButton(scrollBox, function(btn) - local text = Utils.GetButtonText(btn) - return text and slower(text) == targetName + return EncounterFrameMatches(btn, step.ejEncounterID, targetName) end) if bossBtn then ClickButton(bossBtn) end end + RevealEJEncounter(step) end -- ejLootTab needs a frame for the boss content to settle -- before we click the loot tab (otherwise Overview eats @@ -9245,6 +8688,9 @@ end function UI:Hide() if not searchFrame then return end + if ns.Database and ns.Database.CancelDynamicWarmup then + ns.Database:CancelDynamicWarmup() + end searchFrame:Hide() searchFrame.setSmartShowVisible(false) self:HideResults() @@ -10079,6 +9525,18 @@ function UI:ShowFirstTimeSetup() local demoFrame local startDemo + local function EnsureDemoLoaded() + if ns.Demo and ns.Demo.Start then return true end + if C_AddOns and C_AddOns.LoadAddOn then + local ok, reason = pcall(C_AddOns.LoadAddOn, "EasyFind_Demo") + if ok and ns.Demo and ns.Demo.Start then return true end + EasyFind:Print("Demo module could not be loaded: " .. tostring(reason or "unknown error")) + else + EasyFind:Print("Demo module is not available on this client.") + end + return false + end + local function FinishSetup() EasyFind.db.setupComplete = true @@ -10119,12 +9577,20 @@ function UI:ShowFirstTimeSetup() end startDemo = function() - ns.Demo.Start({ + if not EnsureDemoLoaded() then return false end + + panel:Hide() + resizer:Hide() + glow:SetScript("OnUpdate", nil) + glow:Hide() + + demoFrame = ns.Demo.Start({ searchFrame = searchFrame, resultsFrame = resultsFrame, resultButtons = resultButtons, finishSetup = FinishSetup, }) + return true end gotItBtn:SetScript("OnClick", FinishSetup) @@ -10150,13 +9616,6 @@ function UI:ShowFirstTimeSetup() EasyFind.db.staticOpacity = not fadeCheckbox:GetChecked() UI:UpdateSmartShow() - -- Hide all setup-mode chrome before the demo takes over the - -- search bar. Without this, the gold "Drag to move" glow - -- lingers on top of the demo. - panel:Hide() - resizer:Hide() - glow:SetScript("OnUpdate", nil) - glow:Hide() startDemo() end) end @@ -10199,14 +9658,6 @@ end function UI:UpdateFontSize() local scale = EasyFind.db.fontSize or 1.0 - local function ScaleFont(fontString, baseFontObject) - local obj = _G[baseFontObject] - if not obj then return end - local path, baseSize, flags = obj:GetFont() - fontString:SetFont(path, baseSize * scale, flags) - fontString:SetJustifyH(fontString:GetJustifyH()) - end - if not searchFrame then return end ScaleFont(searchFrame.editBox, ns.SEARCHBAR_FONT) @@ -10250,18 +9701,7 @@ function UI:UpdateFontSize() end for i = 1, #resultButtons do - local row = resultButtons[i] - ScaleFont(row.text, theme.leafFont) - ScaleFont(row.tabText, theme.pathFont) - if row.pathSubtext then - ScaleFont(row.pathSubtext, theme.leafFont) - end - if row.amountText then - ScaleFont(row.amountText, "GameFontNormalSmall") - end - if row.repBarText then - ScaleFont(row.repBarText, "GameFontNormalSmall") - end + ApplyResultRowFonts(resultButtons[i], theme) end -- Re-layout visible results with new row heights diff --git a/UIPins.lua b/UIPins.lua new file mode 100644 index 0000000..e4ef6b4 --- /dev/null +++ b/UIPins.lua @@ -0,0 +1,183 @@ +local _, ns = ... + +local UIPins = {} +ns.UIPins = UIPins + +local Utils = ns.Utils +local ipairs, pairs, type = Utils.ipairs, Utils.pairs, Utils.type +local tconcat, tinsert, tremove = Utils.tconcat, Utils.tinsert, Utils.tremove + +local charKey + +local SIMPLE_FIELDS = { + "name", "nameLower", "category", "buttonFrame", "flashLabel", "icon", + "mountID", "spellID", "toyItemID", "petID", "speciesID", "outfitID", "heirloomItemID", + "macroIndex", "macroIsChar", "bagID", "bagSlot", "bagItemLink", + "itemID", "encounterID", "instanceID", "lootSlotName", "lootSourceName", "lootInstanceName", "lootSourceType", + "transmogSetID", + "factionID", "hasRepBar", "canQueue", "isPvP", "isPvE", +} + +local TABLE_FIELDS = { + "path", "steps", "keywords", "keywordsLower", "lootItemLinks", +} + +function UIPins.GetKey(data) + if not data or not data.name then return "" end + return data.name .. "|" .. tconcat(data.path or {}, ">") +end + +local function CleanForStorage(data) + local clean = {} + for i = 1, #SIMPLE_FIELDS do + local key = SIMPLE_FIELDS[i] + local value = data[key] + if value ~= nil then clean[key] = value end + end + for i = 1, #TABLE_FIELDS do + local key = TABLE_FIELDS[i] + local value = data[key] + if value then + local copy = {} + for k, v in pairs(value) do + if type(v) == "table" then + local sub = {} + for sk, sv in pairs(v) do sub[sk] = sv end + copy[k] = sub + else + copy[k] = v + end + end + clean[key] = copy + end + end + return clean +end + +function UIPins.IsCollection(data) + return data and (data.mountID or data.toyItemID or data.petID or data.outfitID + or data.heirloomItemID + or (data.itemID and data.category == "Loot")) +end + +function UIPins.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) + local db = EasyFind and EasyFind.db + if not db then return nil end + if UIPins.IsCollection(data) then + local key = UIPins.GetCharKey() + db.pinnedUIItemsPerChar = db.pinnedUIItemsPerChar or {} + db.pinnedUIItemsPerChar[key] = db.pinnedUIItemsPerChar[key] or {} + return db.pinnedUIItemsPerChar[key] + end + db.pinnedUIItems = db.pinnedUIItems or {} + return db.pinnedUIItems +end + +function UIPins.GetAll() + local db = EasyFind and EasyFind.db + local all = {} + if not db then return all end + for _, pin in ipairs(db.pinnedUIItems or {}) do + all[#all + 1] = pin + end + local charPins = db.pinnedUIItemsPerChar and db.pinnedUIItemsPerChar[UIPins.GetCharKey()] + if charPins then + for _, pin in ipairs(charPins) do + all[#all + 1] = pin + end + end + return all +end + +function UIPins.IsPinned(data) + local db = EasyFind and EasyFind.db + if not db then return false end + local key = UIPins.GetKey(data) + for _, pin in ipairs(db.pinnedUIItems or {}) do + if UIPins.GetKey(pin) == key then return true end + end + if UIPins.IsCollection(data) then + local charPins = db.pinnedUIItemsPerChar and db.pinnedUIItemsPerChar[UIPins.GetCharKey()] + if charPins then + for _, pin in ipairs(charPins) do + if UIPins.GetKey(pin) == key then return true end + end + end + end + return false +end + +function UIPins.Pin(data) + if UIPins.IsPinned(data) then return end + local list = GetPinList(data) + if not list then return end + local clean = CleanForStorage(data) + clean.isPinned = true + tinsert(list, clean) +end + +function UIPins.Unpin(data) + local db = EasyFind and EasyFind.db + if not db then return end + local key = UIPins.GetKey(data) + local items = db.pinnedUIItems or {} + for i = #items, 1, -1 do + if UIPins.GetKey(items[i]) == key then + tremove(items, i) + return + end + end + if UIPins.IsCollection(data) then + local charPins = db.pinnedUIItemsPerChar and db.pinnedUIItemsPerChar[UIPins.GetCharKey()] + if charPins then + for i = #charPins, 1, -1 do + if UIPins.GetKey(charPins[i]) == key then + tremove(charPins, i) + return + end + end + end + end +end + +function UIPins.SyncOutfits() + local db = EasyFind and EasyFind.db + if not db or not C_TransmogOutfitInfo or not C_TransmogOutfitInfo.GetOutfitsInfo then return end + local outfits = C_TransmogOutfitInfo.GetOutfitsInfo() + if not outfits then return end + + local lookup = {} + for _, info in ipairs(outfits) do + lookup[info.outfitID] = info + end + + 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 + tremove(pins, i) + end + end + end + end + + syncList(db.pinnedUIItems) + local charPins = db.pinnedUIItemsPerChar and db.pinnedUIItemsPerChar[UIPins.GetCharKey()] + syncList(charPins) +end diff --git a/Utils.lua b/Utils.lua index 0b5efbe..d83ac10 100644 --- a/Utils.lua +++ b/Utils.lua @@ -158,6 +158,8 @@ function Utils.AttachAutocomplete(editBox, opts) if not editBox or not opts or type(opts.findCandidate) ~= "function" then return end local findCandidate = opts.findCandidate + local onTypedChanged = opts.onTypedChanged + local onAccepted = opts.onAccepted local typedText = "" local programmatic = false -- Last suggestion we rendered into the editbox. Used by the smooth-typing @@ -227,8 +229,10 @@ function Utils.AttachAutocomplete(editBox, opts) local cursorPos = self:GetCursorPosition() local typed = current:sub(1, cursorPos) if typed == typedText then return end + local prevText = typedText local prevLen = #typedText typedText = typed + if onTypedChanged then onTypedChanged(self, typedText, prevText, #typedText > prevLen) end -- Smooth-typing fast path only when the user is GROWING the typed -- prefix. On a shrink (backspace) the suggestion must be torn @@ -254,20 +258,28 @@ function Utils.AttachAutocomplete(editBox, opts) editBox:HookScript("OnEditFocusLost", StripAutocomplete) - editBox:HookScript("OnTabPressed", function(self) + local function AcceptAutocomplete(self) local current = self:GetText() or "" - if current == "" or current == typedText then return end + if current == "" or current == typedText then return false end programmatic = true self:SetCursorPosition(#current) self:HighlightText(0, 0) programmatic = false typedText = current + currentCandidate = nil + if onAccepted then onAccepted(current) end + return true + end + + editBox:HookScript("OnTabPressed", function(self) + AcceptAutocomplete(self) end) -- Public API on the editbox so callers can drive it from their -- search-update path without holding their own state. editBox.UpdateAutocomplete = ApplyAutocomplete editBox.StripAutocomplete = StripAutocomplete + editBox.AcceptAutocomplete = AcceptAutocomplete editBox.GetTypedText = function() return typedText end editBox.IsAutocompleteProgrammatic = function() return programmatic end end @@ -557,12 +569,15 @@ function Utils.DebugPrint(...) end end +local BUTTON_TEXT_KEYS = {"label", "Label", "text", "Text", "Name", "name"} +local FRAME_TEXT_KEYS = {"Label", "label", "Text", "text", "Name", "name"} +local frameTextScratch = {} + function Utils.GetButtonText(btn) if not btn then return nil end -- Named text children (covers virtually every Blizzard button style) - local keys = {"label", "Label", "text", "Text", "Name", "name"} - for _, key in ipairs(keys) do + for _, key in ipairs(BUTTON_TEXT_KEYS) do local child = btn[key] if child and child.GetText then local t = child:GetText() @@ -591,10 +606,10 @@ end -- Collects all text from a frame into a single string for fuzzy matching. function Utils.GetAllFrameText(frame) if not frame then return nil end - local texts = {} + wipe(frameTextScratch) + local texts = frameTextScratch - local keys = {"Label", "label", "Text", "text", "Name", "name"} - for _, key in ipairs(keys) do + for _, key in ipairs(FRAME_TEXT_KEYS) do local child = frame[key] if child and child.GetText then local t = child:GetText() @@ -1048,6 +1063,142 @@ function Utils.CreateClearButton(parent, globalName) return btn end +function Utils.ShowCursorMenu(globalName, rows, opts) + opts = opts or {} + local menu = _G[globalName] + if not menu then + menu = CreateFrame("Frame", globalName, UIParent, "BackdropTemplate") + menu:EnableMouse(true) + menu.rows = {} + menu:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", + edgeFile = ns.TOOLTIP_BORDER, + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 2, right = 2, top = 2, bottom = 2 } + }) + local bg = ns.DARK_PANEL_BG + if bg then menu:SetBackdropColor(bg[1], bg[2], bg[3], bg[4]) end + menu:SetScript("OnShow", function(self) + self._showedAt = GetTime() + self._outsideSince = nil + self._hasEntered = false + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + self:RegisterEvent("GLOBAL_MOUSE_UP") + end) + menu:SetScript("OnHide", function(self) + self._outsideSince = nil + self._hasEntered = false + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + self:UnregisterEvent("GLOBAL_MOUSE_UP") + end) + menu:SetScript("OnUpdate", function(self) + if self:IsMouseOver() then + self._outsideSince = nil + self._hasEntered = true + return + end + if not self._hasEntered then return end + local now = GetTime() + if not self._outsideSince then + self._outsideSince = now + return + end + if now - self._outsideSince > (self.outsideDelay or 0.3) then + self:Hide() + end + end) + menu:SetScript("OnEvent", function(self, event) + if event ~= "GLOBAL_MOUSE_DOWN" and event ~= "GLOBAL_MOUSE_UP" then return end + if self._showedAt and (GetTime() - self._showedAt) < (self.clickGrace or 0.05) then return end + if not self:IsMouseOver() then self:Hide() end + end) + end + + menu:SetFrameStrata(opts.strata or "TOOLTIP") + menu:SetFrameLevel(opts.level or 10000) + menu:SetToplevel(opts.toplevel ~= false) + menu.outsideDelay = opts.outsideDelay or 0.3 + menu.clickGrace = opts.clickGrace or 0.05 + + local rowH = opts.rowHeight or 22 + local width = opts.width or 96 + local shown = 0 + local lastRow + for i = 1, #rows do + local def = rows[i] + if def then + shown = shown + 1 + local row = menu.rows[shown] + if not row then + row = CreateFrame("Button", nil, menu) + row:SetHeight(rowH) + row:RegisterForClicks("LeftButtonUp") + row:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD") + row.label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal") + row.label:SetPoint("LEFT", row, "LEFT", 8, 0) + row.icon = row:CreateTexture(nil, "OVERLAY") + row.icon:SetSize(14, 14) + row.icon:SetPoint("RIGHT", row, "RIGHT", -8, 0) + menu.rows[shown] = row + end + row:SetHeight(rowH) + row.label:SetText(def.text or "") + if def.icon then + row.icon:SetTexture(def.icon) + row.icon:Show() + else + row.icon:Hide() + end + local onClick = def.onClick + row:SetScript("OnClick", function() + menu:Hide() + if onClick then onClick() end + end) + row:ClearAllPoints() + if shown == 1 then + row:SetPoint("TOPLEFT", menu, "TOPLEFT", 4, -4) + row:SetPoint("TOPRIGHT", menu, "TOPRIGHT", -4, -4) + else + row:SetPoint("TOPLEFT", lastRow, "BOTTOMLEFT", 0, 0) + row:SetPoint("TOPRIGHT", lastRow, "BOTTOMRIGHT", 0, 0) + end + row:Show() + lastRow = row + end + end + for i = shown + 1, #menu.rows do + menu.rows[i]:Hide() + menu.rows[i]:SetScript("OnClick", nil) + end + + menu:SetSize(width, rowH * shown + 8) + local scale = UIParent:GetEffectiveScale() + local x, y = GetCursorPosition() + menu:ClearAllPoints() + menu:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", + x / scale + (opts.offsetX or 0), y / scale + (opts.offsetY or 0)) + menu:Show() + return menu +end + +function Utils.SetIconTexture(textureObj, icon, fallback) + if not textureObj then return end + textureObj:SetTexture(nil) + if textureObj.SetAtlas then textureObj:SetAtlas(nil) end + textureObj:SetTexCoord(0, 1, 0, 1) + if type(icon) == "table" and icon.file then + textureObj:SetTexture(icon.file) + local c = icon.coords + if c then textureObj:SetTexCoord(c[1], c[2], c[3], c[4]) end + elseif type(icon) == "string" and ssub(icon, 1, 6) == "atlas:" then + textureObj:SetAtlas(ssub(icon, 7), false) + elseif icon then + textureObj:SetTexture(icon) + else + textureObj:SetTexture(fallback or "Interface\\Icons\\INV_Misc_QuestionMark") + end +end + -- --------------------------------------------------------------------------- -- Addon-wide font selection. -- diff --git a/textures/near-track-ring.tga b/textures/near-track-ring.tga deleted file mode 100644 index 1b731063f7bc8c1cec10500acc3c418debe2ebad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 262188 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 Date: Wed, 6 May 2026 04:13:28 -0400 Subject: [PATCH 039/103] Refactor: extract MapUtils + consolidate helpers; trim modules Net -827 lines across 8 files. - MapUtils.lua (new, 215 lines): zone-name normalization helpers pulled out of MapTab / MapSearch -- ZONE_ABBREVIATIONS (sw, stormwind, og, etc.), PATH_STRIP_ROOTS, PARENT_OVERRIDES, shared C_Map.GetMapInfo wrappers. Loaded right after Utils.lua so map-touching modules can pull from a single source. - Utils.lua (+309): absorbs shared helpers that were duplicated across UI / MapSearch / MapTab. - Database.lua, MapSearch.lua, MapTab.lua, UI.lua: trimmed (-327 / -122 / -207 / -448) by deleting the inlined helpers now living in Utils / MapUtils, and removing the dead-code paths left over from the prior minimap-nav removal. - Perf.lua: drop SNAP_FIELDS entries for tables that no longer exist (hierEntryPool, treeNodePool, UI.PB). - EasyFind.toc: register MapUtils.lua. --- Database.lua | 327 ++---------------------------------- EasyFind.toc | 1 + MapSearch.lua | 122 ++------------ MapTab.lua | 207 ++++------------------- MapUtils.lua | 215 ++++++++++++++++++++++++ Perf.lua | 3 - UI.lua | 448 ++++---------------------------------------------- Utils.lua | 309 +++++++++++++++++++++++----------- 8 files changed, 510 insertions(+), 1122 deletions(-) create mode 100644 MapUtils.lua diff --git a/Database.lua b/Database.lua index 6d25a80..d501790 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 = Utils.tsort local sfind, slower, ssub = Utils.sfind, Utils.slower, Utils.ssub local sbyte = string.byte local mmin, mmax, mabs = Utils.mmin, Utils.mmax, Utils.mabs @@ -2186,6 +2186,8 @@ function Database:PopulateDynamicBags() local CONT = C_Container local getNumSlots = (CONT and CONT.GetContainerNumSlots) or GetContainerNumSlots local getItemInfo = (CONT and CONT.GetContainerItemInfo) or GetContainerItemInfo + local getItemInfoInstant = (C_Item and C_Item.GetItemInfoInstant) or GetItemInfoInstant + local isEquippableItem = (C_Item and C_Item.IsEquippableItem) or _G.IsEquippableItem if not getNumSlots or not getItemInfo then return end RemoveEntriesByCategory("Bag") @@ -2224,6 +2226,12 @@ function Database:PopulateDynamicBags() for _, itemID in ipairs(order) do local info = itemMap[itemID] local name = info.link and info.link:match("%[(.-)%]") or (GetItemInfo and GetItemInfo(itemID)) or ("Item " .. itemID) + local equipLoc + if getItemInfoInstant then + local _, _, _, itemEquipLoc = getItemInfoInstant(itemID) + equipLoc = itemEquipLoc + end + local isEquippable = (equipLoc and equipLoc ~= "") or (isEquippableItem and isEquippableItem(itemID)) or false local first = info.locations[1] local bagBtn = first.bag == 0 and "MainMenuBarBackpackButton" or ("CharacterBag" .. (first.bag - 1) .. "Slot") @@ -2237,6 +2245,8 @@ function Database:PopulateDynamicBags() category = "Bag", icon = info.texture, itemID = itemID, + equipLoc = equipLoc, + isEquippable = isEquippable, bagID = first.bag, bagSlot = first.slot, bagItemLink = info.link, @@ -2302,7 +2312,6 @@ function Database:FlattenTree(tree, parentPath, parentSteps, parentButtonFrame, if node.icon then entry.icon = node.icon end if node.available then entry.available = node.available end if node.canQueue then entry.canQueue = true end - if node.children then entry.isContainer = true end uiSearchData[#uiSearchData + 1] = entry @@ -4005,222 +4014,6 @@ local function GetMultiTokenPrefixCandidates(queryWords) return #prefixCandidateBuf > 0 and prefixCandidateBuf or nil end -local sortChildrenNode -local function compareChildByScore(a, b) - local children = sortChildrenNode.children - local sa = children[a].bestScore or 0 - local sb = children[b].bestScore or 0 - if sa ~= sb then return sa > sb end - return a < b -end -local function sortChildren(node) - sortChildrenNode = node - tsort(node.childOrder, compareChildByScore) - local children = node.children - local order = node.childOrder - for i = 1, #order do - sortChildren(children[order[i]]) - end -end - --- Tree node pool. BuildHierarchicalResults previously allocated ~300 --- fresh tables per call (root + per-result entry + path nodes). At 60 --- keystrokes/sec that's ~18k tables/sec -- the bulk of the per-search --- GC churn that caused +29 MB transient peaks during HOLD-backspace. --- Pooling reuses the same node tables across searches. -local treeNodePool = {} -local treeNodePoolN = 0 -Database._treeNodePool = treeNodePool - -local function getTreeNode() - treeNodePoolN = treeNodePoolN + 1 - local n = treeNodePool[treeNodePoolN] - if not n then - n = { children = {}, childOrder = {} } - treeNodePool[treeNodePoolN] = n - else - wipe(n.children) - wipe(n.childOrder) - end - n.name = nil - n.data = nil - n.bestScore = 0 - n.isMatch = false - n._hac = nil - return n -end - -local function GetOrCreateNode(root, pathParts) - local node = root - for i = 1, #pathParts do - local part = pathParts[i] - local children = node.children - local existing = children[part] - if not existing then - existing = getTreeNode() - existing.name = part - children[part] = existing - node.childOrder[#node.childOrder + 1] = part - end - node = existing - end - return node -end - -local function HasActualContent(node) - if node._hac ~= nil then return node._hac end - if node.data then - local nodeData = node.data - local isCurrencyNode = nodeData.category == "Currency" - if not isCurrencyNode then - if nodeData.available and not nodeData.available() then - node._hac = false - return false - end - node._hac = true - return true - end - - local hasCurrencyID = false - if nodeData.steps and C_CurrencyInfo then - local steps = nodeData.steps - for i = 1, #steps do - if steps[i].currencyID then - hasCurrencyID = true - break - end - end - end - - if hasCurrencyID then - local steps = nodeData.steps - local isLegacyCurrency = false - for i = 1, #steps do - if steps[i].currencyHeader == "Legacy" then - isLegacyCurrency = true - break - end - end - - for i = 1, #steps do - local step = steps[i] - if step.currencyID then - local currencyInfo = C_CurrencyInfo.GetCurrencyInfo(step.currencyID) - if not currencyInfo then node._hac = false; return false end - if currencyInfo.quantity == 0 then - local isDiscovered = (currencyInfo.totalEarned and currencyInfo.totalEarned > 0) - or currencyInfo.useTotalEarnedForMaxQty - or (currencyInfo.discovered == true) - if isLegacyCurrency and not isDiscovered then - node._hac = false - return false - end - end - end - end - node._hac = true - return true - end - - if nodeData.category == "Currency" and nodeData.steps then - local steps = nodeData.steps - local isLegacyParent = false - for i = 1, #steps do - if steps[i].currencyHeader == "Legacy" then - isLegacyParent = true - break - end - end - if not isLegacyParent then - node._hac = true - return true - end - end - end - - local order = node.childOrder - local children = node.children - for i = 1, #order do - if HasActualContent(children[order[i]]) then - node._hac = true - return true - end - end - node._hac = false - return false -end - -local flattenScratch = {} -local hierEntryPool = {} -local hierEntryPoolN = 0 -Database._flattenScratch = flattenScratch -Database._hierEntryPool = hierEntryPool - -local function FlattenNode(self, node, depth, out, containerPaths) - local order = node.childOrder - local children = node.children - for ci = 1, #order do - local child = children[order[ci]] - local hasChildren = #child.childOrder > 0 - - if HasActualContent(child) then - local isContainer = false - if not hasChildren and child.data and child.data.path then - local path = child.data.path - local pathLen = #path - for pi = 1, pathLen do flattenScratch[pi] = path[pi] end - flattenScratch[pathLen + 1] = child.name - for pi = pathLen + 2, #flattenScratch do flattenScratch[pi] = nil end - isContainer = containerPaths[tconcat(flattenScratch, "\1", 1, pathLen + 1)] or false - elseif not hasChildren and child.data then - isContainer = containerPaths[child.name] or false - end - - hierEntryPoolN = hierEntryPoolN + 1 - local entry = hierEntryPool[hierEntryPoolN] - if not entry then - entry = {} - hierEntryPool[hierEntryPoolN] = entry - end - entry.name = child.name - entry.depth = depth - entry.isPathNode = hasChildren or isContainer - entry.isMatch = child.isMatch or false - entry.data = child.data or self:FindItemByName(child.name) - entry.isContainer = isContainer or nil - entry.isPinHeader = nil - entry.isPinned = nil - entry.isSectionHeader = nil - entry.isFlat = nil - entry.flatCatKey = nil - out[#out + 1] = entry - - if hasChildren then - FlattenNode(self, child, depth + 1, out, containerPaths) - end - end - end -end - -function Database:ResetHierEntryPool() - hierEntryPoolN = 0 -end - -local containerPathCache -local nameLookup -local function GetContainerPathSet() - if containerPathCache then return containerPathCache end - local cache = {} - for i = 1, #uiSearchData do - local data = uiSearchData[i] - if data.path then - cache[tconcat(data.path, "\1")] = true - end - end - containerPathCache = cache - return cache -end - function Database:ResetSearchCache() if self._dynamicBatchLoading then self._dynamicBatchChanged = true @@ -4229,8 +4022,6 @@ function Database:ResetSearchCache() prevQuery = "" prevSkipKey = "" wipe(prevCandidates) - containerPathCache = nil - nameLookup = nil local hadPrefixIndex = prefixIndexReady ClearPrefixBuckets() if hadPrefixIndex then self:BuildSearchPrefixIndex() end @@ -4508,102 +4299,6 @@ function Database:SearchUI(query, skipCategories) return results end --- Build a hierarchical tree from flat results for display. --- Uses a proper tree structure internally and DFS-flattens it, so children --- are always adjacent to their parent regardless of alphabetical ordering. -function Database:BuildHierarchicalResults(results) - if not results or #results == 0 then - return {} - end - - -- Reset the tree-node pool index; nodes from this pool are reused - -- across calls so the only per-call allocation is the returned - -- hierarchical array (and pool-grow when first hit). - treeNodePoolN = 0 - local root = getTreeNode() - - for ri = 1, #results do - local item = results[ri] - local itemData = item.data - local path = itemData.path or {} - local parentNode = GetOrCreateNode(root, path) - local itemScore = item.score or 0 - local existing = parentNode.children[itemData.name] - - if existing then - if not existing.data then existing.data = itemData end - existing.isMatch = true - if itemScore > existing.bestScore then - existing.bestScore = itemScore - end - else - local leaf = getTreeNode() - leaf.name = itemData.name - leaf.data = itemData - leaf.bestScore = itemScore - leaf.isMatch = true - parentNode.children[itemData.name] = leaf - parentNode.childOrder[#parentNode.childOrder + 1] = itemData.name - end - - local node = root - for pi = 1, #path do - node = node.children[path[pi]] - if itemScore > node.bestScore then - node.bestScore = itemScore - end - end - end - - sortChildren(root) - - local hierarchical = {} - FlattenNode(self, root, 0, hierarchical, GetContainerPathSet()) - return hierarchical -end - --- Get direct children of a container node from the database. --- Called when user expands a collapsed container in the search results. -function Database:GetContainerChildren(containerData) - if not containerData or not containerData.path then return {} end - local prefix = {} - for _, p in ipairs(containerData.path) do prefix[#prefix + 1] = p end - prefix[#prefix + 1] = containerData.name - local prefixLen = #prefix - - local children = {} - for _, data in ipairs(uiSearchData) do - if data.path and #data.path >= prefixLen then - -- Check prefix match - local match = true - for i = 1, prefixLen do - if data.path[i] ~= prefix[i] then match = false; break end - end - if match and #data.path == prefixLen then - -- Direct child of this container - children[#children + 1] = data - end - end - end - - -- Sort alphabetically - tsort(children, function(a, b) return (a.name or "") < (b.name or "") end) - return children -end - -function Database:FindItemByName(name) - if not nameLookup then - nameLookup = {} - for i = 1, #uiSearchData do - local d = uiSearchData[i] - if not nameLookup[d.name] then - nameLookup[d.name] = d - end - end - end - return nameLookup[name] -end - -- Static UI database must be built at load time (before ADDON_LOADED) so other -- modules can reference uiSearchData during their own initialization. -- Not routed through Core.lua SafeInit, so wrap here for error safety. diff --git a/EasyFind.toc b/EasyFind.toc index 77743dc..c9d881a 100644 --- a/EasyFind.toc +++ b/EasyFind.toc @@ -10,6 +10,7 @@ ## DefaultState: enabled Utils.lua +MapUtils.lua Core.lua Database.lua DatabaseDynamic.lua diff --git a/MapSearch.lua b/MapSearch.lua index e1c594d..7d98c7d 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -4,12 +4,16 @@ local MapSearch = {} ns.MapSearch = MapSearch local Utils = ns.Utils +local MapUtils = ns.MapUtils local DebugPrint = Utils.DebugPrint local pairs, ipairs, type, select = Utils.pairs, Utils.ipairs, Utils.type, Utils.select local tinsert, tsort, tconcat, tremove = Utils.tinsert, Utils.tsort, Utils.tconcat, Utils.tremove local sfind, slower, ssub = Utils.sfind, Utils.slower, Utils.ssub local mmin, mmax, mpi, mfloor = Utils.mmin, Utils.mmax, Utils.mpi, Utils.mfloor local pcall, tostring = Utils.pcall, Utils.tostring +local GetMapParentID = MapUtils.GetParentMapID +local GetMapPath = MapUtils.GetMapPath +local ZONE_ABBREVIATIONS = MapUtils.ZONE_ABBREVIATIONS local GOLD_COLOR = ns.GOLD_COLOR local YELLOW_HIGHLIGHT = ns.YELLOW_HIGHLIGHT @@ -159,7 +163,6 @@ local INDICATOR_STYLES = { offsetY = 0, }, } - -- Indicator color presets local INDICATOR_COLORS = { ["Yellow"] = {1.0, 1.0, 0.0}, @@ -1183,63 +1186,11 @@ function MapSearch:GetAllWorldZones(startMapID, depth, parentPath) return allZones end --- Parent map overrides for guided navigation --- Some zones have incorrect parentMapID in the WoW API, causing guided navigation --- to route through maps where the target zone isn't clickable. --- Maps [childMapID] = correctedParentMapID -local ZONE_PARENT_OVERRIDES = { - [2346] = 2274, -- Undermine → Khaz Algar (API incorrectly says The Ringing Deeps 2214) -} +local ZONE_PARENT_OVERRIDES = MapUtils.PARENT_OVERRIDES or { --- Search for zones matching query --- Common abbreviations for major cities/zones --- Maps lowercase abbreviation → lowercase zone name -local ZONE_ABBREVIATIONS = { - ["sw"] = "stormwind city", - ["stormwind"] = "stormwind city", - ["og"] = "orgrimmar", - ["org"] = "orgrimmar", - ["if"] = "ironforge", - ["tb"] = "thunder bluff", - ["uc"] = "undercity", - ["darn"] = "darnassus", - ["exo"] = "the exodar", - ["smc"] = "silvermoon city", - ["silvermoon"] = "silvermoon city", - ["dal"] = "dalaran", - ["bb"] = "booty bay", - ["sh"] = "shattrath city", - ["shatt"] = "shattrath city", - ["shat"] = "shattrath city", - ["daz"] = "dazar'alor", - ["bor"] = "boralus", - ["orib"] = "oribos", - ["vald"] = "valdrakken", - ["zand"] = "zandalar", - ["kt"] = "kul tiras", - ["ek"] = "eastern kingdoms", - ["kali"] = "kalimdor", - ["dk"] = "acherus: the ebon hold", - -- Continent shorthand for names the normal prefix/substring matcher - -- can't reach ("nr" isn't inside "northrend", "wod" isn't inside - -- "draenor", etc.). Plain prefixes like "drae", "pand", "khaz" are - -- intentionally omitted because substring matching already handles - -- them. - ["nr"] = "northrend", - ["ol"] = "outland", - ["tbc"] = "outland", - ["sl"] = "shadowlands", - ["wod"] = "draenor", - ["di"] = "dragon isles", - ["df"] = "dragon isles", - ["bi"] = "broken isles", - ["leg"] = "broken isles", - ["mop"] = "pandaria", - ["tww"] = "khaz algar", + [2346] = 2274, -- Undermine → Khaz Algar (API incorrectly says The Ringing Deeps 2214) } -ns.MapSearch.ZONE_ABBREVIATIONS = ZONE_ABBREVIATIONS - -- Per-query cache for SearchZones, keyed on mode (local vs global). -- Stores recently-run queries so backspace hits cache (the previous -- query's result set is still in memory) and typing extensions still @@ -1511,7 +1462,7 @@ local function GetContinentForMap(mapID) local info = GetMapInfo(id) if not info then return nil end if info.mapType == Enum.UIMapType.Continent then return id end - id = ZONE_PARENT_OVERRIDES[id] or info.parentMapID + id = GetMapParentID(id, info) if not id or id == 0 then return nil end end end @@ -2264,7 +2215,7 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) DebugPrint("[EasyFind] Target zone:", targetInfo.name) - local targetParentMapID = ZONE_PARENT_OVERRIDES[targetMapID] or targetInfo.parentMapID + local targetParentMapID = GetMapParentID(targetMapID, targetInfo) if not targetParentMapID then DebugPrint("[EasyFind] No parent, going directly to zone") WorldMapFrame:SetMapID(targetMapID) @@ -2319,7 +2270,7 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) targetMapID = resolved targetInfo = GetMapInfo(targetMapID) if not targetInfo then return end - targetParentMapID = ZONE_PARENT_OVERRIDES[targetMapID] or targetInfo.parentMapID + targetParentMapID = GetMapParentID(targetMapID, targetInfo) end -- CASE 1: We're already on the target's parent map - just highlight the zone! @@ -2492,22 +2443,7 @@ end -- Get the full path from World/root to a given map function MapSearch:GetMapPath(mapID) - local path = {} - local currentID = mapID - local maxDepth = 15 - - while currentID and maxDepth > 0 do - local info = GetMapInfo(currentID) - if info then - tinsert(path, 1, {mapID = currentID, name = info.name, mapType = info.mapType}) - currentID = ZONE_PARENT_OVERRIDES[currentID] or info.parentMapID - else - break - end - maxDepth = maxDepth - 1 - end - - return path + return GetMapPath(mapID) end -- Highlight a breadcrumb button to guide user to zoom out toward the target @@ -5609,44 +5545,6 @@ function MapSearch:RefreshAllClearButtons() end end --- Return the currently-highlighted pin's coordinates and mapID, whether --- it was shown via ShowWaypointAt (waypointPin) or HighlightPin (native --- Blizzard pin). Used by the demo's "click to start tracking" step to --- resolve coordinates regardless of which rendering path was taken. -function MapSearch:GetActivePinCoords() - if activePinState and activePinState.x and activePinState.y and activePinState.mapID then - return activePinState.x, activePinState.y, activePinState.mapID - end - if waypointPin and waypointPin:IsShown() and waypointPin.waypointX and waypointPin.waypointY then - local mapID = WorldMapFrame and WorldMapFrame:GetMapID() - return waypointPin.waypointX, waypointPin.waypointY, mapID - end - return nil, nil, nil -end - --- Place a SuperTrack user waypoint at the currently highlighted pin's --- location. Used by the "click the pin to start tracking" step in the --- Guide Mode demo (since Guide Mode doesn't auto-track on result click). -function MapSearch:TrackActiveLocation() - local x, y, mapID = self:GetActivePinCoords() - if not (x and y and mapID) then return false end - SetUserWaypoint(UiMapPoint.CreateFromCoordinates(mapID, x, y)) - C_SuperTrack.SetSuperTrackedUserWaypoint(true) - efPlacedWaypoint = true - self:RefreshAllClearButtons() - return true -end - --- Return the on-screen frame currently showing the pin highlight. This --- is either waypointPin (overlay path) or highlightFrame (which is --- anchored over the native pin in the HighlightPin path). Used by the --- demo cursor to know where to move for a visual "click" on the pin. -function MapSearch:GetActivePinFrame() - if waypointPin and waypointPin:IsShown() then return waypointPin end - if highlightFrame and highlightFrame:IsShown() then return highlightFrame end - return nil -end - function MapSearch:ClearAll() activePinState = nil self:ClearHighlight() diff --git a/MapTab.lua b/MapTab.lua index 70289d7..2ae6d41 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -4,6 +4,7 @@ local MapTab = {} ns.MapTab = MapTab local Utils = ns.Utils +local MapUtils = ns.MapUtils local SafeAfter = Utils and Utils.SafeAfter or function(delay, fn) C_Timer.After(delay, fn) end local tinsert = Utils and Utils.tinsert or table.insert local tremove = table.remove @@ -24,7 +25,12 @@ local TAB_ICON_GOLD = {1.00, 0.82, 0.00} local TAB_ICON_DIM = {0.55, 0.45, 0.10} local TAB_STACK_GAP = -3 -local EYE_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\eye" +local FormatPathPrefix = MapUtils.FormatPathPrefix +local GetTopAncestor = MapUtils.GetTopAncestor +local GetZoneUnderAncestor = MapUtils.GetZoneUnderAncestor +local GetAncestorNames = MapUtils.GetAncestorNames +local ExpandZoneAbbrev = MapUtils.ExpandZoneAbbrev +local BuildFullBreadcrumb = MapUtils.BuildBreadcrumb -- Result row layout local ROW_HEIGHT = 24 @@ -35,112 +41,9 @@ local ROW_POOL_RETAIN = 80 local HEADER_POOL_RETAIN = 40 local SECTION_POOL_RETAIN = 12 --- Roots stripped from pathPrefix display. Every zone in WoW descends --- from "World", and the vast majority descend from "Azeroth", so those --- segments add no disambiguation — strip them in display order only. --- Other continents ("Outland", "Draenor", etc.) are kept because they --- genuinely distinguish a zone's origin. --- Top-level virtual roots that should NOT surface as group headers. --- "Cosmic" (mapID 946) sits above Azeroth, Outland, Draenor, and --- Shadowlands; without it here, raids and dungeons from those --- expansions all group under "Cosmic" instead of their actual --- continent name. -local PATH_STRIP_ROOTS = { "World", "Azeroth", "Cosmic" } - -local function FormatPathPrefix(pathStr) - if type(pathStr) ~= "string" or pathStr == "" then return pathStr end - for i = 1, #PATH_STRIP_ROOTS do - local root = PATH_STRIP_ROOTS[i] .. " > " - if pathStr:sub(1, #root) == root then - pathStr = pathStr:sub(#root + 1) - else - break -- stop on first non-matching root so order is preserved - end - end - -- Exact match (pathStr == a stripped root with nothing after): drop it. - for i = 1, #PATH_STRIP_ROOTS do - if pathStr == PATH_STRIP_ROOTS[i] then return "" end - end - return pathStr -end --- Top non-stripped ancestor name for a mapID. Walks the parentMapID --- chain until the next step would be a stripped root ("World"/"Azeroth") --- or root is reached. Used as the grouping key so deeply-nested zones --- (Dalaran inside Crystalsong Forest inside Northrend) group with --- siblings at the same continent-level header. -local topAncestorCache = {} -local function IsStripped(name) - if not name then return false end - for i = 1, #PATH_STRIP_ROOTS do - if PATH_STRIP_ROOTS[i] == name then return true end - end - return false -end -local function GetTopAncestor(mapID) - if not mapID or mapID == 0 then return nil end - local cached = topAncestorCache[mapID] - if cached ~= nil then - if cached == false then return nil end - return cached.name, cached.mapID - end - local current = mapID - local resultName, resultID - for _ = 1, 20 do - local info = C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(current) - if not info then break end - local parentID = info.parentMapID - local parentInfo = parentID and parentID ~= 0 and C_Map.GetMapInfo(parentID) or nil - local parentName = parentInfo and parentInfo.name - if not parentInfo or not parentName or IsStripped(parentName) or parentID == 0 then - resultName = info.name - resultID = current - break - end - current = parentID - end - if resultName then - topAncestorCache[mapID] = { name = resultName, mapID = resultID } - else - topAncestorCache[mapID] = false - end - return resultName, resultID -end - --- Exposed so UI search and other callers can group map results by the --- same continent label the MapTab uses. MapTab.GetTopAncestor = function(_, mapID) return GetTopAncestor(mapID) end --- Walk mapID's parent chain and return the first ancestor whose --- parentMapID is ancestorMapID — i.e. the direct child of `ancestorMapID` --- that contains `mapID`. Used for second-level zone grouping inside a --- continent group: an FM in Hillsbrad → Hillsbrad; a zone result for --- Eastern Plaguelands → itself. -local zoneUnderAncestorCache = {} -local function GetZoneUnderAncestor(mapID, ancestorMapID) - if not mapID or not ancestorMapID then return nil end - if mapID == ancestorMapID then return nil end - local cacheKey = mapID .. "_" .. ancestorMapID - local cached = zoneUnderAncestorCache[cacheKey] - if cached ~= nil then - if cached == false then return nil end - return cached.name, cached.mapID - end - local current = mapID - for _ = 1, 20 do - local info = C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(current) - if not info then break end - if info.parentMapID == ancestorMapID then - zoneUnderAncestorCache[cacheKey] = { name = info.name, mapID = current } - return info.name, current - end - if not info.parentMapID or info.parentMapID == 0 then break end - current = info.parentMapID - end - zoneUnderAncestorCache[cacheKey] = false - return nil -end - -- Children of a map from Blizzard's world hierarchy (non-recursive). -- Used to populate a group on-demand when the user expands a matched -- parent zone that didn't have any query-matched children. Cached per @@ -360,12 +263,7 @@ local function HideOurPanel() end local function ShowPopup(isPinned, onPin, onGuide) - local rows = {} - if onGuide then - rows[#rows + 1] = { text = "Guide", icon = EYE_ICON_TEX, onClick = onGuide } - end - rows[#rows + 1] = { text = isPinned and "Unpin" or "Pin", onClick = onPin } - Utils.ShowCursorMenu("EasyFindMapTabPopup", rows, { + Utils.ShowPinMenu("EasyFindMapTabPopup", isPinned, onPin, onGuide, nil, { width = 96, rowHeight = 22, offsetX = -8, @@ -544,37 +442,6 @@ local function RowOnClick(row, button) TriggerResultSelect(data) end --- Walk a result's parent-map chain and return a full breadcrumb string --- " > > > " — root first, leaf last. --- The Blizzard API names mapID 946 "Cosmic"; surface it as "World" --- since that's how players refer to it. Skips an ancestor whose name --- matches the POI itself (zone results' own mapID resolves to the --- same name, so we'd otherwise render "Tol Barad > Tol Barad"). -local function BuildFullBreadcrumb(data) - local mapID = data.mapID or data.zoneMapID or data.entranceMapID or data.parentMapID - if not mapID or not C_Map or not C_Map.GetMapInfo then return data.name end - local parts = {} - local current = mapID - local leafName = data.name and data.name:lower() or "" - for _ = 1, 20 do - local info = C_Map.GetMapInfo(current) - if not info then break end - if info.name and info.name:lower() ~= leafName then - local name = info.name == "Cosmic" and "World" or info.name - parts[#parts + 1] = name - end - if not info.parentMapID or info.parentMapID == 0 then break end - current = info.parentMapID - end - if #parts == 0 then return data.name end - local segments = {} - for i = #parts, 1, -1 do - segments[#segments + 1] = parts[i] - end - segments[#segments + 1] = data.name - return table.concat(segments, " > ") -end - local function RowOnEnter(row) if row.data then if row.data.isRecentSearch and row.deleteBtn then @@ -1400,41 +1267,6 @@ local function FilterAndDedupe(results, seen, isLocal) return out end --- Walk the parent chain of mapID and return an array of the names at --- every level (lowercased). Used to let a token like "northrend" match --- any POI whose ancestor chain includes Northrend, so "northrend raid" --- picks out raids scoped to that continent. -local ancestorNamesCache = {} -local function GetAncestorNames(mapID) - if not mapID or mapID == 0 then return {} end - local cached = ancestorNamesCache[mapID] - if cached then return cached end - local names = {} - local current = mapID - for _ = 1, 20 do - local info = C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(current) - if not info then break end - if info.name and info.name ~= "" then - names[#names + 1] = info.name:lower() - end - local parentID = info.parentMapID - if not parentID or parentID == 0 then break end - current = parentID - end - ancestorNamesCache[mapID] = names - return names -end - --- Expand a token through the shared zone-abbreviation table so shortcut --- queries like "nr" resolve to "northrend" during multi-token matching. --- Returns the expansion string (lowercase) or nil if no abbreviation --- exists. Cached lookup — the table is a plain dict. -local function ExpandZoneAbbrev(t) - local tbl = ns.MapSearch and ns.MapSearch.ZONE_ABBREVIATIONS - if not tbl then return nil end - return tbl[t] -end - -- Does a single token match a POI via any available facet? Token must -- already be lowercase. Checks name, keywords, category (with plural/ -- singular flex), pathPrefix, ancestor zone names, and — for ancestors — @@ -2053,8 +1885,11 @@ local function CreateSearchBox(parent) if grew and self.UpdateAutocomplete then self:UpdateAutocomplete() end end) end, - onAccepted = function(text) - MapTab:PushRecentSearch(text) + onAccepted = function(text, source) + if text and text ~= "" then MapTab:RunSearch(text) end + if source ~= "right" and source ~= "ctrl-l" and source ~= "click" then + MapTab:PushRecentSearch(text) + end end, }) editBox:HookScript("OnEditFocusGained", UpdateClear) @@ -2069,6 +1904,17 @@ local function CreateSearchBox(parent) -- the end. ENTER routes through HandleNavKey, which activates a -- highlighted row or falls through to push-to-recents below. editBox:HookScript("OnKeyDown", function(self, key) + if self.HasAutocomplete and self:HasAutocomplete() and self.AcceptAutocomplete then + if key == "RIGHT" or key == "ARROWRIGHT" then + self:AcceptAutocomplete("right") + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + elseif key == "L" and IsControlKeyDown() then + self:AcceptAutocomplete("ctrl-l") + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + end + end -- An active autocomplete suffix is rendered as highlighted -- text. WoW's default arrow-key handling on a highlighted -- selection fires OnTextChanged synchronously, which schedules @@ -2124,7 +1970,10 @@ local function CreateSearchBox(parent) local current = self:GetText() or "" local typed = self.GetTypedText and self:GetTypedText() or current local accepted = false - if current ~= "" and current ~= typed and self.AcceptAutocomplete then + if self.HasAutocomplete and self:HasAutocomplete() and self.AcceptAutocomplete then + accepted = self:AcceptAutocomplete() + current = self:GetText() or "" + elseif current ~= "" and current ~= typed and self.AcceptAutocomplete then accepted = self:AcceptAutocomplete() current = self:GetText() or "" end diff --git a/MapUtils.lua b/MapUtils.lua new file mode 100644 index 0000000..f80f75e --- /dev/null +++ b/MapUtils.lua @@ -0,0 +1,215 @@ +local _, ns = ... + +local Utils = ns.Utils +local MapUtils = {} +ns.MapUtils = MapUtils + +local tconcat = Utils.tconcat +local slower = Utils.slower +local wipe = wipe +local C_Map = C_Map +local GetMapInfo = C_Map and C_Map.GetMapInfo + +local PATH_STRIP_ROOTS = { "World", "Azeroth", "Cosmic" } +local STRIPPED_ROOTS = { World = true, Azeroth = true, Cosmic = true } + +local PARENT_OVERRIDES = { + [2346] = 2274, +} + +local ZONE_ABBREVIATIONS = { + sw = "stormwind city", + stormwind = "stormwind city", + og = "orgrimmar", + org = "orgrimmar", + ["if"] = "ironforge", + tb = "thunder bluff", + uc = "undercity", + darn = "darnassus", + exo = "the exodar", + smc = "silvermoon city", + silvermoon = "silvermoon city", + dal = "dalaran", + bb = "booty bay", + sh = "shattrath city", + shatt = "shattrath city", + shat = "shattrath city", + daz = "dazar'alor", + bor = "boralus", + orib = "oribos", + vald = "valdrakken", + zand = "zandalar", + kt = "kul tiras", + ek = "eastern kingdoms", + kali = "kalimdor", + dk = "acherus: the ebon hold", + nr = "northrend", + ol = "outland", + tbc = "outland", + sl = "shadowlands", + wod = "draenor", + di = "dragon isles", + df = "dragon isles", + bi = "broken isles", + leg = "broken isles", + mop = "pandaria", + tww = "khaz algar", +} + +MapUtils.PARENT_OVERRIDES = PARENT_OVERRIDES +MapUtils.ZONE_ABBREVIATIONS = ZONE_ABBREVIATIONS + +function MapUtils.GetParentMapID(mapID, info) + return PARENT_OVERRIDES[mapID] or (info and info.parentMapID) +end + +function MapUtils.ExpandZoneAbbrev(token) + return token and ZONE_ABBREVIATIONS[token] or nil +end + +function MapUtils.FormatPathPrefix(pathStr) + if type(pathStr) ~= "string" or pathStr == "" then return pathStr end + for i = 1, #PATH_STRIP_ROOTS do + local root = PATH_STRIP_ROOTS[i] .. " > " + if pathStr:sub(1, #root) == root then + pathStr = pathStr:sub(#root + 1) + else + break + end + end + return STRIPPED_ROOTS[pathStr] and "" or pathStr +end + +local topAncestorCache = {} +function MapUtils.GetTopAncestor(mapID) + if not mapID or mapID == 0 then return nil end + local cached = topAncestorCache[mapID] + if cached ~= nil then + if cached == false then return nil end + return cached.name, cached.mapID + end + local current = mapID + local resultName, resultID + for _ = 1, 20 do + local info = GetMapInfo and GetMapInfo(current) + if not info then break end + local parentID = MapUtils.GetParentMapID(current, info) + local parentInfo = parentID and parentID ~= 0 and GetMapInfo(parentID) or nil + local parentName = parentInfo and parentInfo.name + if not parentInfo or not parentName or STRIPPED_ROOTS[parentName] or parentID == 0 then + resultName = info.name + resultID = current + break + end + current = parentID + end + if resultName then + topAncestorCache[mapID] = { name = resultName, mapID = resultID } + else + topAncestorCache[mapID] = false + end + return resultName, resultID +end + +local zoneUnderAncestorCache = {} +function MapUtils.GetZoneUnderAncestor(mapID, ancestorMapID) + if not mapID or not ancestorMapID or mapID == ancestorMapID then return nil end + local cacheKey = mapID .. "_" .. ancestorMapID + local cached = zoneUnderAncestorCache[cacheKey] + if cached ~= nil then + if cached == false then return nil end + return cached.name, cached.mapID + end + local current = mapID + for _ = 1, 20 do + local info = GetMapInfo and GetMapInfo(current) + if not info then break end + local parentID = MapUtils.GetParentMapID(current, info) + if parentID == ancestorMapID then + zoneUnderAncestorCache[cacheKey] = { name = info.name, mapID = current } + return info.name, current + end + if not parentID or parentID == 0 then break end + current = parentID + end + zoneUnderAncestorCache[cacheKey] = false + return nil +end + +local ancestorNamesCache = {} +function MapUtils.GetAncestorNames(mapID) + if not mapID or mapID == 0 then return {} end + local cached = ancestorNamesCache[mapID] + if cached then return cached end + local names = {} + local current = mapID + for _ = 1, 20 do + local info = GetMapInfo and GetMapInfo(current) + if not info then break end + if info.name and info.name ~= "" then + names[#names + 1] = slower(info.name) + end + local parentID = MapUtils.GetParentMapID(current, info) + if not parentID or parentID == 0 then break end + current = parentID + end + ancestorNamesCache[mapID] = names + return names +end + +local mapPathCache = {} +function MapUtils.GetMapPath(mapID) + if not mapID then return {} end + local cached = mapPathCache[mapID] + if cached then return cached end + local path = {} + local currentID = mapID + for _ = 1, 15 do + local info = GetMapInfo and GetMapInfo(currentID) + if not info then break end + path[#path + 1] = { mapID = currentID, name = info.name, mapType = info.mapType } + currentID = MapUtils.GetParentMapID(currentID, info) + if not currentID or currentID == 0 then break end + end + local n = #path + for i = 1, n / 2 do + local j = n - i + 1 + path[i], path[j] = path[j], path[i] + end + mapPathCache[mapID] = path + return path +end + +local breadcrumbParts = {} +function MapUtils.BuildBreadcrumb(data, separator) + if not data then return nil end + local mapID = data.mapID or data.zoneMapID or data.entranceMapID or data.parentMapID + if not mapID or not GetMapInfo then return data.name end + + wipe(breadcrumbParts) + local current = mapID + local leafName = data.name and slower(data.name) or "" + for _ = 1, 20 do + local info = GetMapInfo(current) + if not info then break end + if info.name and slower(info.name) ~= leafName then + breadcrumbParts[#breadcrumbParts + 1] = info.name == "Cosmic" and "World" or info.name + end + local parentID = MapUtils.GetParentMapID(current, info) + if not parentID or parentID == 0 then break end + current = parentID + end + local n = #breadcrumbParts + if n == 0 then + wipe(breadcrumbParts) + return data.name + end + for i = 1, n / 2 do + local j = n - i + 1 + breadcrumbParts[i], breadcrumbParts[j] = breadcrumbParts[j], breadcrumbParts[i] + end + breadcrumbParts[n + 1] = data.name + local result = tconcat(breadcrumbParts, separator or " > ") + wipe(breadcrumbParts) + return result +end diff --git a/Perf.lua b/Perf.lua index 065baac..1c27887 100644 --- a/Perf.lua +++ b/Perf.lua @@ -60,13 +60,10 @@ local SNAP_FIELDS = { { "wordCache", "Database.wordCache", function() return ns.Database and ns.Database._wordCache end }, { "lootItemCache", "Database.lootItemCache", function() return ns.Database and ns.Database._lootItemCache end }, { "lootEntries", "Database.lootEntries", function() return ns.Database and ns.Database._lootEntries end }, - { "hierEntryPool", "Database.hierEntryPool", function() return ns.Database and ns.Database._hierEntryPool end }, { "resultEntryPool", "Database.resultEntryPool", function() return ns.Database and ns.Database._resultEntryPool end }, - { "treeNodePool", "Database.treeNodePool", function() return ns.Database and ns.Database._treeNodePool end }, { "resultsBuf", "Database.resultsBuf", function() return ns.Database and ns.Database._resultsBuf end }, { "flatEntries", "UI.flatEntries", function() return ns.UI and ns.UI._flatEntries end }, { "flatCombined", "UI.flatCombined", function() return ns.UI and ns.UI._flatCombined end }, - { "PB", "UI.PB", function() return ns.UI and ns.UI._PB end }, { "SCRATCH", "UI.SCRATCH", function() return ns.UI and ns.UI._SCRATCH end }, { "resultButtons", "UI.resultButtons", function() return ns.UI and ns.UI._resultButtons end }, { "rareTrackCache", "MapSearch.rareTrackCache", function() return ns.MapSearch and ns.MapSearch._rareTrackCache end }, diff --git a/UI.lua b/UI.lua index 70ce78d..78a0d61 100644 --- a/UI.lua +++ b/UI.lua @@ -29,7 +29,6 @@ local GetCursorPosition = GetCursorPosition local InCombatLockdown = InCombatLockdown local wipe = wipe -local EYE_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\eye" local REP_BAR_WIDTH = 100 local searchFrame @@ -216,7 +215,7 @@ local function GetActionHint(data) if data.spellID and data.category == "Ability" then return "Select to cast" end if data.macroIndex then return "Select to run macro" end if data.itemID and data.category == "Bag" then - if data.equipLoc and data.equipLoc ~= "" then + if data.isEquippable or (data.equipLoc and data.equipLoc ~= "") then return "Select to equip item" end return "Select to use item" @@ -297,15 +296,7 @@ function UI:SyncOutfitPins() end local function ShowPinPopup(_, isPinned, onPinAction, onGuide, onAddAlias) - local rows = {} - if onGuide then - rows[#rows + 1] = { text = "Guide", icon = EYE_ICON_TEX, onClick = onGuide } - end - rows[#rows + 1] = { text = isPinned and "Unpin" or "Pin", onClick = onPinAction } - if onAddAlias then - rows[#rows + 1] = { text = "Add Alias", onClick = onAddAlias } - end - Utils.ShowCursorMenu("EasyFindPinPopup", rows, { + Utils.ShowPinMenu("EasyFindPinPopup", isPinned, onPinAction, onGuide, onAddAlias, { strata = "FULLSCREEN_DIALOG", width = 96, rowHeight = 22, @@ -420,7 +411,7 @@ THEMES["Modern"] = { pathColorHover = {1.0, 1.0, 1.0, 1.0}, -- white (hover state) leafColor = {0.9, 0.9, 0.9}, -- light grey items -- tree lines - warm gold (single colour at every depth) - showTreeLines = true, + showTreeLines = false, indentColors = { {0.85, 0.65, 0.15, 0.80}, {0.85, 0.65, 0.15, 0.80}, @@ -438,7 +429,7 @@ THEMES["Modern"] = { -- header bar disabled (headerTab used instead) showHeaderBar = false, -- header tab: quest-log style with atlas textures - showHeaderTab = true, + showHeaderTab = false, headerTabAtlas = "QuestLog-tab", -- WoW atlas for tab background headerHighlightAlpha = 0.40, -- highlight layer alpha -- +/- button atlases @@ -839,6 +830,7 @@ function UI:CreateSearchFrame() -- two searches (the user's and the autocomplete suffix's), -- doubling per-keystroke cost. if not userInput then return end + if self.IsAutocompleteBackspaceStrip and self:IsAutocompleteBackspaceStrip() then return end historyIndex = 0 historyDraft = "" if pendingUISearchTimer then pendingUISearchTimer:Cancel() end @@ -973,18 +965,11 @@ function UI:CreateSearchFrame() end return nil end, + onAccepted = function(text) + if text and text ~= "" then UI:OnSearchTextChanged(text, true) end + end, }) - -- After Tab confirms an autocomplete suggestion, re-run the search - -- so the result list reflects the now-full text. Without this the - -- visible results stay on the original typed prefix even though the - -- editbox shows the confirmed candidate. - editBox:HookScript("OnTabPressed", function(self) - local current = self:GetText() or "" - if current == "" then return end - UI:OnSearchTextChanged(current, true) - end) - -- Shift+click link insertion: when the search bar has focus, shift-clicking -- an item in bags / an achievement in the achievement frame / a spell in -- the spellbook etc. routes the link's display name into our editbox the @@ -1158,6 +1143,17 @@ function UI:CreateSearchFrame() Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) return end + if self.HasAutocomplete and self:HasAutocomplete() and self.AcceptAutocomplete then + if key == "RIGHT" or key == "ARROWRIGHT" then + self:AcceptAutocomplete("right") + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + elseif key == "L" and IsControlKeyDown() then + self:AcceptAutocomplete("ctrl-l") + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + end + end -- ENTER with autocomplete highlight visible: WoW's default -- editbox processing treats the first Enter as "deselect" -- and silently swallows it without firing OnEnterPressed. @@ -3603,7 +3599,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- listens for GLOBAL_MOUSE_DOWN regardless of button) but leaves the -- filter dropdown stuck open. dropdown:SetScript("OnUpdate", function(self) - if self._demoSuspend then return end if self:IsShown() and (IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton")) then if not self:IsMouseOver() and not toggleBtn:IsMouseOver() then @@ -3632,10 +3627,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end) searchFrame.filterDropdown = dropdown - -- Expose the checkRows table so the demo system can hover/click - -- specific filter rows (e.g., "map") by key without duplicating the - -- layout logic. - dropdown.checkRows = checkRows end function EnsureResultButton(index) @@ -3763,12 +3754,11 @@ local INDENT_COLORS = { local INDENT_PX = 20 -- pixels per depth level (icon 16 + 4 gap) local LINE_X_OFF = 10 -- horizontal offset within each depth column (clears tab rounded corner) local LINE_W = 2 -- connector line thickness -local MAX_DEPTH = #INDENT_COLORS +local MAX_DEPTH = 0 -- Session-only collapse state for path nodes (cleared on every new search) local collapsedNodes = {} -- key = "name_depth", value = true local cachedHierarchical -- last full hierarchical list for re-rendering after toggle -local expandedContainers = {} -- tracks which containers have had children injected local flatEntries = {} local flatCombined = {} @@ -3822,62 +3812,6 @@ local function FlatNameLess(ra, rb) end -- Expand a container node: inject its database children into cachedHierarchical. -local function ExpandContainer(entry, entryIndex) - if not entry or not entry.data or not entry.isContainer then return end - local key = entry.name .. "_" .. (entry.depth or 0) - if expandedContainers[key] then return end -- already expanded - - local children = ns.Database:GetContainerChildren(entry.data) - if #children == 0 then return end - - local childDepth = (entry.depth or 0) + 1 - -- Build child entries and insert right after the container in cachedHierarchical - local toInsert = {} - for _, childData in ipairs(children) do - -- Check if this child is itself a container - local childIsContainer = false - local fp = {} - if childData.path then - for _, p in ipairs(childData.path) do fp[#fp + 1] = p end - end - fp[#fp + 1] = childData.name - -- Quick check: any item in the DB has this as a path prefix? - for _, dbItem in ipairs(ns.Database.uiSearchData or {}) do - if dbItem.path then - local match = true - for i = 1, #fp do - if not dbItem.path[i] or dbItem.path[i] ~= fp[i] then - match = false; break - end - end - if match and #dbItem.path >= #fp then - childIsContainer = true; break - end - end - end - - toInsert[#toInsert + 1] = { - name = childData.name, - depth = childDepth, - isPathNode = childIsContainer, - data = childData, - isContainer = childIsContainer or nil, - } - -- Start child containers collapsed too - if childIsContainer then - collapsedNodes[childData.name .. "_" .. childDepth] = true - end - end - - -- Insert after entryIndex - for i = #toInsert, 1, -1 do - tinsert(cachedHierarchical, entryIndex + 1, toInsert[i]) - end - - expandedContainers[key] = true - entry.isContainer = nil -- no longer needs lazy expansion -end - function UI:CreateResultButton(index) local scrollChild = resultsFrame.scrollChild local resultRow = CreateFrame("Button", "EasyFindResultButton"..index, scrollChild, "SecureActionButtonTemplate") @@ -3891,15 +3825,6 @@ function UI:CreateResultButton(index) local hlTex = resultRow:GetHighlightTexture() if hlTex then hlTex:SetBlendMode("ADD") end - -- Retail theme: full-width dark gradient behind headers (Event Schedule style) - local headerGrad = resultRow:CreateTexture(nil, "BACKGROUND", nil, 1) - headerGrad:SetTexture("Interface\\QuestFrame\\UI-QuestLogTitleHighlight") - headerGrad:SetBlendMode("ADD") - headerGrad:SetVertexColor(0.35, 0.27, 0.08, 0.6) - headerGrad:SetAllPoints() - headerGrad:Hide() - resultRow.headerGrad = headerGrad - -- Thin horizontal separator line at the bottom of each row local separator = resultRow:CreateTexture(nil, "ARTWORK", nil, 0) separator:SetColorTexture(0.5, 0.45, 0.3, 0.3) @@ -3909,182 +3834,6 @@ function UI:CreateResultButton(index) separator:Hide() resultRow.separator = separator - -- Retail: raised tab header (quest-log style with atlas textures) - local headerTab = CreateFrame("Button", nil, resultRow) - headerTab:SetAllPoints() - headerTab:RegisterForClicks("LeftButtonUp") - headerTab:SetScript("OnClick", function(self, mouseButton) - local row = self:GetParent() - if mouseButton == "RightButton" then - local postClick = row:GetScript("PostClick") - if postClick then postClick(row, mouseButton) end - return - end - if row.data then - UI:SelectResult(row.data) - end - end) - headerTab:RegisterForClicks("LeftButtonUp", "RightButtonUp") - headerTab:Hide() - resultRow.headerTab = headerTab - - -- Background texture using QuestLog-tab atlas - local tabBg = headerTab:CreateTexture(nil, "BACKGROUND") - tabBg:SetAllPoints() - tabBg:SetAtlas("QuestLog-tab") - resultRow.tabBg = tabBg - - -- Hover overlay: same atlas, additive blend, manually shown/hidden - local tabHoverOverlay = headerTab:CreateTexture(nil, "ARTWORK", nil, -1) - tabHoverOverlay:SetAllPoints() - tabHoverOverlay:SetAtlas("QuestLog-tab") - tabHoverOverlay:SetBlendMode("ADD") - tabHoverOverlay:SetAlpha(0.40) - tabHoverOverlay:Hide() - resultRow.tabHoverOverlay = tabHoverOverlay - - -- +/- toggle button on right side (filter-button style) - local toggleBtn = CreateFrame("Button", nil, headerTab) - toggleBtn:SetSize(26, 25) - toggleBtn:SetPoint("RIGHT", headerTab, "RIGHT", -8, 0) - toggleBtn:SetFrameLevel(headerTab:GetFrameLevel() + 2) - toggleBtn:RegisterForClicks("LeftButtonUp") - toggleBtn:SetScript("OnClick", function(self) - local row = self:GetParent():GetParent() - if row.isPinHeader then - return - elseif row.isPathNode then - local key = (row.pathNodeName or "") .. "_" .. (row.pathNodeDepth or 0) - local wasCollapsed = collapsedNodes[key] - collapsedNodes[key] = not collapsedNodes[key] - if wasCollapsed and row._containerEntry and cachedHierarchical then - for idx, entry in ipairs(cachedHierarchical) do - if entry == row._containerEntry then - ExpandContainer(entry, idx) - break - end - end - end - if cachedHierarchical then - UI:ShowHierarchicalResults(cachedHierarchical, true) - end - else - return - end - -- Rebuild repurposes rows, clearing visual state. Re-show btnBg - -- for whichever toggleBtn is now under the cursor. - for i = 1, MAX_BUTTON_POOL do - local rb = resultButtons[i] - if rb and rb.toggleBtn and rb.toggleBtn:IsMouseOver() then - rb.toggleBtn.btnBg:Show() - break - end - end - end) - - local toggleBtnBg = toggleBtn:CreateTexture(nil, "ARTWORK") - toggleBtnBg:SetAllPoints() - toggleBtnBg:SetTexture(796424) - toggleBtnBg:Hide() - toggleBtn.btnBg = toggleBtnBg - - local toggleIcon = toggleBtn:CreateTexture(nil, "OVERLAY") - toggleIcon:SetSize(18, 17) - toggleIcon:SetPoint("CENTER") - toggleIcon:SetAtlas("QuestLog-icon-expand") - resultRow.toggleIcon = toggleIcon - - toggleBtn:SetHighlightTexture(130757) - toggleBtn:SetScript("OnEnter", function(self) - self.btnBg:Show() - local row = self:GetParent():GetParent() - if row.tabHoverOverlay then row.tabHoverOverlay:Show() end - if row.tabText then row.tabText:SetTextColor(0.90, 0.88, 0.85, 1.0) end - end) - toggleBtn:SetScript("OnLeave", function(self) - self.btnBg:Hide() - local row = self:GetParent():GetParent() - if not self:GetParent():IsMouseOver() then - if row.tabHoverOverlay then row.tabHoverOverlay:Hide() end - if row.tabText then - if row._isMatch then - row.tabText:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) - else - row.tabText:SetTextColor(0.60, 0.58, 0.55, 1.0) - end - end - end - end) - resultRow.toggleBtn = toggleBtn - - local toggleHighlight = headerTab:CreateTexture(nil, "OVERLAY") - toggleHighlight:SetSize(26, 25) - toggleHighlight:SetPoint("CENTER", toggleBtn, "CENTER", 0, 0) - toggleHighlight:SetColorTexture(0.3, 0.6, 1.0, 0.4) - toggleHighlight:Hide() - resultRow.toggleHighlight = toggleHighlight - - -- Header name text (child of headerTab) - local tabText = headerTab:CreateFontString(nil, "OVERLAY", "Game15Font_Shadow") - tabText:SetPoint("LEFT", headerTab, "LEFT", 10, 0) - tabText:SetPoint("RIGHT", toggleBtn, "LEFT", -4, 0) - tabText:SetJustifyH("LEFT") - tabText:SetMaxLines(1) - tabText:SetTextColor(0.60, 0.58, 0.55, 1.0) -- muted gray (normal state) - resultRow.tabText = tabText - - -- Hover handlers: brighten tab bg, text near-white, icon bright yellow - headerTab:SetScript("OnEnter", function(self) - local parent = self:GetParent() - if parent.tabHoverOverlay then - parent.tabHoverOverlay:Show() - end - if parent.tabText then - parent.tabText:SetTextColor(0.90, 0.88, 0.85, 1.0) -- soft white (slightly muted) - end - -- Show tooltip for unearned currencies - if parent.isUnearnedCurrency and unearnedTooltip then - local tooltipText = parent.isPathNode and "This tab does not exist on this character yet" or "Currency not yet earned" - unearnedTooltip.text:SetText(tooltipText) - - local textWidth = unearnedTooltip.text:GetStringWidth() - local textHeight = unearnedTooltip.text:GetStringHeight() - unearnedTooltip:SetSize(textWidth + 20, textHeight + 16) - - local scale = UIParent:GetEffectiveScale() - local x, y = GetCursorPosition() - unearnedTooltip:ClearAllPoints() - unearnedTooltip:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", x / scale + 10, y / scale + 10) - unearnedTooltip:Show() - end - end) - headerTab:SetScript("OnLeave", function(self) - local parent = self:GetParent() - if parent.tabHoverOverlay then - parent.tabHoverOverlay:Hide() - end - if parent.tabText then - if parent._isMatch then - parent.tabText:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) -- back to gold - else - parent.tabText:SetTextColor(0.60, 0.58, 0.55, 1.0) -- back to gray - end - end - -- Hide tooltip for unearned currencies - if unearnedTooltip then - unearnedTooltip:Hide() - end - end) - - -- Tab selection highlight (keyboard nav, child of headerTab) - local tabSelTex = headerTab:CreateTexture(nil, "BACKGROUND") - tabSelTex:SetAllPoints() - tabSelTex:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") - tabSelTex:SetBlendMode("ADD") - tabSelTex:SetVertexColor(0.3, 0.6, 1.0, 0.4) - tabSelTex:Hide() - resultRow.tabSelectionHighlight = tabSelTex - -- Tree connector textures per depth level resultRow.treeVert = {} -- vertical │ pass-through for ancestors resultRow.treeBranch = {} -- horizontal ─ branch connector @@ -4874,16 +4623,7 @@ function UI:CreateResultButton(index) if isToggleClick then local key = (self.pathNodeName or "") .. "_" .. (self.pathNodeDepth or 0) - local wasCollapsed = collapsedNodes[key] collapsedNodes[key] = not collapsedNodes[key] - if wasCollapsed and self._containerEntry and cachedHierarchical then - for idx, entry in ipairs(cachedHierarchical) do - if entry == self._containerEntry then - ExpandContainer(entry, idx) - break - end - end - end if cachedHierarchical then UI:ShowHierarchicalResults(cachedHierarchical, true) end @@ -5245,7 +4985,6 @@ function UI:OnSearchTextChanged(text, force) end wipe(collapsedNodes) - wipe(expandedContainers) local needsHeavy = ns.Database and ns.Database.QueryNeedsHeavySearchData and ns.Database:QueryNeedsHeavySearchData(text) if not force and not needsHeavy and ns.Database and ns.Database.CancelDynamicWarmup then @@ -5764,7 +5503,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.isPinned = entry.isPinned or false resultRow.pathNodeName = entry.isPathNode and entry.name or nil resultRow.pathNodeDepth = entry.isPathNode and depth or nil - resultRow._containerEntry = entry.isContainer and entry or nil if resultRow.pinIcon then resultRow.pinIcon:Hide() end if resultRow.pinToggle then resultRow.pinToggle:Hide() end if resultRow.pinHeaderLine then resultRow.pinHeaderLine:Hide() end @@ -5824,8 +5562,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow._isMatch = entry.isMatch and entry.isPathNode if entry.isPinHeader then -- Pin header: plain text + toggle icon + underline (no tab/gradient) - resultRow.headerTab:Hide() - resultRow.headerGrad:Hide() + if resultRow.headerTab then resultRow.headerTab:Hide() end + if resultRow.headerGrad then resultRow.headerGrad:Hide() end local collapseAtlas = theme.collapseAtlas or "QuestLog-icon-shrink" resultRow.pinToggle:SetAtlas(collapseAtlas) resultRow.pinToggle:Show() @@ -5843,8 +5581,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- category dividers (UI / Mounts / Toys / Map / ...) -- so they cost less vertical space than a full -- parent-tab header and don't waste a parent indent. - resultRow.headerTab:Hide() - resultRow.headerGrad:Hide() + if resultRow.headerTab then resultRow.headerTab:Hide() end + if resultRow.headerGrad then resultRow.headerGrad:Hide() end resultRow.text:SetText("") resultRow.sectionLabelText:SetText(entry.name) resultRow.sectionLabelText:Show() @@ -5856,7 +5594,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.sectionLabelRight:SetPoint("LEFT", resultRow.sectionLabelText, "RIGHT", 6, 0) resultRow.sectionLabelRight:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) resultRow.sectionLabelRight:Show() - elseif theme.showHeaderTab and entry.isPathNode then + elseif theme.showHeaderTab and entry.isPathNode and resultRow.headerTab then -- Quest-log raised tab header local tabInset = depth * indPx resultRow.headerTab:ClearAllPoints() @@ -5883,17 +5621,17 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end -- Normal icon/text hidden - SetRowIcon("hidden") handles icon below resultRow.text:SetText("") - resultRow.headerGrad:Hide() + if resultRow.headerGrad then resultRow.headerGrad:Hide() end else - resultRow.headerTab:Hide() + if resultRow.headerTab then resultRow.headerTab:Hide() end -- Gradient header (Classic fallback) local showGrad = theme.showHeaderBar and entry.isPathNode - if showGrad then + if showGrad and resultRow.headerGrad then resultRow.headerGrad:SetAllPoints() local gradAlpha = mmax(0.25, 0.6 - depth * 0.1) resultRow.headerGrad:SetVertexColor(0.35, 0.27, 0.08, gradAlpha) end - resultRow.headerGrad:SetShown(showGrad) + if resultRow.headerGrad then resultRow.headerGrad:SetShown(showGrad) end end -- Separator line between rows (skip for pin header which has its own underline) @@ -6870,7 +6608,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- 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 + if theme.showHeaderTab and entry.isPathNode and resultRow.headerTab and resultRow.headerTab:IsShown() then textObj = nil elseif not entry.isPinHeader then textObj = resultRow.text @@ -6926,8 +6664,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow:SetAttribute("macro", nil) resultRow:SetAttribute("macrotext", nil) end - resultRow.headerGrad:Hide() - resultRow.headerTab:Hide() + if resultRow.headerGrad then resultRow.headerGrad:Hide() end + if resultRow.headerTab then resultRow.headerTab:Hide() end resultRow.separator:Hide() resultRow.repBar:Hide() if resultRow.flatCatIcon then resultRow.flatCatIcon:Hide() end @@ -7308,7 +7046,6 @@ function UI:ShowPinnedItems() end wipe(collapsedNodes) - wipe(expandedContainers) local entries = pinnedOnlyEntries for i, pin in ipairs(pins) do local e = entries[i] @@ -7443,63 +7180,14 @@ function UI:UpdateSelectionHighlight(skipRefocus) for i = 1, MAX_BUTTON_POOL do local resultRow = resultButtons[i] if not resultRow then break end - -- Leaf rows (no headerTab): LockHighlight pins the shared - -- row HighlightTexture so keyboard selection looks identical - -- to mouse hover. Header rows skip the row highlight — their - -- visual is carried by tabHoverOverlay inside the tab to match - -- mouse hover exactly (rather than painting a row-wide glow - -- that extends past the tab). local isHeaderRow = resultRow.headerTab and resultRow.headerTab:IsShown() if resultRow.LockHighlight then - if i == selectedIndex and not toggleFocused and not isHeaderRow then + if i == selectedIndex and not isHeaderRow then resultRow:LockHighlight() else resultRow:UnlockHighlight() end end - if resultRow.tabSelectionHighlight then - resultRow.tabSelectionHighlight:Hide() - end - if resultRow.toggleHighlight then - local showToggle = i == selectedIndex and toggleFocused - local isPinToggle = resultRow.isPinHeader and resultRow.pinToggle and resultRow.pinToggle:IsShown() - if showToggle and isPinToggle then - resultRow.toggleHighlight:ClearAllPoints() - resultRow.toggleHighlight:SetPoint("CENTER", resultRow.pinToggle, "CENTER", 0, 0) - end - resultRow.toggleHighlight:SetShown(showToggle and isPinToggle) - if resultRow.toggleBtn then - if resultRow.toggleBtn.btnBg then - resultRow.toggleBtn.btnBg:SetShown(showToggle and not isPinToggle) - end - if showToggle and not isPinToggle then - resultRow.toggleBtn:LockHighlight() - else - resultRow.toggleBtn:UnlockHighlight() - end - end - -- Tab overlay shows for keyboard selection on a non-pin - -- header whether the cursor is on the row body (not - -- toggleFocused) or on the toggle button (toggleFocused). - -- Pin headers use their own pin-toggle highlight instead. - local isHeaderSelected = i == selectedIndex - and resultRow.headerTab and resultRow.headerTab:IsShown() - and not isPinToggle - if resultRow.tabHoverOverlay then - resultRow.tabHoverOverlay:SetShown(isHeaderSelected) - end - if resultRow.tabText then - if isHeaderSelected then - resultRow.tabText:SetTextColor(0.90, 0.88, 0.85, 1.0) - elseif not resultRow.headerTab:IsMouseOver() then - if resultRow._isMatch then - resultRow.tabText:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) - else - resultRow.tabText:SetTextColor(0.60, 0.58, 0.55, 1.0) - end - end - end - end end if selectedIndex > 0 then if resultButtons[selectedIndex] then @@ -7557,16 +7245,7 @@ function UI:ActivateSelected() if resultRow.isPathNode and toggleFocused then -- Toggle collapse when focus is on the +/- control local key = (resultRow.pathNodeName or "") .. "_" .. (resultRow.pathNodeDepth or 0) - local wasCollapsed = collapsedNodes[key] collapsedNodes[key] = not collapsedNodes[key] - if wasCollapsed and resultRow._containerEntry and cachedHierarchical then - for idx, entry in ipairs(cachedHierarchical) do - if entry == resultRow._containerEntry then - ExpandContainer(entry, idx) - break - end - end - end if cachedHierarchical then local savedIndex = selectedIndex local savedToggle = toggleFocused @@ -9501,18 +9180,9 @@ function UI:ShowFirstTimeSetup() CreateKeybindButton("toggle", toggleLabel, "EASYFIND_TOGGLE_FOCUS", "Ctrl+Space") CreateKeybindButton("map", mapLabel, "EASYFIND_MAP_FOCUS", "Ctrl+M") - -- Buttons sit just below the panel, centered as a pair around the - -- panel's vertical midline. "See demo" on the LEFT, "Got it" on the - -- RIGHT. Anchoring TOP to the panel's BOTTOM places them outside the - -- frame so they don't crowd the keybind section above. - local seeDemoBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") - seeDemoBtn:SetSize(100, 22) - seeDemoBtn:SetPoint("TOPLEFT", panel, "BOTTOM", 4, -8) - seeDemoBtn:SetText("See demo") - local gotItBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") gotItBtn:SetSize(100, 22) - gotItBtn:SetPoint("TOPRIGHT", panel, "BOTTOM", -4, -8) + gotItBtn:SetPoint("TOP", panel, "BOTTOM", 0, -8) gotItBtn:SetText("Got it") -- During setup: allow drag without holding Shift @@ -9520,23 +9190,6 @@ function UI:ShowFirstTimeSetup() self:StartMoving() end) - -- Forward declarations for the demo frames so FinishSetup can clean - -- them up regardless of whether the player went through See Demo. - local demoFrame - local startDemo - - local function EnsureDemoLoaded() - if ns.Demo and ns.Demo.Start then return true end - if C_AddOns and C_AddOns.LoadAddOn then - local ok, reason = pcall(C_AddOns.LoadAddOn, "EasyFind_Demo") - if ok and ns.Demo and ns.Demo.Start then return true end - EasyFind:Print("Demo module could not be loaded: " .. tostring(reason or "unknown error")) - else - EasyFind:Print("Demo module is not available on this client.") - end - return false - end - local function FinishSetup() EasyFind.db.setupComplete = true @@ -9553,11 +9206,6 @@ function UI:ShowFirstTimeSetup() resizer:SetScript("OnUpdate", nil) resizer:Hide() panel:Hide() - if demoFrame then demoFrame:Hide() end - -- Clear the demo suspend flag so the dropdown auto-close works - if searchFrame.filterDropdown then - searchFrame.filterDropdown._demoSuspend = nil - end -- Restore shift-only drag searchFrame:SetScript("OnDragStart", function(self) @@ -9576,23 +9224,6 @@ function UI:ShowFirstTimeSetup() EasyFind.db.lastSeenVersion = ns.version end - startDemo = function() - if not EnsureDemoLoaded() then return false end - - panel:Hide() - resizer:Hide() - glow:SetScript("OnUpdate", nil) - glow:Hide() - - demoFrame = ns.Demo.Start({ - searchFrame = searchFrame, - resultsFrame = resultsFrame, - resultButtons = resultButtons, - finishSetup = FinishSetup, - }) - return true - end - gotItBtn:SetScript("OnClick", FinishSetup) -- Escape closes the whole tutorial from the positioning panel. @@ -9607,17 +9238,6 @@ function UI:ShowFirstTimeSetup() end end) - seeDemoBtn:SetScript("OnClick", function() - -- Save position + apply preferences, then skip straight to the - -- interactive demo (no mode-intro step in between). - local point, _, relPoint, x, y = searchFrame:GetPoint() - EasyFind.db.uiSearchPosition = {point, relPoint, x, y} - EasyFind.db.smartShow = smartShowCheckbox:GetChecked() - EasyFind.db.staticOpacity = not fadeCheckbox:GetChecked() - UI:UpdateSmartShow() - - startDemo() - end) end -- Flash a label on the search frame (used for Currency hint) diff --git a/Utils.lua b/Utils.lua index d83ac10..a827b2f 100644 --- a/Utils.lua +++ b/Utils.lua @@ -137,23 +137,6 @@ function Utils.CreateKeyRepeat(frame, initialDelay, fastDelay, accelDuration) return { Start = Start, Stop = Stop, IsKey = IsKey } end ---- Scroll a ScrollFrame so that the given child button is visible. ---- Uses the button's top/bottom relative to the scrollChild. --- Chrome-style inline autocomplete for an editbox. The full --- suggestion is set as the editbox text, the cursor sits at the end --- of what the user typed, and the trailing suggestion is selected so --- the native caret is always visible at the end of the typed prefix. --- Tab confirms; typing a character replaces the selection (advancing --- the prefix); backspace deletes the selection without re-applying --- the suggestion. --- --- opts.findCandidate(typedText) -> string | nil --- Returns the autocomplete suggestion for `typedText`, or nil to --- strip any active suggestion. --- --- The caller is responsible for invoking editBox.UpdateAutocomplete() --- after its search results refresh, since the suggestion source is --- typically derived from those results. function Utils.AttachAutocomplete(editBox, opts) if not editBox or not opts or type(opts.findCandidate) ~= "function" then return end @@ -162,128 +145,255 @@ function Utils.AttachAutocomplete(editBox, opts) local onAccepted = opts.onAccepted local typedText = "" local programmatic = false - -- Last suggestion we rendered into the editbox. Used by the smooth-typing - -- fast path to extend the highlight in-place when the new typed prefix is - -- still a prefix of the same candidate, so the suggestion doesn't blink - -- between the keystroke and the deferred ApplyAutocomplete next frame. local currentCandidate = nil - -- Set true by the smooth-typing fast path when it has already - -- rendered the correct suggestion for the current typedText, so a - -- subsequent caller-driven ApplyAutocomplete from the search-refresh - -- timer can skip the redundant findCandidate + cursor/highlight pass - -- (which would re-run the lookup and re-paint the highlight even - -- when nothing visually needs to change). local smoothExtendDone = false + local restoreBackspaceText, restoreBackspaceCursor + local mouseAcceptCandidate + local mouseAcceptTypedLen + local backspaceStripActive = false + local charDispatchedTyped + + local function HasAutocomplete() + return currentCandidate ~= nil and (editBox:GetText() or "") ~= typedText + end + + local function NormalizeCandidate(candidate) + if not candidate or candidate == "" or typedText == "" then return nil end + candidate = slower(candidate) + local typedLen = #typedText + if typedLen >= #candidate then return nil end + if slower(ssub(candidate, 1, typedLen)) ~= slower(typedText) then + return nil + end + return candidate + end local function StripAutocomplete() - local current = editBox:GetText() or "" - if current == typedText then return end + local hadAutocomplete = HasAutocomplete() + if hadAutocomplete then + programmatic = true + editBox:SetText(typedText) + editBox:SetCursorPosition(#typedText) + editBox:HighlightText(0, 0) + programmatic = false + else + editBox:HighlightText(0, 0) + end + currentCandidate = nil + smoothExtendDone = false + return hadAutocomplete + end + + local function RenderCandidate(candidate) + candidate = NormalizeCandidate(candidate) + if not candidate then + StripAutocomplete() + return false + end + + local typedLen = #typedText + typedText = ssub(candidate, 1, typedLen) + currentCandidate = candidate + programmatic = true - editBox:SetText(typedText) - editBox:SetCursorPosition(#typedText) - editBox:HighlightText(0, 0) + if editBox:GetText() ~= candidate then + editBox:SetText(candidate) + end + editBox:SetCursorPosition(typedLen) + editBox:HighlightText(typedLen, #candidate) programmatic = false + return true end local function ApplyAutocomplete() if programmatic or typedText == "" or not editBox:HasFocus() then + StripAutocomplete() return end - -- Smooth fast path already settled the suggestion for this - -- typedText; consume the flag and bail before findCandidate - -- runs (which would re-call into the result list and re-paint - -- the highlight even if nothing changed). if smoothExtendDone then smoothExtendDone = false return end local candidate = findCandidate(typedText) - if not candidate or candidate:lower() == typedText:lower() then - currentCandidate = nil - StripAutocomplete() + RenderCandidate(candidate) + end + + editBox:HookScript("OnTextChanged", function(self, userInput) + if programmatic then return end + local current = self:GetText() or "" + if restoreBackspaceText then + local restoreText = restoreBackspaceText + local restoreCursor = restoreBackspaceCursor or #restoreText + restoreBackspaceText, restoreBackspaceCursor = nil, nil + backspaceStripActive = false + if current ~= restoreText then + programmatic = true + self:SetText(restoreText) + self:SetCursorPosition(restoreCursor) + self:HighlightText(0, 0) + programmatic = false + end + typedText = ssub(restoreText, 1, restoreCursor) return end - local suffix = candidate:sub(#typedText + 1):lower() - if suffix == "" then - currentCandidate = nil - StripAutocomplete() - return + local cursorPos = self:GetCursorPosition() or #current + if not userInput and cursorPos == 0 and current ~= "" then + cursorPos = #current end - local fullText = typedText .. suffix - currentCandidate = fullText - if editBox:GetText() == fullText then - editBox:SetCursorPosition(#typedText) - editBox:HighlightText(#typedText, #fullText) + local typed = ssub(current, 1, cursorPos) + if charDispatchedTyped and typed == charDispatchedTyped then + charDispatchedTyped = nil return end - programmatic = true - editBox:SetText(fullText) - editBox:SetCursorPosition(#typedText) - editBox:HighlightText(#typedText, #fullText) - programmatic = false - end - - editBox:HookScript("OnTextChanged", function(self) - if programmatic then return end - local current = self:GetText() or "" - local cursorPos = self:GetCursorPosition() - local typed = current:sub(1, cursorPos) if typed == typedText then return end local prevText = typedText local prevLen = #typedText typedText = typed - if onTypedChanged then onTypedChanged(self, typedText, prevText, #typedText > prevLen) end - - -- Smooth-typing fast path only when the user is GROWING the typed - -- prefix. On a shrink (backspace) the suggestion must be torn - -- down -- re-extending it here would cancel the backspace - -- visually and the user gets stuck. + local grew = #typedText > prevLen if #typedText > prevLen and currentCandidate and typedText ~= "" and #typedText < #currentCandidate - and typedText:lower() == currentCandidate:sub(1, #typedText):lower() then - local fullText = typedText .. currentCandidate:sub(#typedText + 1) - programmatic = true - if current ~= fullText then - self:SetText(fullText) - end - self:SetCursorPosition(#typedText) - self:HighlightText(#typedText, #fullText) - programmatic = false - smoothExtendDone = true - elseif #typedText < prevLen then + and slower(typedText) == slower(ssub(currentCandidate, 1, #typedText)) then + smoothExtendDone = RenderCandidate(currentCandidate) + else currentCandidate = nil + self:HighlightText(0, 0) + end + if onTypedChanged then onTypedChanged(self, typedText, prevText, grew) end + end) + + editBox:HookScript("OnChar", function(self, char) + if not currentCandidate or not char or char == "" then return end + local current = self:GetText() or "" + if current == currentCandidate then return end + local cursorPos = self:GetCursorPosition() or #current + local typed = ssub(current, 1, cursorPos) + if #typed <= #typedText then return end + local candidatePrefix = ssub(currentCandidate, 1, #typed) + if slower(typed) ~= slower(candidatePrefix) then + currentCandidate = nil + self:HighlightText(0, 0) + return + end + + local prevText = typedText + typedText = candidatePrefix + if not RenderCandidate(currentCandidate) then return end + smoothExtendDone = true + if onTypedChanged then + charDispatchedTyped = typedText + if C_Timer then + C_Timer.After(0, function() + charDispatchedTyped = nil + end) + end + onTypedChanged(self, typedText, prevText, true) end end) editBox:HookScript("OnEditFocusLost", StripAutocomplete) - local function AcceptAutocomplete(self) - local current = self:GetText() or "" - if current == "" or current == typedText then return false end + local function AcceptAutocomplete(self, source, cursorPos) + local candidate = currentCandidate + if not candidate or candidate == "" then return false end + if cursorPos then + if cursorPos < 0 then cursorPos = 0 end + if cursorPos > #candidate then cursorPos = #candidate end + end + if not self:HasFocus() then self:SetFocus() end programmatic = true - self:SetCursorPosition(#current) + self:SetText(candidate) + self:SetCursorPosition(cursorPos or #candidate) self:HighlightText(0, 0) programmatic = false - typedText = current + typedText = candidate currentCandidate = nil - if onAccepted then onAccepted(current) end + smoothExtendDone = false + if onAccepted then onAccepted(candidate, source) end return true end + editBox:HookScript("OnMouseDown", function(_, button) + if button ~= "LeftButton" then return end + mouseAcceptCandidate = HasAutocomplete() and currentCandidate or nil + mouseAcceptTypedLen = mouseAcceptCandidate and #typedText or nil + end) + + editBox:HookScript("OnMouseUp", function(self, button) + if button ~= "LeftButton" or not mouseAcceptCandidate then return end + local candidate = mouseAcceptCandidate + local typedLen = mouseAcceptTypedLen or #typedText + mouseAcceptCandidate = nil + mouseAcceptTypedLen = nil + if (self:GetText() or "") ~= candidate then return end + local cursorPos = self:GetCursorPosition() or #candidate + if cursorPos < typedLen then + local prefix = ssub(candidate, 1, typedLen) + programmatic = true + self:SetText(prefix) + self:SetCursorPosition(cursorPos) + self:HighlightText(0, 0) + programmatic = false + typedText = prefix + currentCandidate = nil + smoothExtendDone = false + return + end + typedText = candidate + currentCandidate = nil + smoothExtendDone = false + self:HighlightText(0, 0) + if onAccepted then onAccepted(candidate, "click") end + end) + editBox:HookScript("OnTabPressed", function(self) - AcceptAutocomplete(self) + AcceptAutocomplete(self, "tab") + end) + + editBox:HookScript("OnKeyDown", function(self, key) + if key == "BACKSPACE" and HasAutocomplete() then + backspaceStripActive = true + restoreBackspaceText = typedText + restoreBackspaceCursor = #typedText + StripAutocomplete() + if C_Timer then + C_Timer.After(0, function() + restoreBackspaceText, restoreBackspaceCursor = nil, nil + backspaceStripActive = false + end) + end + if Utils.SafeCallMethod then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + end + return + end + local source + if key == "RIGHT" or key == "ARROWRIGHT" then + source = "right" + elseif key == "L" and IsControlKeyDown() then + source = "ctrl-l" + end + if source + and AcceptAutocomplete(self, source) then + if Utils.SafeCallMethod then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + end + end end) - -- Public API on the editbox so callers can drive it from their - -- search-update path without holding their own state. editBox.UpdateAutocomplete = ApplyAutocomplete editBox.StripAutocomplete = StripAutocomplete - editBox.AcceptAutocomplete = AcceptAutocomplete + editBox.AcceptAutocomplete = function(self, source, cursorPos) + return AcceptAutocomplete(self, source, cursorPos) + end editBox.GetTypedText = function() return typedText end + editBox.HasAutocomplete = function() return HasAutocomplete() end + editBox.IsAutocompleteBackspaceStrip = function() return backspaceStripActive end editBox.IsAutocompleteProgrammatic = function() return programmatic end end +--- Scroll a ScrollFrame so that the given child button is visible. +--- Uses the button's top/bottom relative to the scrollChild. function Utils.ScrollToButton(scrollFrame, button) if not scrollFrame or not button then return end local _, _, _, _, btnOffsetY = button:GetPoint(1) @@ -303,6 +413,7 @@ ns.GOLD_COLOR = {1.0, 0.82, 0.0} ns.YELLOW_HIGHLIGHT = {1, 1, 0} ns.DEFAULT_OPACITY = 0.75 ns.TOOLTIP_BORDER = "Interface\\Tooltips\\UI-Tooltip-Border" +ns.EYE_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\eye" ns.DARK_PANEL_BG = {0.1, 0.1, 0.1, 0.95} ns.RESULT_ICON_SIZE = 18 ns.SEARCHBAR_HEIGHT = 30 -- base search bar frame height (before font scaling) @@ -383,17 +494,7 @@ function ns.CreateSearchBorder(frame) fillLeft = fillLeft, fillMid = fillMid, fillRight = fillRight, borderLeft = borderLeft, borderMid = borderMid, borderRight = borderRight, } - ApplyCapWidths(frame) - -- A theme/font/zoom change calls SetHeight; OnSizeChanged keeps - -- the cap width in lockstep without each caller having to know. frame:HookScript("OnSizeChanged", ApplyCapWidths) -end - --- Kept as a public no-op for callers that still pass the legacy --- scale parameter. Cap width tracks frame height now via the --- OnSizeChanged hook installed in CreateSearchBorder, so we just --- re-apply in case the caller resized the frame in the same tick. -function ns.ScaleSearchBorder(frame, _scale) ApplyCapWidths(frame) end @@ -1181,6 +1282,18 @@ function Utils.ShowCursorMenu(globalName, rows, opts) return menu end +function Utils.ShowPinMenu(globalName, isPinned, onPin, onGuide, onAddAlias, opts) + local rows = {} + if onGuide then + rows[#rows + 1] = { text = "Guide", icon = ns.EYE_ICON_TEX, onClick = onGuide } + end + rows[#rows + 1] = { text = isPinned and "Unpin" or "Pin", onClick = onPin } + if onAddAlias then + rows[#rows + 1] = { text = "Add Alias", onClick = onAddAlias } + end + return Utils.ShowCursorMenu(globalName, rows, opts) +end + function Utils.SetIconTexture(textureObj, icon, fallback) if not textureObj then return end textureObj:SetTexture(nil) From 6294652f87f0fbd5202fb318d4f2b17e2b711258 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Wed, 6 May 2026 18:12:23 -0400 Subject: [PATCH 040/103] Fix ability navigation when same spell appears in multiple sections Clicking a spec-version spell (e.g. Balance Wrath) was always landing on the baseline class version (Druid Wrath) because both spells share spellID, name, and icon -- the matcher had no way to disambiguate. Live PagedSpellsFrame elementData uses these field names (verified via the new /efd sbprobe tool, not the names assumed in the original implementation): - slotIndex (not itemIndex / spellBookItemSlotIndex) - spellBank (not spellBookItemBank / bank) - specID (the unambiguous disambiguator: Balance=102, Feral=103, ...) Database.lua: - PopulateDynamicAbilities now stores spellBookSpecID = lineInfo.specID on each ability entry so the matcher has something to disambiguate with at click time. UI.lua: - ExtractSlot/ExtractBank check the actual live field names (slotIndex, spellBank) before the legacy guesses. - New ExtractSpecID + HasSpecMismatch helpers. - SpellRecordMatches: top-priority specID + slot/spell match path, rejects bare-number matches (Button:GetSpellID returning a raw number couldn't disambiguate two rows that share spellID), and rejects spellID/name matches that lack positive specID-or-slot agreement (an info table with just {spellID, name} can no longer claim to be the right row when target carries disambig hints). - FindSpellbookCategoryTab uses tab.tabText (the real field) and tab:GetText() as fallbacks for picking General vs class tab. - OpenAbilityInSpellbook simplified to walk-pages-forward; with the matcher now disambiguating by specID/slot, the first hit it produces is the correct row. Other changes touching MapSearch / MapTab / Utils / BlizzOptionsSearch are misc fixes from the same branch that were already on disk. --- BlizzOptionsSearch.lua | 90 +++- Database.lua | 55 ++- MapSearch.lua | 27 +- MapTab.lua | 63 ++- UI.lua | 983 ++++++++++++++++++++++++++++++++++------- Utils.lua | 48 +- 6 files changed, 1031 insertions(+), 235 deletions(-) diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index 159733c..1dad131 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -273,24 +273,82 @@ BlizzOptionsSearch.GetTooltipForVariable = GetTooltipForVariable -- enumerated. Cached per variable since layouts don't change after -- registration. False sentinel = "we looked, none available". local optionsByVariable = {} -local function NormalizeOptionTable(opts) +local function NormalizeOptionTable(opts, setting) if type(opts) == "function" then - local ok, o = pcall(opts) + local ok, o = pcall(opts, setting) + if not ok then + ok, o = pcall(opts) + end if not ok then return nil end opts = o end if type(opts) ~= "table" then return nil end local norm = {} for _, o in ipairs(opts) do - if type(o) == "table" and o.value ~= nil then - local lab = o.label or o.text or o.name or tostring(o.value) - tinsert(norm, { value = o.value, label = lab }) + if type(o) == "table" then + local value = o.value + if value == nil then value = o.cvarValue end + if value == nil and type(o.data) == "table" then + value = o.data.value + if value == nil then value = o.data.cvarValue end + if value == nil then value = o.data.id end + elseif value == nil then + value = o.data + end + if value == nil and o.GetValue then + local ok, v = pcall(o.GetValue, o) + if ok then value = v end + end + if value ~= nil then + local label = o.label or o.text or o.name + if not label and type(o.data) == "table" then + label = o.data.label or o.data.text or o.data.name + end + if not label and o.GetText then + local ok, text = pcall(o.GetText, o) + if ok then label = text end + end + if not label and o.GetName then + local ok, name = pcall(o.GetName, o) + if ok then label = name end + end + tinsert(norm, { value = value, label = label or tostring(value) }) + end + elseif o ~= nil then + tinsert(norm, { value = o, label = tostring(o) }) + end + end + if #norm == 0 then + for value, label in pairs(opts) do + if type(label) ~= "table" then + tinsert(norm, { value = value, label = tostring(label) }) + end end end if #norm == 0 then return nil end return norm end +local function GetInitializerOptions(init, setting) + local d = init and init.data + local found + if type(d) == "table" then + found = NormalizeOptionTable(d.options, setting) + or NormalizeOptionTable(d.dropdownOptions, setting) + or NormalizeOptionTable(d.values, setting) + or NormalizeOptionTable(d.valueOptions, setting) + end + if not found and init and init.GetOptions then + local ok, opts = pcall(init.GetOptions, init) + if ok then found = NormalizeOptionTable(opts, setting) end + end + if not found and setting and setting.GetOptions then + local ok, opts = pcall(setting.GetOptions, setting) + if ok then found = NormalizeOptionTable(opts, setting) end + end + return found +end + local function GetOptionsForVariable(variable) if not variable then return nil end local cached = optionsByVariable[variable] @@ -318,9 +376,7 @@ local function GetOptionsForVariable(variable) if setting and setting.GetVariable then local vok, v = pcall(setting.GetVariable, setting) if vok and v == variable then - local d = init.data - local opts = (type(d) == "table") and d.options or nil - found = NormalizeOptionTable(opts) + found = GetInitializerOptions(init, setting) return end end @@ -338,7 +394,7 @@ local function GetOptionsForVariable(variable) if type(list) == "table" then for _, cat in ipairs(list) do scan(cat) end end - optionsByVariable[variable] = found or false + if found then optionsByVariable[variable] = found end return found end BlizzOptionsSearch.GetOptionsForVariable = GetOptionsForVariable @@ -739,7 +795,10 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) local d = init.data local opts = (type(d) == "table") and d.options or nil if type(opts) == "function" then - local ook, o = pcall(opts) + local ook, o = pcall(opts, setting) + if not ook then + ook, o = pcall(opts) + end if ook then opts = o end end if type(opts) == "table" and opts.minValue and opts.maxValue then @@ -761,15 +820,8 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) -- objects. We normalize to { value, label } pairs and -- skip if the table doesn't look enumerable. local settingOptions - if resolvedType == "dropdown" and type(opts) == "table" then - local norm = {} - for _, o in ipairs(opts) do - if type(o) == "table" and o.value ~= nil then - local lab = o.label or o.text or o.name or tostring(o.value) - tinsert(norm, { value = o.value, label = lab }) - end - end - if #norm > 0 then settingOptions = norm end + if resolvedType == "dropdown" then + settingOptions = GetInitializerOptions(init, setting) end local nameLower = slower(settingName) diff --git a/Database.lua b/Database.lua index d501790..d644dd8 100644 --- a/Database.lua +++ b/Database.lua @@ -1869,45 +1869,38 @@ function Database:PopulateDynamicAbilities() local BANK = (Enum and Enum.SpellBookSpellBank and Enum.SpellBookSpellBank.Player) or 0 local SPELL_TYPE = Enum and Enum.SpellBookItemType and Enum.SpellBookItemType.Spell - local seen = {} -- spellID -> true (dedupe across skill lines) + local seen = {} local numLines = SBOOK.GetNumSpellBookSkillLines() or 0 for tab = 1, numLines do local lineInfo = SBOOK.GetSpellBookSkillLineInfo(tab) local offset = lineInfo and lineInfo.itemIndexOffset or 0 local numSpells = lineInfo and lineInfo.numSpellBookItems or 0 + local lineName = lineInfo and lineInfo.name + local lineSpecID = lineInfo and lineInfo.specID + local offSpecID = lineInfo and lineInfo.offSpecID + if (lineSpecID == nil or lineSpecID == 0) + and offSpecID and offSpecID ~= 0 then + lineSpecID = offSpecID + end for s = offset + 1, offset + numSpells do local itemInfo = SBOOK.GetSpellBookItemInfo(s, BANK) - -- Skip flyout placeholders, passives we don't want, etc. + local seenKey = itemInfo and itemInfo.actionID + and (tostring(itemInfo.actionID) .. ":" .. tostring(tab) + .. ":" .. tostring(lineSpecID or "")) if itemInfo and (not SPELL_TYPE or itemInfo.itemType == SPELL_TYPE) and itemInfo.name and itemInfo.name ~= "" - and itemInfo.actionID and not seen[itemInfo.actionID] then - seen[itemInfo.actionID] = true + and itemInfo.actionID and seenKey and not seen[seenKey] then + seen[seenKey] = true local name = itemInfo.name local subName = itemInfo.subName local displayName = (subName and subName ~= "") and (name .. " (" .. subName .. ")") or name local nameLower = slower(displayName) - -- "ability" / "abilities" let multi-word queries scope - -- to abilities ("feral abilities" matches all spec - -- abilities for that line). "spell" / "cast" / similar - -- generics are deliberately omitted: they used to drag - -- every ability into single-letter-typo searches via - -- fuzzy matching. The "ability"/"abilities" pair is - -- safer because the only nearby word ("agility") is - -- in FUZZY_BLOCKLIST. - -- Spec / skill-line name (e.g. "Feral", "Restoration") - -- is included so multi-word queries like "feral - -- abilities" can scope to a spec. local kw = { slower(name), "ability", "abilities" } - if lineInfo and lineInfo.name and lineInfo.name ~= "" then - kw[#kw + 1] = slower(lineInfo.name) + if lineName and lineName ~= "" then + kw[#kw + 1] = slower(lineName) end - -- offSpecID > 0 means this line belongs to a non-active - -- specialization (the greyed-out tabs in the spellbook). - -- We still index the spells so users can search across all - -- specs, but mark them so the row renderer can desaturate - -- the icon and dim the text the way the spellbook does. local isOffSpec = lineInfo and lineInfo.offSpecID and lineInfo.offSpecID ~= 0 uiSearchData[#uiSearchData + 1] = { @@ -1916,17 +1909,19 @@ function Database:PopulateDynamicAbilities() keywords = kw, keywordsLower = kw, category = "Ability", - -- Spec / class skill-line name ("Feral", "Druid", "Restoration"). - -- Used by the row renderer to show "Feral Ability" subtext - -- instead of the bare "Ability" category label. treeName = lineInfo and lineInfo.name, isOffSpec = isOffSpec, + isPassive = itemInfo.isPassive, + isSpellbookOnly = isOffSpec or itemInfo.isPassive, icon = itemInfo.iconID, spellID = itemInfo.actionID, + spellBookSpellID = itemInfo.spellID or itemInfo.actionID, + spellBookIndex = s, + spellBookBank = BANK, + spellBookCategoryIndex = tab, + spellBookCategoryName = lineName, + spellBookSpecID = lineSpecID, spellName = name, - -- No multi-step guide: SelectResult treats spellID - -- as a direct cast target; drag-to-pickup uses it - -- to put the spell on the cursor. steps = { { spellID = itemInfo.actionID } }, } end @@ -4047,6 +4042,7 @@ end function Database:SearchUI(query, skipCategories) if not query or query == "" or #query < 2 then prevQuery = "" + wipe(prevCandidates) wipe(resultsBuf) return resultsBuf end @@ -4058,7 +4054,7 @@ function Database:SearchUI(query, skipCategories) if ssub(query, queryLen, queryLen) == " " then query = query:match("^(.-)%s+$") or query queryLen = #query - if queryLen == 0 then prevQuery = ""; wipe(resultsBuf); return resultsBuf end + if queryLen == 0 then prevQuery = ""; wipe(prevCandidates); wipe(resultsBuf); return resultsBuf end end wipe(resultsQueryWords) @@ -4126,6 +4122,7 @@ function Database:SearchUI(query, skipCategories) end if not searchSet or #searchSet == 0 then wipe(resultsBuf) + wipe(prevCandidates) prevQuery = query prevSkipKey = skipKey return resultsBuf diff --git a/MapSearch.lua b/MapSearch.lua index 7d98c7d..0566378 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -1369,8 +1369,11 @@ function MapSearch:SearchZones(query) return a.name < b.name end) - -- Store under the current query so backspace/retyping hits cache. - CachePut(cache, query, matches) + if #matches > 0 then + CachePut(cache, query, matches) + else + cache.lastQuery = query + end return matches end @@ -4631,18 +4634,18 @@ function MapSearch:SearchPOIs(pois, query, noCache) if noCache then return results end - -- Snapshot for the query cache. Shallow-copy because `results` - -- points at reuseSearchResults which the next call wipes. - local snapshot = {} - for i = 1, #results do snapshot[i] = results[i] end - if cache.entries[query] == nil then - cache.order[#cache.order + 1] = query - if #cache.order > SEARCH_CACHE_MAX then - local oldest = tremove(cache.order, 1) - cache.entries[oldest] = nil + if #results > 0 then + local snapshot = {} + for i = 1, #results do snapshot[i] = results[i] end + if cache.entries[query] == nil then + cache.order[#cache.order + 1] = query + if #cache.order > SEARCH_CACHE_MAX then + local oldest = tremove(cache.order, 1) + cache.entries[oldest] = nil + end end + cache.entries[query] = { matchedCategory = matchedCategory, results = snapshot } end - cache.entries[query] = { matchedCategory = matchedCategory, results = snapshot } cache.lastQuery = query cache.lastCategory = matchedCategory diff --git a/MapTab.lua b/MapTab.lua index 2ae6d41..9b6f3b8 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -33,8 +33,8 @@ local ExpandZoneAbbrev = MapUtils.ExpandZoneAbbrev local BuildFullBreadcrumb = MapUtils.BuildBreadcrumb -- Result row layout -local ROW_HEIGHT = 24 -local ROW_ICON_SIZE = 18 +local ROW_HEIGHT = 22 +local ROW_ICON_SIZE = 17 local SECTION_HEADER_H = 22 local MAX_ROW_POOL = 300 local ROW_POOL_RETAIN = 80 @@ -124,6 +124,42 @@ local sessionCollapsed = {} local sessionCollapsedQuery = nil local lastQueryGen = 0 local pendingSearchTimer +local pendingSearchFrame +local pendingSearchText +local pendingSearchGrew +local pendingSearchEditBox +local function CancelPendingSearch() + if pendingSearchFrame then pendingSearchFrame:Hide() end + pendingSearchTimer = nil + pendingSearchText = nil + pendingSearchGrew = nil + pendingSearchEditBox = nil +end +local function SchedulePendingSearch(editBox, typed, grew) + if not pendingSearchFrame then + pendingSearchFrame = CreateFrame("Frame") + pendingSearchFrame:Hide() + pendingSearchFrame:SetScript("OnUpdate", function(self) + self:Hide() + local box = pendingSearchEditBox + local text = pendingSearchText or "" + local shouldAutocomplete = pendingSearchGrew + pendingSearchTimer = nil + pendingSearchText = nil + pendingSearchGrew = nil + pendingSearchEditBox = nil + MapTab:RunSearch(text) + if shouldAutocomplete and box and box.UpdateAutocomplete then + box:UpdateAutocomplete() + end + end) + end + pendingSearchTimer = true + pendingSearchText = typed + pendingSearchGrew = grew + pendingSearchEditBox = editBox + pendingSearchFrame:Show() +end -- Tracks the query that produced the currently-displayed results. The -- search box text can diverge from this (e.g. clicking a recent-search -- row runs a search without populating the box), so result-click and @@ -734,10 +770,7 @@ local function TrimHeaderPool() end ReleaseMapTabMemory = function(trimFrames) - if pendingSearchTimer then - pendingSearchTimer:Cancel() - pendingSearchTimer = nil - end + if pendingSearchTimer then CancelPendingSearch() end ReleaseAllRows() ReleaseAllHeaders() if panel and panel.scrollChild then panel.scrollChild:SetHeight(1) end @@ -1878,12 +1911,7 @@ local function CreateSearchBox(parent) onTypedChanged = function(self, typed, _, grew) lastTypeTime = GetTime() UpdateClear(self) - if pendingSearchTimer then pendingSearchTimer:Cancel(); pendingSearchTimer = nil end - pendingSearchTimer = C_Timer.NewTimer(0, function() - pendingSearchTimer = nil - MapTab:RunSearch(typed) - if grew and self.UpdateAutocomplete then self:UpdateAutocomplete() end - end) + SchedulePendingSearch(self, typed, grew) end, onAccepted = function(text, source) if text and text ~= "" then MapTab:RunSearch(text) end @@ -1915,18 +1943,11 @@ local function CreateSearchBox(parent) return end end - -- An active autocomplete suffix is rendered as highlighted - -- text. WoW's default arrow-key handling on a highlighted - -- selection fires OnTextChanged synchronously, which schedules - -- a C_Timer.NewTimer(0) to RunSearch on the next frame — - -- RenderRows resets navRowIndex to 0 and drops the move we're - -- about to make. Cancel that timer for nav keys before - -- HandleNavKey runs so the navigation actually sticks. + -- Keep nav keys from racing a pending search render. if pendingSearchTimer and (key == "DOWN" or key == "UP" or (IsControlKeyDown() and (key == "J" or key == "K"))) then - pendingSearchTimer:Cancel() - pendingSearchTimer = nil + CancelPendingSearch() end HandleNavKey(key, true) Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) diff --git a/UI.lua b/UI.lua index 78a0d61..7d8676c 100644 --- a/UI.lua +++ b/UI.lua @@ -40,7 +40,8 @@ local resultsFrame -- resultsFrame when ShowHierarchicalResults runs. local containerFrame local resultButtons = {} -local MAX_BUTTON_POOL = 50 -- Maximum buttons (scroll handles overflow beyond this) +local MAX_BUTTON_POOL = 50 +local MAX_SEARCH_RESULT_ROWS = 18 local EnsureResultButton local inCombat = false local selectingResult = false -- guard: suppress OnTextChanged re-renders during SelectResult @@ -197,6 +198,10 @@ local function GetFlatSubtext(data) return data.category or "" end +local function IsSpellbookOnlyAbility(data) + return data and data.category == "Ability" and data.spellID and data.isSpellbookOnly +end + -- Hint shown only on the currently-selected row, replacing the normal -- subtext so the user knows what Enter / left-click will do without -- cluttering every other row. Returns nil for entries whose action @@ -212,7 +217,9 @@ local function GetActionHint(data) if data.outfitID then return "Select to wear outfit" end if data.gearSetID then return "Select to equip gear set" end if data.transmogSetID then return "Select to preview appearance set" end - if data.spellID and data.category == "Ability" then return "Select to cast" end + if data.spellID and data.category == "Ability" then + return IsSpellbookOnlyAbility(data) and "Select to show in spellbook" or "Select to cast" + end if data.macroIndex then return "Select to run macro" end if data.itemID and data.category == "Bag" then if data.isEquippable or (data.equipLoc and data.equipLoc ~= "") then @@ -297,7 +304,8 @@ end local function ShowPinPopup(_, isPinned, onPinAction, onGuide, onAddAlias) Utils.ShowPinMenu("EasyFindPinPopup", isPinned, onPinAction, onGuide, onAddAlias, { - strata = "FULLSCREEN_DIALOG", + strata = "TOOLTIP", + level = 100, width = 96, rowHeight = 22, }) @@ -327,28 +335,8 @@ local function ClearResultTooltips() end end --- Centralized icon setter - resets texture state before applying to prevent --- atlas/texture bleed between rows. local function SetRowIcon(btn, kind, value, iconSize) - -- Cache the (kind, value, size) we last applied. The hot path on - -- every keystroke re-renders rows whose icon hasn't changed at all - -- (incremental narrowing keeps top-N rows the same), so skipping - -- the SetTexture / SetAtlas / SetTexCoord burst when nothing - -- changed is one of the bigger per-keystroke wins. local sz = iconSize or 16 - -- Cache key is the raw value (numeric fileID or the full coords - -- table). Table identity is what we want: two distinct sprite-sheet - -- entries with the same fileID but different coords are distinct - -- tables, so they correctly miss the cache. - if btn._iconKind == kind and btn._iconValKey == value and btn._iconSize == sz then - if kind == "hidden" then return end - btn.icon:Show() - return - end - btn._iconKind = kind - btn._iconValKey = value - btn._iconSize = sz - btn.icon:SetTexture(nil) btn.icon:SetTexCoord(0, 1, 0, 1) btn.icon:SetVertexColor(1, 1, 1, 1) @@ -394,16 +382,16 @@ local THEMES = {} -- Modern: quest-log style - raised tab headers, golden tree lines, grey border THEMES["Modern"] = { - rowHeight = 22, + rowHeight = 20, indentPx = 20, -- matches INDENT_PX so tree lines align lineWidth = 2, resultsWidth = 350, - resultsPadTop = 10, - resultsPadBot = 10, + resultsPadTop = 8, + resultsPadBot = 8, resultsPadLeft = 12, btnWidth = 366, - iconSize = 16, - pathIconSize = 14, + iconSize = 15, + pathIconSize = 13, -- fonts pathFont = ns.SEARCHBAR_FONT, leafFont = ns.LEAF_FONT, @@ -466,6 +454,12 @@ local function ScaleFont(fontString, baseFontObject) fontString:SetJustifyH(fontString:GetJustifyH()) end +local function SetScaledFont(fontString, baseFontObject) + if not fontString then return end + fontString:SetFontObject(baseFontObject) + ScaleFont(fontString, baseFontObject) +end + local function ApplyResultRowFonts(row, theme) if not row then return end theme = theme or GetActiveTheme() @@ -779,7 +773,7 @@ function UI:CreateSearchFrame() _G["EasyFindSpecFlyout"], } for _, g in ipairs(guards) do - if g and g:IsShown() and g:IsMouseOver() then + if Utils.IsFrameOrChildMouseOver(g) then onGuard = true break end @@ -814,9 +808,26 @@ function UI:CreateSearchFrame() end end) - local pendingUISearchTimer local lastTypedLen = 0 local lastSearchTime = 0 + local pendingUISearchText = "" + local pendingUISearchGrew = false + local pendingUISearchDue = 0 + local pendingUISearchFrame = CreateFrame("Frame") + pendingUISearchFrame:Hide() + pendingUISearchFrame:SetScript("OnUpdate", function(self) + if GetTime() < pendingUISearchDue then return end + self:Hide() + local typedNow = pendingUISearchText + local grew = pendingUISearchGrew + pendingUISearchText = "" + pendingUISearchGrew = false + lastSearchTime = GetTime() + UI:OnSearchTextChanged(typedNow) + if grew and editBox.UpdateAutocomplete then + editBox.UpdateAutocomplete() + end + end) local SEARCH_THROTTLE = 0.05 -- 50ms cap on search/render frequency editBox:SetScript("OnTextChanged", function(self, userInput) self.placeholder:SetShown(self:GetText() == "") @@ -833,7 +844,6 @@ function UI:CreateSearchFrame() if self.IsAutocompleteBackspaceStrip and self:IsAutocompleteBackspaceStrip() then return end historyIndex = 0 historyDraft = "" - if pendingUISearchTimer then pendingUISearchTimer:Cancel() end -- Search query is the text up to the cursor -- anything past -- the cursor is unaccepted autocomplete suffix and must not -- feed into search results. Programmatic autocomplete SetText @@ -845,14 +855,10 @@ function UI:CreateSearchFrame() lastTypedLen = #typedNow local elapsed = GetTime() - lastSearchTime local delay = elapsed >= SEARCH_THROTTLE and 0 or (SEARCH_THROTTLE - elapsed) - pendingUISearchTimer = C_Timer.NewTimer(delay, function() - pendingUISearchTimer = nil - lastSearchTime = GetTime() - UI:OnSearchTextChanged(typedNow) - if grew and editBox.UpdateAutocomplete then - editBox.UpdateAutocomplete() - end - end) + pendingUISearchText = typedNow + pendingUISearchGrew = grew + pendingUISearchDue = GetTime() + delay + pendingUISearchFrame:Show() end) editBox:SetScript("OnEnterPressed", function(self) @@ -1494,7 +1500,8 @@ function UI:CreateSearchFrame() 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 or rd.spellID + if rd and (rd.outfitID or rd.toyItemID + or (rd.spellID and not IsSpellbookOnlyAbility(rd)) or rd.mountID or rd.macroIndex or (rd.itemID and rd.category == "Bag")) then Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) @@ -1725,7 +1732,7 @@ function UI:CreateSearchFrame() if dropdown and dropdown.guardFrames then for i = 1, #dropdown.guardFrames do local g = dropdown.guardFrames[i] - if g and g:IsShown() and g:IsMouseOver() then return end + if Utils.IsFrameOrChildMouseOver(g) then return end end end local extras = { @@ -1733,7 +1740,7 @@ function UI:CreateSearchFrame() _G["EasyFindUIWizard"], } for _, g in ipairs(extras) do - if g and g:IsShown() and g:IsMouseOver() then return end + if Utils.IsFrameOrChildMouseOver(g) then return end end UI:Hide() end) @@ -3689,7 +3696,7 @@ function UI:CreateResultsFrame() _G["EasyFindSpecFlyout"], } for _, g in ipairs(guards) do - if g and g:IsShown() and g:IsMouseOver() then return end + if Utils.IsFrameOrChildMouseOver(g) then return end end UI:HideResults() end) @@ -3799,6 +3806,66 @@ local function MaybeLoadHeavySearchData(text, needsHeavy) if not started then heavySearchLoading = false end end +local function RefocusSearchEditBox() + if searchFrame and searchFrame.editBox + and not (navFrame and navFrame:IsKeyboardEnabled()) then + searchFrame.editBox.blockFocus = nil + searchFrame.editBox:SetFocus() + end +end + +local function ReadSettingVariable(variable) + if Settings and Settings.GetSetting then + local sok, settObj = pcall(Settings.GetSetting, variable) + if sok and settObj and settObj.GetValue then + local vok, value = pcall(settObj.GetValue, settObj) + if vok then return value end + end + end + if GetCVar then + local ok, value = pcall(GetCVar, variable) + if ok then return value end + end +end + +local function WriteSettingVariable(variable, value) + if Settings and Settings.SetValue then + local ok = pcall(Settings.SetValue, variable, value) + if ok then return true end + ok = pcall(Settings.SetValue, Settings, variable, value) + if ok then return true end + end + if Settings and Settings.GetSetting then + local sok, settObj = pcall(Settings.GetSetting, variable) + if sok and settObj and settObj.SetValue then + local ok = pcall(settObj.SetValue, settObj, value) + if ok then return true end + end + end + if SetCVar then + local ok = pcall(SetCVar, variable, tostring(value)) + if ok then return true end + end + return false +end + +local function ActivateSettingResult(data) + if not data or not data.settingVariable then return false end + local stype = data.settingType + if stype == "checkbox" then + UI:ToggleSettingCheckbox(data) + elseif stype == "slider" then + RefocusSearchEditBox() + elseif stype == "dropdown" then + if not UI:CycleSettingDropdown(data) then + UI:OpenSettingNoClose(data) + end + else + UI:OpenSettingNoClose(data) + end + return true +end + -- Within-group ordering for flat-list mode. Score-first so the best -- matches stay at the top — alphabetical was burying high-scoring -- prefix matches (e.g. "Skull Bash" for query "skull") below low- @@ -4383,7 +4450,20 @@ function UI:CreateResultButton(index) -- the secure click. SetScript would replace them and break casts. resultRow:HookScript("OnMouseDown", function(self, button) if button ~= "LeftButton" then return end - if not IsShiftKeyDown() then return end + if not IsShiftKeyDown() then + local handled = ActivateSettingResult(self.data) + if not handled and IsSpellbookOnlyAbility(self.data) then + UI:SelectResult(self.data) + handled = true + end + if handled then + self._handledNonSecureMouseDown = true + C_Timer.After(0, function() + self._handledNonSecureMouseDown = nil + end) + end + return + end if not self.data then return end local x, y = GetCursorPosition() self._dragOriginX, self._dragOriginY = x, y @@ -4476,6 +4556,10 @@ function UI:CreateResultButton(index) end end) resultRow:SetScript("PostClick", function(self, mouseButton, down) + if self._handledNonSecureMouseDown then + self._handledNonSecureMouseDown = nil + return + end -- Shift+click pickup: cursor is holding the action for the -- user to drop on a bar. Don't navigate away or close. if self._pickedUp then @@ -4574,32 +4658,7 @@ function UI:CreateResultButton(index) -- the next keystroke to the search bar instead of the bind. return end - if self.data and self.data.settingVariable then - local stype = self.data.settingType - if stype == "checkbox" then - UI:ToggleSettingCheckbox(self.data) - elseif stype == "slider" then - -- Slider widget handles its own drag/click. The row - -- still receives a "click" because the slider is a - -- child, so refocus the editbox to keep results open - -- (OnEditFocusLost would otherwise hide them). - if searchFrame and searchFrame.editBox - and not (navFrame and navFrame:IsKeyboardEnabled()) then - searchFrame.editBox.blockFocus = nil - searchFrame.editBox:SetFocus() - end - elseif stype == "dropdown" then - -- Cycle to the next dropdown value inline. Falls back to - -- opening the panel only if we can't enumerate options - -- (variable not found in any live category layout). - if not UI:CycleSettingDropdown(self.data) then - UI:OpenSettingNoClose(self.data) - end - else - UI:OpenSettingNoClose(self.data) - end - return - end + if ActivateSettingResult(self.data) then return end if self.isPinHeader then return @@ -5250,12 +5309,20 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local theme = EasyFind.db.resultsTheme local above = EasyFind.db.uiResultsAbove local collapsedKey = next(collapsedNodes) + local fontScale = EasyFind.db.fontSize or 1.0 + local searchW = searchFrame and searchFrame:GetWidth() or 0 + local customResultsW = EasyFind.db.uiResultsWidth or 0 + local maxResultsH = EasyFind.db.uiResultsHeight or 280 local n = #hierarchical local last = self._lastRenderSig local same = last and last.n == n and last.theme == theme and last.above == above and last.collapsedKey == collapsedKey + and last.fontScale == fontScale + and last.searchW == searchW + and last.customResultsW == customResultsW + and last.maxResultsH == maxResultsH and resultsFrame:IsShown() if same then for hi = 1, n do @@ -5276,6 +5343,10 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) last.theme = theme last.above = above last.collapsedKey = collapsedKey + last.fontScale = fontScale + last.searchW = searchW + last.customResultsW = customResultsW + last.maxResultsH = maxResultsH for hi = 1, n do local e = hierarchical[hi] last[hi * 2 - 1] = e.data @@ -5287,22 +5358,34 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) ClearResultTooltips() local theme = GetActiveTheme() - local rowH = theme.rowHeight + local fontScale = EasyFind.db.fontSize or 1.0 + local rowH = mfloor(theme.rowHeight * fontScale + 0.5) + if rowH < theme.rowHeight then rowH = theme.rowHeight end + local flatExtraH = mfloor(16 * fontScale + 0.5) + if flatExtraH < 16 then flatExtraH = 16 end + local stackGap = mfloor(2 * fontScale + 0.5) + if stackGap < 2 then stackGap = 2 end + local stackHalfGap = stackGap * 0.5 local indPx = theme.indentPx - local padT = theme.resultsPadTop + local padT = mfloor((theme.resultsPadTop or 0) * fontScale + 0.5) + if padT < theme.resultsPadTop then padT = theme.resultsPadTop end + local padB = mfloor((theme.resultsPadBot or 0) * fontScale + 0.5) + if padB < theme.resultsPadBot then padB = theme.resultsPadBot end -- Scale row icons to match leaf font height so icon top/bottom -- align with text top/bottom instead of overflowing the cap line. - local iconScale = 1.15 + local iconScale = 1.12 local leafFontObj = _G[theme.leafFont] local leafFontPx = 10 if leafFontObj and leafFontObj.GetFont then local _, sz = leafFontObj:GetFont() if sz and sz > 0 then leafFontPx = sz end end - local rowIconSize = math.floor(leafFontPx * iconScale + 0.5) + local rowIconSize = math.floor(leafFontPx * fontScale * iconScale + 0.5) if rowIconSize < 12 then rowIconSize = 12 end - if rowIconSize > (theme.iconSize or 16) then rowIconSize = theme.iconSize or 16 end + local maxIconSize = math.floor((theme.iconSize or 16) * fontScale + 0.5) + if maxIconSize < (theme.iconSize or 16) then maxIconSize = theme.iconSize or 16 end + if rowIconSize > maxIconSize then rowIconSize = maxIconSize end -- Apply theme backdrop to results frame resultsFrame:SetBackdrop(theme.resultsBackdrop) @@ -5375,6 +5458,9 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end local count = mmin(visibleN, MAX_BUTTON_POOL) + if pinSlots < visibleN then + count = mmin(count, pinSlots + MAX_SEARCH_RESULT_ROWS) + end local maxVisibleHeight = EasyFind.db.uiResultsHeight or 280 local willScroll = visibleN * rowH > maxVisibleHeight @@ -5438,7 +5524,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- to fit the name + path subtext stack with breathing room above -- the name and below the path so neither bleeds into the rep bar. local padL = theme.resultsPadLeft or 10 - local entryRowH = entry.isFlat and (rowH + 20) or rowH + local entryRowH = entry.isFlat and (rowH + flatExtraH) or rowH resultRow:SetSize(resultsFrame:GetWidth() - padL * 2 - scrollInset, entryRowH) resultRow:ClearAllPoints() resultRow:SetPoint("TOPLEFT", resultsFrame.scrollChild, "TOPLEFT", padL, -yOffset) @@ -5474,7 +5560,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) newType, newKey, newVal = "macro", "macrotext", "/cancelform [form]" elseif data and data.outfitID then newType, newKey, newVal = "action", "action", 0 - elseif data and data.spellID then + elseif data and data.spellID and not IsSpellbookOnlyAbility(data) then newType, newKey, newVal = "spell", "spell", data.spellName or data.spellID elseif data and data.itemID and data.category == "Bag" then newType, newKey, newVal = "item", "item", data.name @@ -5573,7 +5659,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.text:SetPoint("LEFT", resultRow, "LEFT", 2, 0) resultRow.text:SetPoint("RIGHT", resultRow.pinToggle, "LEFT", -4, 0) resultRow.text:SetText(entry.name) - resultRow.text:SetFontObject(theme.pathFont) + SetScaledFont(resultRow.text, theme.pathFont) resultRow.text:SetTextColor(0.7, 0.7, 0.7, 1.0) elseif entry.isSectionHeader then -- Lightweight inline section label: centered text @@ -5731,7 +5817,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local catIconDef = GetFlatCategoryIcon(data) local leftAnchor if catIconDef then - local sz = entryRowH - 14 + local sz = entryRowH - 16 if catIconDef.atlas then resultRow.flatCatIcon:SetAtlas(catIconDef.atlas) resultRow.flatCatIcon:SetTexCoord(0, 1, 0, 1) @@ -5760,10 +5846,10 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("TOPLEFT", leftAnchor, "TOPRIGHT", 6, -5) + resultRow.text:SetPoint("BOTTOMLEFT", leftAnchor, "RIGHT", 6, stackHalfGap) resultRow.text:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) resultRow.text:SetText(entry.name) - resultRow.text:SetFontObject(theme.pathFont) + SetScaledFont(resultRow.text, theme.pathFont) if isUnearnedCurrency then resultRow.text:SetTextColor(0.5, 0.5, 0.5, 1.0) else @@ -5771,10 +5857,10 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end resultRow.pathSubtext:ClearAllPoints() - resultRow.pathSubtext:SetPoint("TOPLEFT", resultRow.text, "BOTTOMLEFT", 0, -1) + resultRow.pathSubtext:SetPoint("TOPLEFT", resultRow.text, "BOTTOMLEFT", 0, -stackGap) resultRow.pathSubtext:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) resultRow.pathSubtext:SetText(GetFlatSubtext(data)) - resultRow.pathSubtext:SetFontObject(theme.leafFont) + SetScaledFont(resultRow.pathSubtext, theme.leafFont) resultRow.pathSubtext:SetTextColor(0.55, 0.55, 0.55, 1.0) resultRow.pathSubtext:Show() else @@ -5792,7 +5878,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- Style: path nodes vs leaf results, themed if entry.isPathNode then - resultRow.text:SetFontObject(theme.pathFont) + SetScaledFont(resultRow.text, theme.pathFont) if entry.isMatch then resultRow.text:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) -- gold for matches else @@ -5800,13 +5886,13 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end elseif isUnearnedCurrency then -- Gray out unearned currencies - resultRow.text:SetFontObject(theme.leafFont) + SetScaledFont(resultRow.text, theme.leafFont) resultRow.text:SetTextColor(0.5, 0.5, 0.5, 1.0) elseif entry.isMatch then - resultRow.text:SetFontObject(theme.leafFont) + SetScaledFont(resultRow.text, theme.leafFont) resultRow.text:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) -- gold for matches else - resultRow.text:SetFontObject(theme.leafFont) + SetScaledFont(resultRow.text, theme.leafFont) resultRow.text:SetTextColor(unpack(theme.leafColor)) end end @@ -5920,7 +6006,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local leftAnchor local catIconDef = FLAT_CATEGORY_ICONS.currency if catIconDef and resultRow.flatCatIcon then - local sz = entry.isFlat and (entryRowH - 14) or rowIconSize + local sz = entry.isFlat and (entryRowH - 16) or rowIconSize if catIconDef.atlas then resultRow.flatCatIcon:SetAtlas(catIconDef.atlas) else @@ -6471,11 +6557,12 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- Lazily cache the option list so subsequent renders -- can translate raw values (often opaque ints) into -- the localized label the dropdown actually shows. - if data.settingType == "dropdown" and data.settingOptions == nil + if data.settingType == "dropdown" and not data.settingOptions and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetOptionsForVariable then - data.settingOptions = ns.BlizzOptionsSearch.GetOptionsForVariable(data.settingVariable) or false + local opts = ns.BlizzOptionsSearch.GetOptionsForVariable(data.settingVariable) + if opts then data.settingOptions = opts end end - if data.settingOptions and rawVal ~= nil then + if type(data.settingOptions) == "table" and rawVal ~= nil then for oi = 1, #data.settingOptions do local o = data.settingOptions[oi] if o.value == rawVal or tostring(o.value) == tostring(rawVal) then @@ -6512,13 +6599,13 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) or d.outfitID or d.transmogSetID or d.category == "Currency" or (d.itemID and d.category == "Loot")) if rightSideIcon then - local rightSize = entryRowH - 18 + local rightSize = entryRowH - 20 if rightSize < (theme.iconSize or 16) then rightSize = theme.iconSize or 16 end resultRow.icon:SetSize(rightSize, rightSize) else - local flatIconSize = entryRowH - 14 + local flatIconSize = entryRowH - 16 resultRow.icon:SetSize(flatIconSize, flatIconSize) end end @@ -6569,10 +6656,10 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.text:ClearAllPoints() if leftAnchor then - resultRow.text:SetPoint("TOPLEFT", leftAnchor, "TOPRIGHT", 6, -7) + resultRow.text:SetPoint("BOTTOMLEFT", leftAnchor, "RIGHT", 6, stackHalfGap) else local flatIndent = depth * indPx + 4 - resultRow.text:SetPoint("TOPLEFT", resultRow, "TOPLEFT", flatIndent, -7) + resultRow.text:SetPoint("BOTTOMLEFT", resultRow, "LEFT", flatIndent, stackHalfGap) end if rightAnchor == resultRow then resultRow.text:SetPoint("RIGHT", resultRow, "RIGHT", rightOffset, 0) @@ -6581,7 +6668,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end resultRow.pathSubtext:ClearAllPoints() - resultRow.pathSubtext:SetPoint("TOPLEFT", resultRow.text, "BOTTOMLEFT", 0, -1) + resultRow.pathSubtext:SetPoint("TOPLEFT", resultRow.text, "BOTTOMLEFT", 0, -stackGap) if rightAnchor == resultRow then resultRow.pathSubtext:SetPoint("RIGHT", resultRow, "RIGHT", rightOffset, 0) else @@ -6619,7 +6706,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if minH > actualH then actualH = minH resultRow:SetHeight(actualH) - if resultRow.headerTab:IsShown() then + if resultRow.headerTab and resultRow.headerTab:IsShown() then resultRow.headerTab:SetHeight(actualH) end -- Reposition tree connectors for taller row @@ -6639,7 +6726,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- Off-spec abilities: desaturate the icon and dim the text -- to match the spellbook's greyed-out treatment for spells -- that belong to a non-active spec line (offSpecID > 0). - if data and data.isOffSpec then + if IsSpellbookOnlyAbility(data) then if resultRow.icon then resultRow.icon:SetVertexColor(0.4, 0.4, 0.4, 1.0) end @@ -6722,14 +6809,14 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end -- Size the results frame and scroll child - resultsFrame:SetHeight(padT + theme.resultsPadBot + visibleHeight) + resultsFrame:SetHeight(padT + padB + visibleHeight) resultsFrame.scrollChild:SetWidth(resultsFrame:GetWidth() - scrollInset) resultsFrame.scrollChild:SetHeight(totalContentHeight) -- Position scroll frame inside results frame (accounting for padding) resultsFrame.scrollFrame:ClearAllPoints() resultsFrame.scrollFrame:SetPoint("TOPLEFT", resultsFrame, "TOPLEFT", 0, -padT) - resultsFrame.scrollFrame:SetPoint("BOTTOMRIGHT", resultsFrame, "BOTTOMRIGHT", 0, theme.resultsPadBot) + resultsFrame.scrollFrame:SetPoint("BOTTOMRIGHT", resultsFrame, "BOTTOMRIGHT", 0, padB) -- Reset scroll position on new search (preserve on expand/collapse toggle) if not preserveScroll then @@ -6854,27 +6941,15 @@ end function UI:ToggleSettingCheckbox(data) if not data or not data.settingVariable then return end local var = data.settingVariable - local toggled = false - if Settings and Settings.GetSetting then - local sok, settObj = pcall(Settings.GetSetting, var) - if sok and settObj and settObj.GetValue and settObj.SetValue then - local vok, curVal = pcall(settObj.GetValue, settObj) - if vok then - if type(curVal) == "boolean" then - pcall(settObj.SetValue, settObj, not curVal) - toggled = true - elseif curVal == "1" or curVal == "0" then - pcall(settObj.SetValue, settObj, curVal == "1" and "0" or "1") - toggled = true - end - end - end - end - if not toggled and GetCVar then - local cur = GetCVar(var) - if cur and SetCVar then - SetCVar(var, cur == "1" and "0" or "1") - end + local curVal = ReadSettingVariable(var) + if type(curVal) == "boolean" then + WriteSettingVariable(var, not curVal) + elseif curVal == "1" or curVal == "0" then + WriteSettingVariable(var, curVal == "1" and "0" or "1") + elseif curVal == "true" or curVal == "false" then + WriteSettingVariable(var, curVal == "true" and "false" or "true") + elseif curVal == 1 or curVal == 0 then + WriteSettingVariable(var, curVal == 1 and 0 or 1) end -- Refresh the row so the checkbox state updates without closing -- the search panel. Keeps focus on the editbox so the user can @@ -6900,17 +6975,7 @@ function UI:CycleSettingDropdown(data) end if not opts or #opts == 0 then return false end - local settObj - if Settings and Settings.GetSetting then - local sok, s = pcall(Settings.GetSetting, var) - if sok then settObj = s end - end - local curVal - if settObj and settObj.GetValue then - local vok, v = pcall(settObj.GetValue, settObj) - if vok then curVal = v end - end - if curVal == nil and GetCVar then curVal = GetCVar(var) end + local curVal = ReadSettingVariable(var) local curIdx for i = 1, #opts do @@ -6923,13 +6988,7 @@ function UI:CycleSettingDropdown(data) local nextIdx = (curIdx or 0) % #opts + 1 local nextVal = opts[nextIdx].value - local applied = false - if settObj and settObj.SetValue then - applied = pcall(settObj.SetValue, settObj, nextVal) - end - if not applied and SetCVar then - pcall(SetCVar, var, tostring(nextVal)) - end + if not WriteSettingVariable(var, nextVal) then return false end self:RefreshResults() if searchFrame and searchFrame.editBox @@ -7013,6 +7072,25 @@ function UI:HideResults() if resultsFrame.truncSeparator then resultsFrame.truncSeparator:Hide() end + cachedHierarchical = nil + self._lastRenderSig = nil + for i = 1, #resultButtons do + local row = resultButtons[i] + if row then + row.data = nil + row:Hide() + if row.icon then + row.icon.mountID = nil + row.icon.toyItemID = nil + row.icon.petID = nil + row.icon.spellID = nil + row.icon.outfitID = nil + row.icon.heirloomItemID = nil + row.icon.bagItemID = nil + row.icon.lootItemID = nil + end + end + end selectedIndex = 0 toggleFocused = false self:UpdateSelectionHighlight(true) @@ -7071,6 +7149,7 @@ function UI:SelectFirstResult() -- Only select if results are visible and there's actual data local first = resultButtons[1] if resultsFrame:IsShown() and first and first:IsShown() and first.data then + if ActivateSettingResult(first.data) then return end self:SelectResult(first.data) end end @@ -7207,15 +7286,12 @@ function UI:UpdateSelectionHighlight(skipRefocus) end end - -- Bind Enter to the selected result button for any secure-action row - -- (spell/toy/macro/action/item) so the secure dispatch fires on keypress - -- the same as a mouse click. Without this, Enter on an ability would - -- fall through ActivateSelected → SelectResult, which intentionally - -- no-ops for spell/toy data because casting is protected. + -- Secure rows need Enter bound to the row button so protected actions fire. if not InCombatLockdown() then local selRow = selectedIndex > 0 and resultButtons[selectedIndex] local rd = selRow and selRow.data - local secureRow = rd and (rd.outfitID or rd.toyItemID or rd.spellID + local secureRow = rd and (rd.outfitID or rd.toyItemID + or (rd.spellID and not IsSpellbookOnlyAbility(rd)) or rd.mountID or rd.macroIndex or (rd.itemID and rd.category == "Bag")) if secureRow then @@ -7255,6 +7331,7 @@ function UI:ActivateSelected() self:UpdateSelectionHighlight() end elseif resultRow.data then + if ActivateSettingResult(resultRow.data) then return end self:SelectResult(resultRow.data) end return @@ -7415,6 +7492,612 @@ function UI:ApplyTransmogBrowseMode() end end +local function ExtractSlot(value) + return value.slotIndex + or value.spellBookIndex + or value.spellBookItemSlotIndex + or value.spellBookItemIndex + or value.itemIndex + or value.slot + or value.index +end + +local function ExtractBank(value) + return value.spellBank + or value.spellBookBank + or value.spellBookItemBank + or value.bank +end + +local function ExtractSpecID(value) + local specID = value.specID + if specID ~= nil and specID ~= 0 then return specID end + if value.spellBookSpecID ~= nil then return value.spellBookSpecID end + local offSpecID = value.offSpecID + if offSpecID ~= nil and offSpecID ~= 0 then return offSpecID end + return specID +end + +local function HasSlotMismatch(value, targetSlot, targetBank) + if not targetSlot then return false end + local valueSlot = ExtractSlot(value) + if not valueSlot then return false end + if valueSlot ~= targetSlot then return true end + if not targetBank then return false end + local valueBank = ExtractBank(value) + if valueBank ~= nil and valueBank ~= targetBank then return true end + return false +end + +local function HasSpecMismatch(value, targetSpecID) + if targetSpecID == nil then return false end + local valueSpecID = ExtractSpecID(value) + return valueSpecID ~= nil and valueSpecID ~= targetSpecID +end + +local function SpellRecordMatches(value, target, seen) + if not value then return false end + local vt = type(value) + if vt == "number" then + if target.spellBookSpecID ~= nil or target.spellBookIndex then return false end + return value == target.spellID or value == target.spellBookSpellID + end + if vt ~= "table" then return false end + seen = seen or {} + if seen[value] then return false end + seen[value] = true + + local targetSpell = target.spellBookSpellID or target.spellID + local targetSlot = target.spellBookIndex + local targetBank = target.spellBookBank + local targetSpecID = target.spellBookSpecID + + if targetSpecID ~= nil then + local specID = ExtractSpecID(value) + if specID and specID == targetSpecID then + local slot = ExtractSlot(value) + if slot and targetSlot and slot == targetSlot then return true end + if targetSpell and (value.spellID == targetSpell + or value.actionID == targetSpell) then return true end + end + end + + if targetSlot then + local bank = ExtractBank(value) + local slot = ExtractSlot(value) + if slot == targetSlot + and (not targetBank or bank == nil or bank == targetBank) + and not HasSpecMismatch(value, targetSpecID) then + return true + end + end + + -- When the target carries disambiguation hints (specID or slot), + -- spellID/name matches REQUIRE the value to carry a matching + -- specID or slot. Without that, a sibling row with no slot/spec + -- exposed (e.g. SpellBookItemInfo returned by a Button method) + -- would match by spellID alone and the wrong "Wrath" wins. + local needsDisambig = target.spellBookSpecID ~= nil + or target.spellBookIndex ~= nil + local function valueHasMatchingDisambig() + local valueSpec = ExtractSpecID(value) + if targetSpecID and valueSpec and valueSpec == targetSpecID then + return true + end + local valueSlot = ExtractSlot(value) + if targetSlot and valueSlot and valueSlot == targetSlot then + return true + end + return false + end + + if targetSpell and (value.spellID == targetSpell + or value.spellId == targetSpell + or value.actionID == targetSpell + or value.actionId == targetSpell + or value.id == targetSpell) then + if not HasSpecMismatch(value, targetSpecID) + and not HasSlotMismatch(value, targetSlot, targetBank) + and (not needsDisambig or valueHasMatchingDisambig()) then + return true + end + end + + local targetName = target.nameLower or (target.spellName and slower(target.spellName)) + if targetName and value.name and slower(value.name) == targetName then + if not HasSpecMismatch(value, targetSpecID) + and not HasSlotMismatch(value, targetSlot, targetBank) + and (not needsDisambig or valueHasMatchingDisambig()) then + return true + end + end + + if value.data and value.data ~= value and SpellRecordMatches(value.data, target, seen) then return true end + if value.elementData and value.elementData ~= value and SpellRecordMatches(value.elementData, target, seen) then return true end + if value.spellInfo and value.spellInfo ~= value and SpellRecordMatches(value.spellInfo, target, seen) then return true end + if value.spellBookItemInfo and value.spellBookItemInfo ~= value and SpellRecordMatches(value.spellBookItemInfo, target, seen) then return true end + if value.spellBookItemData and value.spellBookItemData ~= value and SpellRecordMatches(value.spellBookItemData, target, seen) then return true end + if value.itemInfo and value.itemInfo ~= value and SpellRecordMatches(value.itemInfo, target, seen) then return true end + return false +end + +local function SpellFrameMatchesSelf(frame, target) + if not frame then return false end + + local targetSlot = target.spellBookIndex + local targetBank = target.spellBookBank + if targetSlot and frame.GetItemSlotIndex then + local sok, slot = pcall(frame.GetItemSlotIndex, frame) + if sok and slot == targetSlot then + if not targetBank or not frame.GetSpellBookItemBank then + return true + end + local bok, bank = pcall(frame.GetSpellBookItemBank, frame) + if not bok or bank == nil or bank == targetBank then + return true + end + end + end + + if frame.GetElementData then + local ok, data = pcall(frame.GetElementData, frame) + if ok and SpellRecordMatches(data, target) then return true end + end + if frame.GetSpellID then + local ok, id = pcall(frame.GetSpellID, frame) + if ok and SpellRecordMatches(id, target) then return true end + end + if frame.GetSpellBookItemInfo then + local ok, info = pcall(frame.GetSpellBookItemInfo, frame) + if ok and SpellRecordMatches(info, target) then return true end + end + if SpellRecordMatches(frame.data, target) then return true end + if SpellRecordMatches(frame.elementData, target) then return true end + if SpellRecordMatches(frame.spellInfo, target) then return true end + if SpellRecordMatches(frame.spellBookItemInfo, target) then return true end + if SpellRecordMatches(frame.spellBookItemData, target) then return true end + return false +end + +local function SpellFrameMatches(frame, target) + if SpellFrameMatchesSelf(frame, target) then return true end + if frame and frame.GetParent then + local parent = frame:GetParent() + if parent and SpellFrameMatchesSelf(parent, target) then return true end + if parent and parent.GetParent then + local gp = parent:GetParent() + if gp and SpellFrameMatchesSelf(gp, target) then return true end + end + end + return false +end + +local function SpellFrameTextMatches(frame, target) + local targetName = target and (target.nameLower or (target.spellName and slower(target.spellName))) + if not targetName then return false end + local text = GetButtonText(frame) + return text and slower(text) == targetName +end + +local function GetTabSkillLineName(tab) + if not tab then return nil end + if type(tab.tabText) == "string" and tab.tabText ~= "" then + return tab.tabText + end + if tab.skillLineInfo and type(tab.skillLineInfo.name) == "string" then + return tab.skillLineInfo.name + end + if type(tab.skillLine) == "table" and type(tab.skillLine.name) == "string" then + return tab.skillLine.name + end + if tab.elementData and type(tab.elementData.name) == "string" then + return tab.elementData.name + end + if tab.GetElementData then + local ok, ed = pcall(tab.GetElementData, tab) + if ok and ed and type(ed.name) == "string" then return ed.name end + end + if tab.tooltipText and type(tab.tooltipText) == "string" then + return tab.tooltipText + end + if tab.GetText then + local ok, txt = pcall(tab.GetText, tab) + if ok and type(txt) == "string" and txt ~= "" then return txt end + end + local btnText = GetButtonText(tab) + if btnText and btnText ~= "" then return btnText end + return nil +end + +local function FindSpellbookCategoryTab(frame, data) + local book = frame and frame.SpellBookFrame + local tabSystem = book and book.CategoryTabSystem + if not tabSystem or not tabSystem.GetChildren then return nil end + + local targetName = data and data.spellBookCategoryName + local targetLower = targetName and slower(targetName) + local targetIsGeneral = targetLower == slower(_G.GENERAL or "general") + + local seen = {} + local candidates = {} + local function add(tab) + if tab and not seen[tab] and tab.Click then + seen[tab] = true + candidates[#candidates + 1] = tab + end + end + if tabSystem.tabs then + for _, tab in ipairs(tabSystem.tabs) do add(tab) end + end + local children = { tabSystem:GetChildren() } + for i = 1, #children do + local child = children[i] + if child and child:IsShown() then add(child) end + end + + local generalLower = slower(_G.GENERAL or "general") + local function isGeneralTab(tab) + local name = GetTabSkillLineName(tab) + if name and slower(name) == generalLower then return true end + local text = GetButtonText(tab) + if text and slower(text) == generalLower then return true end + return false + end + + if targetIsGeneral then + for i = 1, #candidates do + if isGeneralTab(candidates[i]) then return candidates[i] end + end + else + local classLower = UnitClass and slower(UnitClass("player") or "") or "" + for i = 1, #candidates do + local name = GetTabSkillLineName(candidates[i]) + if name and classLower ~= "" and slower(name) == classLower then + return candidates[i] + end + end + for i = 1, #candidates do + if not isGeneralTab(candidates[i]) then return candidates[i] end + end + end + + return nil +end + +local function GetSpellbookPagedFrame(frame) + local book = frame and frame.SpellBookFrame + return book and (book.PagedSpellsFrame or book) or nil +end + +local function SpellbookPageButton(frame, key) + local paged = GetSpellbookPagedFrame(frame) + local controls = paged and paged.PagingControls + return controls and controls[key] or nil +end + +local function CanClickButton(btn) + if not btn or not btn:IsShown() then return false end + if btn.IsEnabled then + local ok, enabled = pcall(btn.IsEnabled, btn) + if ok then return enabled end + end + return btn.Click ~= nil +end + +local function ClickSpellbookPage(frame, key) + local btn = SpellbookPageButton(frame, key) + if not CanClickButton(btn) then return false end + return ClickButton(btn) +end + +local function RewindSpellbookToFirstPage(frame) + for _ = 1, 12 do + if not ClickSpellbookPage(frame, "PrevPageButton") then break end + end +end + +local function GetSpellbookDataProvider(paged) + if not paged then return nil, nil end + local function probe(frame) + if not frame then return nil end + if frame.GetDataProvider then + local ok, dp = pcall(frame.GetDataProvider, frame) + if ok and dp then return dp end + end + return nil + end + local dp = probe(paged) + if dp then return dp, paged end + if paged.GetChildren then + for _, child in ipairs({ paged:GetChildren() }) do + local cdp = probe(child) + if cdp then return cdp, child end + if child and child.GetChildren then + for _, gc in ipairs({ child:GetChildren() }) do + local gdp = probe(gc) + if gdp then return gdp, gc end + end + end + end + end + return nil, nil +end + +local function FindSpellElementInSection(paged, data) + local dp = GetSpellbookDataProvider(paged) + if not dp then return nil end + + local size = dp.GetSize and dp:GetSize() or 0 + if size == 0 and not dp.Enumerate then return nil end + + local targetSpellID = data.spellBookSpellID or data.spellID + local targetSlot = data.spellBookIndex + local targetBank = data.spellBookBank + local targetSpecID = data.spellBookSpecID + local targetNameLower = data.nameLower + or (data.spellName and slower(data.spellName)) + or (data.name and slower(data.name)) + local targetSectionLower = data.spellBookCategoryName + and slower(data.spellBookCategoryName) + + local currentSection + local fallback + local function inspect(elem) + if elem then + local info = elem.spellBookItemInfo or elem.spellInfo or elem.spellBookItemData + local isSpellElement = info or elem.spellID or elem.actionID or ExtractSlot(elem) + if not isSpellElement then + local hdr = elem.name or elem.title or elem.text or elem.header + if type(hdr) == "string" and hdr ~= "" then + currentSection = slower(hdr) + end + else + info = info or elem + local elemSlot = ExtractSlot(elem) or ExtractSlot(info) + local elemBank = ExtractBank(elem) or ExtractBank(info) + local elemSpecID = ExtractSpecID(elem) or ExtractSpecID(info) + local specOK = targetSpecID == nil or elemSpecID == nil + or elemSpecID == targetSpecID + local match = false + if specOK and targetSlot and elemSlot == targetSlot + and (not targetBank or elemBank == nil or elemBank == targetBank) then + match = true + elseif specOK and targetSpellID + and not HasSlotMismatch(elem, targetSlot, targetBank) + and not HasSlotMismatch(info, targetSlot, targetBank) + and (elem.spellID == targetSpellID + or elem.actionID == targetSpellID + or info.spellID == targetSpellID + or info.actionID == targetSpellID) then + match = true + elseif specOK and targetNameLower and info.name + and not HasSlotMismatch(elem, targetSlot, targetBank) + and not HasSlotMismatch(info, targetSlot, targetBank) + and slower(info.name) == targetNameLower then + match = true + end + if match then + if targetSectionLower and currentSection == targetSectionLower then + return elem + end + fallback = fallback or elem + end + end + end + return nil + end + + if dp.Enumerate then + for a, b in dp:Enumerate() do + local found = inspect(b or a) + if found then return found end + end + else + for i = 1, size do + local elem = dp.Find and dp:Find(i) + local found = inspect(elem) + if found then return found end + end + end + return fallback +end + +local function ScrollSpellbookToElement(paged, elem) + if not elem then return false end + local _, host = GetSpellbookDataProvider(paged) + if host and host.ScrollToElementData then + local alignCenter = ScrollBoxConstants and ScrollBoxConstants.AlignCenter + pcall(host.ScrollToElementData, host, elem, alignCenter) + return true + end + return false +end + +local function FindVisibleButtonForElement(paged, elem) + if not elem then return nil end + local _, host = GetSpellbookDataProvider(paged) + if host and host.EnumerateFrames then + for _, f in host:EnumerateFrames() do + if f and f:IsShown() and f.GetElementData then + local ok, ed = pcall(f.GetElementData, f) + if ok and ed == elem then return f end + end + end + end + return nil +end + +local function HideHighlightOnHover(frame) + if not frame or frame._efHideHighlightOnHover or not frame.HookScript then return end + frame._efHideHighlightOnHover = true + frame:HookScript("OnEnter", function() + local highlight = ns.Highlight + if highlight and highlight.HideHighlight then + highlight:HideHighlight() + end + end) + if frame.IsMouseOver and frame:IsMouseOver() then + local highlight = ns.Highlight + if highlight and highlight.HideHighlight then + highlight:HideHighlight() + end + end +end + +local function FindSpellbookButton(root, target, scroll, candidate) + if not root then return nil end + local nextCandidate = candidate + if root.Click then + nextCandidate = root + elseif root.GetScript then + local ok, onClick = pcall(root.GetScript, root, "OnClick") + if ok and onClick then nextCandidate = root end + end + if root.EnumerateFrames and root.GetDataProvider then + if scroll then + Utils.ScrollBoxScrollTo(root, function(data) + return SpellRecordMatches(data, target) + end) + end + local hasID = target and (target.spellBookSpellID or target.spellID) + local btn = Utils.ScrollBoxFindButton(root, function(frame) + if SpellFrameMatches(frame, target) then return true end + if not hasID and SpellFrameTextMatches(frame, target) then return true end + return false + end) + if btn then return btn end + end + local hasID = target and (target.spellBookSpellID or target.spellID) + if SpellFrameMatches(root, target) then + return nextCandidate or root + end + if not hasID and SpellFrameTextMatches(root, target) then + return nextCandidate or root + end + if root.GetChildren then + for _, child in ipairs({ root:GetChildren() }) do + if child and child:IsShown() then + local found = FindSpellbookButton(child, target, scroll, nextCandidate) + if found then return found end + end + end + end + return nil +end + +function UI:OpenAbilityInSpellbook(data) + local highlight = ns.Highlight + local categoryClicked = false + local rewound = false + local triedElementScroll = false + local targetElement + local pagesAdvanced = 0 + local MAX_PAGES = 20 + + local function openFrame() + local frame = _G["PlayerSpellsFrame"] + if frame and frame:IsShown() then + if highlight and highlight.IsTabSelected + and not highlight:IsTabSelected("PlayerSpellsFrame", 3) then + local tab = highlight.GetTabButton + and highlight:GetTabButton("PlayerSpellsFrame", 3) + ClickButton(tab) + return true + end + return false + end + + local util = _G.PlayerSpellsUtil + if util and util.TogglePlayerSpellsFrame then + pcall(util.TogglePlayerSpellsFrame, 3) + else + ClickButton(_G["PlayerSpellsMicroButton"]) + end + return true + end + + local function reveal(attempt) + local needsRetry = openFrame() + local frame = _G["PlayerSpellsFrame"] + if needsRetry then + if attempt < 36 then + C_Timer.After(0.05, function() reveal(attempt + 1) end) + end + return + end + local root = frame and frame.SpellBookFrame + if not root or not root:IsShown() then + if attempt < 36 then + C_Timer.After(0.05, function() reveal(attempt + 1) end) + end + return + end + + if not categoryClicked then + local tab = FindSpellbookCategoryTab(frame, data) + if tab then + categoryClicked = true + rewound = false + triedElementScroll = false + targetElement = nil + pagesAdvanced = 0 + ClickButton(tab) + if attempt < 36 then + C_Timer.After(0, function() reveal(attempt + 1) end) + end + return + end + end + + local paged = GetSpellbookPagedFrame(frame) or root + + if not triedElementScroll then + triedElementScroll = true + targetElement = FindSpellElementInSection(paged, data) + if targetElement and ScrollSpellbookToElement(paged, targetElement) then + C_Timer.After(0, function() reveal(attempt + 1) end) + return + end + end + + if targetElement then + local elementBtn = FindVisibleButtonForElement(paged, targetElement) + if elementBtn and highlight then + highlight:HighlightFrame(elementBtn) + HideHighlightOnHover(elementBtn) + return + end + end + + local btn = FindSpellbookButton(paged, data, false) + if btn and highlight then + highlight:HighlightFrame(btn) + HideHighlightOnHover(btn) + return + end + + if not rewound then + RewindSpellbookToFirstPage(frame) + rewound = true + pagesAdvanced = 0 + C_Timer.After(0, function() reveal(attempt + 1) end) + return + end + + if pagesAdvanced < MAX_PAGES + and ClickSpellbookPage(frame, "NextPageButton") then + pagesAdvanced = pagesAdvanced + 1 + C_Timer.After(0, function() reveal(attempt + 1) end) + return + end + if attempt < 36 then + C_Timer.After(0.05, function() reveal(attempt + 1) end) + end + end + + C_Timer.After(0.05, function() reveal(1) end) +end + function UI:SelectResult(data, forceGuide) if not data then return end local useFast = not forceGuide @@ -7562,10 +8245,12 @@ function UI:SelectResult(data, forceGuide) -- Toy: handled by SecureActionButton on mousedown (UseToyByItemID is protected) if data.toyItemID then return end - -- Ability: cast via SecureActionButton (CastSpell is protected). The spell - -- attribute was set when the row was bound, so the click already fired the - -- cast — don't try to walk the (no-op) steps[] guide here. - if data.spellID then return end + if data.spellID then + if forceGuide or IsSpellbookOnlyAbility(data) then + self:OpenAbilityInSpellbook(data) + end + return + end -- Pet: summon/dismiss. Stored petID can go stale (released, caged, -- traded) -- look up a fresh owned GUID by speciesID first, fall diff --git a/Utils.lua b/Utils.lua index a827b2f..656a8f6 100644 --- a/Utils.lua +++ b/Utils.lua @@ -704,6 +704,20 @@ function Utils.GetButtonText(btn) return nil end +function Utils.IsFrameOrChildMouseOver(frame) + if not frame or not frame:IsShown() then return false end + if frame:IsMouseOver() then return true end + if frame.GetChildren then + for i = 1, select("#", frame:GetChildren()) do + local child = select(i, frame:GetChildren()) + if child and Utils.IsFrameOrChildMouseOver(child) then + return true + end + end + end + return false +end + -- Collects all text from a frame into a single string for fuzzy matching. function Utils.GetAllFrameText(frame) if not frame then return nil end @@ -1179,6 +1193,18 @@ function Utils.ShowCursorMenu(globalName, rows, opts) }) local bg = ns.DARK_PANEL_BG if bg then menu:SetBackdropColor(bg[1], bg[2], bg[3], bg[4]) end + local function MenuHasMouse(self) + if self:IsMouseOver() then return true end + if self.rows then + for i = 1, #self.rows do + local row = self.rows[i] + if row and row:IsShown() and row:IsMouseOver() then + return true + end + end + end + return false + end menu:SetScript("OnShow", function(self) self._showedAt = GetTime() self._outsideSince = nil @@ -1191,9 +1217,14 @@ function Utils.ShowCursorMenu(globalName, rows, opts) self._hasEntered = false self:UnregisterEvent("GLOBAL_MOUSE_DOWN") self:UnregisterEvent("GLOBAL_MOUSE_UP") + if self.rows then + for i = 1, #self.rows do + self.rows[i]:Hide() + end + end end) menu:SetScript("OnUpdate", function(self) - if self:IsMouseOver() then + if MenuHasMouse(self) then self._outsideSince = nil self._hasEntered = true return @@ -1211,7 +1242,7 @@ function Utils.ShowCursorMenu(globalName, rows, opts) menu:SetScript("OnEvent", function(self, event) if event ~= "GLOBAL_MOUSE_DOWN" and event ~= "GLOBAL_MOUSE_UP" then return end if self._showedAt and (GetTime() - self._showedAt) < (self.clickGrace or 0.05) then return end - if not self:IsMouseOver() then self:Hide() end + if not MenuHasMouse(self) then self:Hide() end end) end @@ -1231,9 +1262,9 @@ function Utils.ShowCursorMenu(globalName, rows, opts) shown = shown + 1 local row = menu.rows[shown] if not row then - row = CreateFrame("Button", nil, menu) + row = CreateFrame("Button", nil, UIParent) + row:EnableMouse(true) row:SetHeight(rowH) - row:RegisterForClicks("LeftButtonUp") row:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD") row.label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal") row.label:SetPoint("LEFT", row, "LEFT", 8, 0) @@ -1242,7 +1273,10 @@ function Utils.ShowCursorMenu(globalName, rows, opts) row.icon:SetPoint("RIGHT", row, "RIGHT", -8, 0) menu.rows[shown] = row end + row:SetParent(UIParent) + row:SetFrameStrata(menu:GetFrameStrata()) row:SetHeight(rowH) + row:SetFrameLevel(menu:GetFrameLevel() + 20 + shown) row.label:SetText(def.text or "") if def.icon then row.icon:SetTexture(def.icon) @@ -1251,10 +1285,12 @@ function Utils.ShowCursorMenu(globalName, rows, opts) row.icon:Hide() end local onClick = def.onClick - row:SetScript("OnClick", function() + row:SetScript("OnMouseDown", function(_, button) + if button ~= "LeftButton" then return end menu:Hide() if onClick then onClick() end end) + row:SetScript("OnClick", nil) row:ClearAllPoints() if shown == 1 then row:SetPoint("TOPLEFT", menu, "TOPLEFT", 4, -4) @@ -1264,12 +1300,14 @@ function Utils.ShowCursorMenu(globalName, rows, opts) row:SetPoint("TOPRIGHT", lastRow, "BOTTOMRIGHT", 0, 0) end row:Show() + if row.Raise then row:Raise() end lastRow = row end end for i = shown + 1, #menu.rows do menu.rows[i]:Hide() menu.rows[i]:SetScript("OnClick", nil) + menu.rows[i]:SetScript("OnMouseDown", nil) end menu:SetSize(width, rowH * shown + 8) From fe0292c9ce20081dd453e8a8b754961504c0cd31 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 7 May 2026 02:19:08 -0400 Subject: [PATCH 041/103] Lazy-load dynamic providers; macro/settings clicks; search fixes PLAYER_LOGIN no longer scans collections. EnsureDynamicLoaded() runs the staggered provider chain on first search-bar entry (Toggle/Focus/slash command/edit-focus). Matches the GlobalSearch and AllTheThings shape: zero scanning at login, work pulled by user action so the post-load- screen stutter goes away. Re-callable: subsequent calls only refresh providers marked dirty (UPDATE_MACROS, SPELLS_CHANGED, etc.). DatabaseDynamic: LoadCoreDynamicSearchData stubbed; new LoadDeferredSyncProvidersStaggered runs every sync provider one per frame with a single ResetSearchCache at the end. LoadHeavyDynamicSearch DataSync handles loot only (current spec); bosses lazy-load on the first "boss" keyword. Each Populate* now returns ready state so a not-ready API leaves the provider dirty for retry. Macro: ctrl+click opens MacroFrame; left-click runs the macro via the SecureActionButton macrotext attribute. PreClick clears the secure type when ctrl is held so the run path doesn't also fire. Settings: drop OnMouseDown handling; PostClick is the only writer. WriteSettingVariable / applySettingValue prefer Settings.GetSetting + setObj:SetValue (writes that actually land), then SetCVar, with an explicit bool-to-"1"/"0" conversion so CVars don't store "true". Search: pins only show when the bar is empty (so a user with 6 pins doesn't see them prepended to every unrelated query). +200 zone score boost removed in the UI bar so prefix matches like Hearthstone aren't overtaken by zone fuzzy matches like Heart of Fear. Gating-shift detection in SearchUI bypasses prevCandidates reuse when a new word flips a category gate, fixing the "haste ring" / "icc boss" needs- backspace bug. MapSearch: case-insensitive dedup of dungeon entrances so "Chamber Of Heart" and "Chamber of Heart" don't both pass. Abilities provider: flyout expansion via GetFlyoutInfo / GetFlyoutSlotInfo so Switch Flight Style, Skyriding, etc. show up. --- .luacheckrc | 1 + Core.lua | 92 ++++++++++------- Database.lua | 236 ++++++++++++++++++++++++++++++-------------- DatabaseDynamic.lua | 99 ++++++++++++++++--- MapSearch.lua | 17 +++- UI.lua | 136 ++++++++++++------------- 6 files changed, 383 insertions(+), 198 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index de4bce0..8393475 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -94,6 +94,7 @@ read_globals = { "ACCEPT", "CANCEL", "StaticPopup_Show", "C_SpellBook", "C_Container", "GetNumSpellTabs", "GetSpellTabInfo", "GetSpellBookItemInfo", "GetSpellBookItemName", "GetSpellBookItemTexture", + "GetFlyoutInfo", "GetFlyoutSlotInfo", "GetContainerNumSlots", "GetContainerItemInfo", "PickupContainerItem", "NUM_BAG_SLOTS", "GetNumMacros", "GetMacroInfo", "MAX_ACCOUNT_MACROS", "ShowMacroFrame", "MacroFrame", "MacroFrame_SelectMacro", "MacroFrame_Update", diff --git a/Core.lua b/Core.lua index a63d818..f23da27 100644 --- a/Core.lua +++ b/Core.lua @@ -301,6 +301,7 @@ local function OnInitialize() if msg == "o" or msg == "options" or msg == "config" or msg == "settings" then EasyFind:OpenOptions() elseif msg == "toggle" or msg == "t" then + EasyFind:EnsureDynamicLoaded() if ns.UI then ns.UI:Toggle() end elseif msg == "c" or msg == "clear" then EasyFind:ClearAll() @@ -443,45 +444,62 @@ local function InstallTransmogClassFilterHook() end) end -local function OnPlayerLogin() - SafeAfter(0, function() - local function SafeInit(mod, name) - if not mod then return end - local ok, err = xpcall(mod.Initialize, ErrorHandler, mod) - if not ok then - EasyFind:Print("|cffff4444" .. name .. " failed to initialize: " .. tostring(err) .. "|r") - end - end - if EasyFind.db.enableMapSearch ~= false then - SafeInit(ns.MapSearch, "MapSearch") - end - if EasyFind.db.enableUISearch ~= false and ns.Database and ns.Database.LoadCoreDynamicSearchData then - local ok, err = xpcall(ns.Database.LoadCoreDynamicSearchData, ErrorHandler, ns.Database) - if not ok then - EasyFind:Print("|cffff4444Search data failed to initialize: " .. tostring(err) .. "|r") - end +-- Lazy dynamic load: pulled the moment the user opens the search bar (or any +-- entry point that surfaces dynamic results). Other search-style addons follow +-- the same shape: nothing is scanned at PLAYER_LOGIN, so the player never sees +-- a post-load-screen stutter from us. +-- Calling repeatedly is safe: LoadDeferredSyncProvidersStaggered skips +-- providers that are loaded-and-clean, so re-entry only refreshes dirty ones. +function EasyFind:EnsureDynamicLoaded() + if not ns.Database then return end + if EasyFind.db.enableUISearch == false then return end + if ns.Database.LoadDeferredSyncProvidersStaggered then + ns.Database:LoadDeferredSyncProvidersStaggered() + end + if not self._dynamicLoadTriggered then + self._dynamicLoadTriggered = true + if ns.Database.LoadHeavyDynamicSearchDataSync then + SafeAfter(0.5, function() + if EasyFind.db.enableUISearch == false then return end + ns.Database:LoadHeavyDynamicSearchDataSync() + end) end - if EasyFind.db.enableUISearch ~= false then - SafeInit(ns.UI, "UI") - SafeInit(ns.Highlight, "Highlight") - if ns.Database and ns.Database.WarmSearchHotPath then - SafeAfter(0, function() ns.Database:WarmSearchHotPath() end) - end - if ns.UI and ns.UI.WarmSearchHotPath then - SafeAfter(0.05, function() ns.UI:WarmSearchHotPath() end) + -- Late-arriving APIs (Wardrobe, Heirlooms) sometimes aren't ready in + -- the first pass. Re-trigger after they've had time to populate. + SafeAfter(3.0, function() + if EasyFind.db.enableUISearch == false then return end + if ns.Database.LoadDeferredSyncProvidersStaggered then + ns.Database:LoadDeferredSyncProvidersStaggered() end + end) + end +end + +local function OnPlayerLogin() + local function SafeInit(mod, name) + if not mod then return end + local ok, err = xpcall(mod.Initialize, ErrorHandler, mod) + if not ok then + EasyFind:Print("|cffff4444" .. name .. " failed to initialize: " .. tostring(err) .. "|r") end - if EasyFind.db.enableMapSearch ~= false and ns.MapSearch and ns.MapSearch.WarmUISearchCaches then - SafeAfter(0.10, function() ns.MapSearch:WarmUISearchCaches() end) - end - if ns.Options and ns.Options.RegisterWithBlizzardOptions then - local ok, err = xpcall(ns.Options.RegisterWithBlizzardOptions, ErrorHandler, ns.Options) - if not ok then - EasyFind:Print("|cffff4444Options registration failed: " .. tostring(err) .. "|r") - end + end + if EasyFind.db.enableMapSearch ~= false then + SafeInit(ns.MapSearch, "MapSearch") + end + if EasyFind.db.enableUISearch ~= false then + SafeInit(ns.UI, "UI") + SafeInit(ns.Highlight, "Highlight") + end + if EasyFind.db.enableMapSearch ~= false and ns.MapSearch and ns.MapSearch.WarmUISearchCaches then + ns.MapSearch:WarmUISearchCaches() + end + if ns.Options and ns.Options.RegisterWithBlizzardOptions then + local ok, err = xpcall(ns.Options.RegisterWithBlizzardOptions, ErrorHandler, ns.Options) + if not ok then + EasyFind:Print("|cffff4444Options registration failed: " .. tostring(err) .. "|r") end - InstallTransmogClassFilterHook() - end) + end + InstallTransmogClassFilterHook() -- Minimap button (delayed slightly so Minimap frame is ready) SafeAfter(0.6, function() @@ -581,14 +599,17 @@ eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2) end) function EasyFind:ToggleSearchUI() + self:EnsureDynamicLoaded() if ns.UI then ns.UI:Toggle() end end function EasyFind:FocusSearchUI() + self:EnsureDynamicLoaded() if ns.UI then ns.UI:Focus() end end function EasyFind:ToggleFocusSearchUI() + self:EnsureDynamicLoaded() if WorldMapFrame and WorldMapFrame:IsShown() and ns.MapTab then ns.MapTab:Focus() elseif ns.UI then @@ -597,6 +618,7 @@ function EasyFind:ToggleFocusSearchUI() end function EasyFind:FocusMapSearch() + self:EnsureDynamicLoaded() if ns.MapTab then ns.MapTab:Focus() end end diff --git a/Database.lua b/Database.lua index d644dd8..3b16990 100644 --- a/Database.lua +++ b/Database.lua @@ -95,7 +95,7 @@ end -- Called after PLAYER_LOGIN when C_CurrencyInfo is available -- Scans the WoW currency list and injects any currencies not already in the static database function Database:PopulateDynamicCurrencies() - if not C_CurrencyInfo or not C_CurrencyInfo.GetCurrencyListSize then return end + if not C_CurrencyInfo or not C_CurrencyInfo.GetCurrencyListSize then return false end RemoveEntriesByCategory("Currency") wipe(knownCurrencyIDs) @@ -282,12 +282,13 @@ function Database:PopulateDynamicCurrencies() end if not didCollapse then break end end + return true end -- Called after PLAYER_LOGIN when C_Reputation is available -- Scans the WoW reputation list and injects factions as searchable entries function Database:PopulateDynamicReputations() - if not C_Reputation or not C_Reputation.GetNumFactions then return end + if not C_Reputation or not C_Reputation.GetNumFactions then return false end RemoveEntriesByCategory("Reputation") @@ -469,6 +470,7 @@ function Database:PopulateDynamicReputations() end if not didCollapse then break end end + return true end -- Called after PLAYER_LOGIN when C_MountJournal is available @@ -907,12 +909,12 @@ end local outfitEntries = {} -- track injected entries for re-population function Database:PopulateDynamicMounts() - if not C_MountJournal or not C_MountJournal.GetMountIDs then return end + if not C_MountJournal or not C_MountJournal.GetMountIDs then return false end RemoveEntriesByCategory("Mount") local mountIDs = C_MountJournal.GetMountIDs() - if not mountIDs then return end + if not mountIDs then return false end for _, mountID in ipairs(mountIDs) do local name, spellID, icon, _, _, _, _, @@ -927,19 +929,20 @@ function Database:PopulateDynamicMounts() }, MOUNT_MT) end end + return true end -- Called after PLAYER_LOGIN when C_ToyBox is available -- Scans the player's collected toys and injects them into the search database function Database:PopulateDynamicToys() - if not C_ToyBox then return end + if not C_ToyBox then return false end RemoveEntriesByCategory("Toy") local GetToyInfo = C_ToyBox.GetToyInfo local GetNumFilteredToys = C_ToyBox.GetNumFilteredToys local GetToyFromIndex = C_ToyBox.GetToyFromIndex - if not GetToyInfo or not GetNumFilteredToys or not GetToyFromIndex then return end + if not GetToyInfo or not GetNumFilteredToys or not GetToyFromIndex then return false end -- Save current filter state, set to show all collected toys only local hasFilterAPI = C_ToyBox.GetCollectedShown and C_ToyBox.SetCollectedShown @@ -975,12 +978,13 @@ function Database:PopulateDynamicToys() if C_ToyBox.SetUncollectedShown then C_ToyBox.SetUncollectedShown(savedUncollected) end if C_ToyBox.SetFilterString then C_ToyBox.SetFilterString(savedString) end if C_ToyBox.ForceToyRefilter then C_ToyBox.ForceToyRefilter() end + return true end -- Called after PLAYER_LOGIN when C_PetJournal is available -- Scans the player's collected pets and injects them into the search database function Database:PopulateDynamicPets() - if not C_PetJournal or not C_PetJournal.GetNumPets then return end + if not C_PetJournal or not C_PetJournal.GetNumPets then return false end RemoveEntriesByCategory("Pet") @@ -999,7 +1003,14 @@ function Database:PopulateDynamicPets() if C_PetJournal.SetSearchFilter then C_PetJournal.SetSearchFilter("") end local numPets = C_PetJournal.GetNumPets() - if not numPets then return end + if not numPets then + if C_PetJournal.SetFilterChecked then + if savedCollected ~= nil then C_PetJournal.SetFilterChecked(LE_PET_JOURNAL_FILTER_COLLECTED, savedCollected) end + if savedNotCollected ~= nil then C_PetJournal.SetFilterChecked(LE_PET_JOURNAL_FILTER_NOT_COLLECTED, savedNotCollected) end + end + if C_PetJournal.SetSearchFilter then C_PetJournal.SetSearchFilter(savedString) end + return false + end local seen = {} for i = 1, numPets do @@ -1023,6 +1034,7 @@ function Database:PopulateDynamicPets() if savedNotCollected ~= nil then C_PetJournal.SetFilterChecked(LE_PET_JOURNAL_FILTER_NOT_COLLECTED, savedNotCollected) end end if C_PetJournal.SetSearchFilter then C_PetJournal.SetSearchFilter(savedString) end + return true end -- Called after PLAYER_LOGIN when C_Heirloom is available. @@ -1030,16 +1042,16 @@ end -- search database. Click handler creates the heirloom item in the -- player's bags via C_Heirloom.CreateHeirloom. function Database:PopulateDynamicHeirlooms() - if not C_Heirloom or not C_Heirloom.GetHeirloomItemIDs then return end + if not C_Heirloom or not C_Heirloom.GetHeirloomItemIDs then return false end RemoveEntriesByCategory("Heirloom") local ids = C_Heirloom.GetHeirloomItemIDs() - if type(ids) ~= "table" then return end + if type(ids) ~= "table" then return false end local hasHeirloom = C_Heirloom.PlayerHasHeirloom local getInfo = C_Heirloom.GetHeirloomInfo - if not getInfo then return end + if not getInfo then return false end local getItemIcon = C_Item and C_Item.GetItemIconByID for _, itemID in ipairs(ids) do @@ -1060,6 +1072,7 @@ function Database:PopulateDynamicHeirlooms() end end if self.ResetSearchCache then self:ResetSearchCache() end + return true end -- Scan known character titles and inject as search entries. Click on a @@ -1071,12 +1084,12 @@ function Database:PopulateDynamicTitles() local getNum = GetNumTitles local getName = GetTitleName local isKnown = IsTitleKnown - if not getNum or not getName or not isKnown then return end + if not getNum or not getName or not isKnown then return false end RemoveEntriesByCategory("Title") local total = getNum() - if not total or total <= 0 then return end + if not total or total <= 0 then return false end for titleID = 1, total do if isKnown(titleID) then @@ -1093,6 +1106,7 @@ function Database:PopulateDynamicTitles() end end if self.ResetSearchCache then self:ResetSearchCache() end + return true end -- Scan the player's saved Equipment Manager gear sets and inject as @@ -1100,15 +1114,15 @@ end -- C_EquipmentSet.UseEquipmentSet (no protected-frame issues outside -- combat). Per-set icons come from the saved iconFileID. function Database:PopulateDynamicGearSets() - if not C_EquipmentSet or not C_EquipmentSet.GetEquipmentSetIDs then return end + if not C_EquipmentSet or not C_EquipmentSet.GetEquipmentSetIDs then return false end RemoveEntriesByCategory("Gear Set") local ids = C_EquipmentSet.GetEquipmentSetIDs() - if type(ids) ~= "table" then return end + if type(ids) ~= "table" then return false end local getInfo = C_EquipmentSet.GetEquipmentSetInfo - if not getInfo then return end + if not getInfo then return false end for _, setID in ipairs(ids) do local name, iconFileID = getInfo(setID) @@ -1122,19 +1136,20 @@ function Database:PopulateDynamicGearSets() end end if self.ResetSearchCache then self:ResetSearchCache() end + return true 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 + if not C_TransmogOutfitInfo or not C_TransmogOutfitInfo.GetOutfitsInfo then return false end -- Remove previous outfit entries (handles mid-session outfit changes) RemoveEntriesByCategory("Outfit") wipe(outfitEntries) local outfits = C_TransmogOutfitInfo.GetOutfitsInfo() - if not outfits then return end + if not outfits then return false end for _, info in ipairs(outfits) do if not info.isDisabled then @@ -1148,6 +1163,7 @@ function Database:PopulateDynamicOutfits() outfitEntries[#outfitEntries + 1] = entry end end + return true end -- Reads the default UI's transmog set filters and updates our saved settings. @@ -1185,7 +1201,7 @@ end -- Scans all transmog appearance sets and injects them into the search database. -- Respects class, collected, and PvE/PvP filter settings. function Database:PopulateDynamicTransmogSets() - if not C_TransmogSets or not C_TransmogSets.GetAllSets then return end + if not C_TransmogSets or not C_TransmogSets.GetAllSets then return false end -- Remove previous entries (handles mid-session filter changes) RemoveEntriesWithField("transmogSetID") @@ -1196,10 +1212,10 @@ function Database:PopulateDynamicTransmogSets() if self.ResetSearchCache then self:ResetSearchCache() end local allSets = C_TransmogSets.GetAllSets() - if not allSets then return end + if not allSets then return false end local db = EasyFind and EasyFind.db - if not db then return end + if not db then return false end local classFilter = db.appearanceSetClass local showCollected = not db or db.appearanceSetCollected ~= false local showNotCollected = not db or db.appearanceSetNotCollected ~= false @@ -1325,6 +1341,7 @@ function Database:PopulateDynamicTransmogSets() end end end + return true end function Database:FindEmptyActionSlot() @@ -1797,7 +1814,7 @@ end -- the macro. Re-callable: clears prior Macro entries before re-injecting, -- so calls from UPDATE_MACROS reflect renames/edits/deletes. function Database:PopulateDynamicMacros() - if not GetNumMacros or not GetMacroInfo then return end + if not GetNumMacros or not GetMacroInfo then return false end RemoveEntriesByCategory("Macro") if self.ResetSearchCache then self:ResetSearchCache() end @@ -1848,6 +1865,7 @@ function Database:PopulateDynamicMacros() for i = 1, numPerChar do injectMacro(MAX_ACCOUNT + i, true) end + return true end -- Inject one entry per learned spell into uiSearchData. Retail Midnight @@ -1864,13 +1882,46 @@ function Database:PopulateDynamicAbilities() local SBOOK = C_SpellBook if not SBOOK or not SBOOK.GetSpellBookItemInfo or not SBOOK.GetNumSpellBookSkillLines or not SBOOK.GetSpellBookSkillLineInfo then - return + return false end local BANK = (Enum and Enum.SpellBookSpellBank and Enum.SpellBookSpellBank.Player) or 0 local SPELL_TYPE = Enum and Enum.SpellBookItemType and Enum.SpellBookItemType.Spell + local FLYOUT_TYPE = Enum and Enum.SpellBookItemType and Enum.SpellBookItemType.Flyout + local GetFlyoutInfoFn = GetFlyoutInfo + local GetFlyoutSlotInfoFn = GetFlyoutSlotInfo local seen = {} + local function injectAbility(name, subName, spellID, iconID, lineName, lineSpecID, isOffSpec, isPassive, slotIndex) + if not name or name == "" or not spellID then return end + local displayName = (subName and subName ~= "") and (name .. " (" .. subName .. ")") or name + local nameLower = slower(displayName) + local kw = { slower(name), "ability", "abilities" } + if lineName and lineName ~= "" then + kw[#kw + 1] = slower(lineName) + end + uiSearchData[#uiSearchData + 1] = { + name = displayName, + nameLower = nameLower, + keywords = kw, + keywordsLower = kw, + category = "Ability", + treeName = lineName, + isOffSpec = isOffSpec or false, + isPassive = isPassive, + isSpellbookOnly = isOffSpec or isPassive, + icon = iconID, + spellID = spellID, + spellBookSpellID = spellID, + spellBookIndex = slotIndex, + spellBookBank = BANK, + spellBookCategoryName = lineName, + spellBookSpecID = lineSpecID, + spellName = name, + steps = { { spellID = spellID } }, + } + end + local numLines = SBOOK.GetNumSpellBookSkillLines() or 0 for tab = 1, numLines do local lineInfo = SBOOK.GetSpellBookSkillLineInfo(tab) @@ -1883,50 +1934,55 @@ function Database:PopulateDynamicAbilities() and offSpecID and offSpecID ~= 0 then lineSpecID = offSpecID end + local isOffSpec = lineInfo and lineInfo.offSpecID + and lineInfo.offSpecID ~= 0 or false for s = offset + 1, offset + numSpells do local itemInfo = SBOOK.GetSpellBookItemInfo(s, BANK) - local seenKey = itemInfo and itemInfo.actionID - and (tostring(itemInfo.actionID) .. ":" .. tostring(tab) - .. ":" .. tostring(lineSpecID or "")) - if itemInfo - and (not SPELL_TYPE or itemInfo.itemType == SPELL_TYPE) - and itemInfo.name and itemInfo.name ~= "" - and itemInfo.actionID and seenKey and not seen[seenKey] then - seen[seenKey] = true - local name = itemInfo.name - local subName = itemInfo.subName - local displayName = (subName and subName ~= "") and (name .. " (" .. subName .. ")") or name - local nameLower = slower(displayName) - local kw = { slower(name), "ability", "abilities" } - if lineName and lineName ~= "" then - kw[#kw + 1] = slower(lineName) + if itemInfo and itemInfo.name and itemInfo.name ~= "" and itemInfo.actionID then + if (not SPELL_TYPE or itemInfo.itemType == SPELL_TYPE) then + local seenKey = tostring(itemInfo.actionID) .. ":" .. tostring(tab) + .. ":" .. tostring(lineSpecID or "") + if not seen[seenKey] then + seen[seenKey] = true + injectAbility(itemInfo.name, itemInfo.subName, itemInfo.actionID, + itemInfo.iconID, lineName, lineSpecID, isOffSpec, itemInfo.isPassive, s) + end + elseif FLYOUT_TYPE and itemInfo.itemType == FLYOUT_TYPE + and GetFlyoutInfoFn and GetFlyoutSlotInfoFn then + -- Flyouts are containers (Switch Flight Style → Skyriding / + -- Steady Flight, etc.). The flyout itself isn't castable, + -- but its slot spells are — scan and inject each, and use + -- the flyout's own name as a keyword so "switch flight" + -- finds the slot spells. + local flyoutID = itemInfo.actionID + local flyoutName, _, numSlots, isKnown = GetFlyoutInfoFn(flyoutID) + if isKnown and numSlots and numSlots > 0 then + local flyoutKw = flyoutName and flyoutName ~= "" + and slower(flyoutName) or nil + for slot = 1, numSlots do + local slotSpellID, _, slotIsKnown, slotSpellName = GetFlyoutSlotInfoFn(flyoutID, slot) + if slotIsKnown and slotSpellID and slotSpellName then + local slotIcon = C_Spell and C_Spell.GetSpellTexture + and C_Spell.GetSpellTexture(slotSpellID) + local seenKey = tostring(slotSpellID) .. ":" .. tostring(tab) + .. ":" .. tostring(lineSpecID or "") + if not seen[seenKey] then + seen[seenKey] = true + injectAbility(slotSpellName, nil, slotSpellID, + slotIcon, lineName, lineSpecID, isOffSpec, false, s) + if flyoutKw then + local injected = uiSearchData[#uiSearchData] + injected.keywords[#injected.keywords + 1] = flyoutKw + end + end + end + end + end end - local isOffSpec = lineInfo and lineInfo.offSpecID - and lineInfo.offSpecID ~= 0 - uiSearchData[#uiSearchData + 1] = { - name = displayName, - nameLower = nameLower, - keywords = kw, - keywordsLower = kw, - category = "Ability", - treeName = lineInfo and lineInfo.name, - isOffSpec = isOffSpec, - isPassive = itemInfo.isPassive, - isSpellbookOnly = isOffSpec or itemInfo.isPassive, - icon = itemInfo.iconID, - spellID = itemInfo.actionID, - spellBookSpellID = itemInfo.spellID or itemInfo.actionID, - spellBookIndex = s, - spellBookBank = BANK, - spellBookCategoryIndex = tab, - spellBookCategoryName = lineName, - spellBookSpecID = lineSpecID, - spellName = name, - steps = { { spellID = itemInfo.actionID } }, - } end end end + return true end -- Common community abbreviations for dungeons/raids whose initials @@ -2183,7 +2239,7 @@ function Database:PopulateDynamicBags() local getItemInfo = (CONT and CONT.GetContainerItemInfo) or GetContainerItemInfo local getItemInfoInstant = (C_Item and C_Item.GetItemInfoInstant) or GetItemInfoInstant local isEquippableItem = (C_Item and C_Item.IsEquippableItem) or _G.IsEquippableItem - if not getNumSlots or not getItemInfo then return end + if not getNumSlots or not getItemInfo then return false end RemoveEntriesByCategory("Bag") if self.ResetSearchCache then self:ResetSearchCache() end @@ -2254,6 +2310,7 @@ function Database:PopulateDynamicBags() }, } end + return true end function Database:_ResetDynamicProviderCaches() @@ -4099,10 +4156,53 @@ function Database:SearchUI(query, skipCategories) (skipCategories["Loot"] and "L" or "") ) or "" + -- prevCandidates is missing entries the current (more permissive) + -- scoring pass would now match. Two distinct cases trigger this: + -- + -- 1. A gating flag flipped false → true on this keystroke + -- ("icc bos" → "icc boss" turns bossQueryWord on; "characters" + -- → "characters ach..." turns achQueryWord on). Boss / + -- achievement entries that previously failed the stricter + -- gate were never scored, so prevCandidates contains zero of + -- them. + -- + -- 2. A new word was just appended to a stat-keyword query + -- ("haste" → "haste ring"). lootStatQueryWord stays true + -- throughout, but loot rings are missing from the "ha" + -- prefix bucket (their lootStatKw is populated lazily, after + -- the index was built), so they never made it into + -- prevCandidates. Adding "ring" pulls them in via the "ri" + -- bucket on the multi-token path. + -- + -- Either case bypasses extension for one keystroke and lets the + -- prefix lookup rebuild the candidate set. + local prevLootStat, prevBossWord, prevAchWord = false, false, false + local prevWordCount = 0 + if prevQuery ~= "" then + for prevWord in prevQuery:gmatch("%S+") do + prevWordCount = prevWordCount + 1 + if IsLootStatSearchWord(prevWord) then prevLootStat = true end + if prevWord == "boss" or prevWord == "bosses" then + prevBossWord = true + end + if ssub(prevWord, 1, 3) == "ach" or prevWord == "stat" or prevWord == "stats" + or prevWord == "statistic" or prevWord == "statistics" then + prevAchWord = true + end + end + end + local addedNewWord = #queryWords > prevWordCount + local gatingShifted = + (lootStatQueryWord and not prevLootStat) + or (bossQueryWord and not prevBossWord) + or (achQueryWord and not prevAchWord) + or (lootStatQueryWord and addedNewWord) + local searchSet local prevLen = #prevQuery if prevLen > 0 and skipKey == prevSkipKey - and queryLen > prevLen and ssub(query, 1, prevLen) == prevQuery then + and queryLen > prevLen and ssub(query, 1, prevLen) == prevQuery + and not gatingShifted then -- Forward extension of the prior query: re-score the prior -- candidates only. (Cheapest path.) searchSet = prevCandidates @@ -4266,17 +4366,9 @@ function Database:SearchUI(query, skipCategories) prevSkipKey = skipKey tsort(results, scoreDescending) - -- Cap to top-N by score. Beyond ~100 results the per-keystroke - -- bucket-build / sort / hierarchical-flatten pipeline pays in full - -- for entries the user can't see (the row pool is 50 anyway), and - -- "525 results for `mo`" hits 70ms+ in build alone. Capping the - -- raw score-sorted list before the downstream pipeline makes the - -- hot path proportional to visible work, not to dataset size. - local SEARCH_RESULT_CAP = 100 + local SEARCH_RESULT_CAP = 250 if resultsN > SEARCH_RESULT_CAP then for i = SEARCH_RESULT_CAP + 1, resultsN do results[i] = nil end - -- Trim the pool to the cap so a single broad query ("mo") does - -- not leave the pool sized at ~420 forever. for i = SEARCH_RESULT_CAP + 1, #resultEntryPool do resultEntryPool[i] = nil end diff --git a/DatabaseDynamic.lua b/DatabaseDynamic.lua index 14a9596..368a7ea 100644 --- a/DatabaseDynamic.lua +++ b/DatabaseDynamic.lua @@ -79,8 +79,14 @@ local function RunDynamicProvider(database, provider, onDone) local fn = database[provider.fn] if not fn then onDone(false); return end - local ok, err = xpcall(fn, Utils.ErrorHandler, database) - FinishDynamicProvider(database, provider, ok, err, true, onDone) + local ok, readyOrErr = xpcall(fn, Utils.ErrorHandler, database) + if ok and readyOrErr == false then + provider.loaded = false + provider.dirty = true + onDone(false) + return + end + FinishDynamicProvider(database, provider, ok, readyOrErr, true, onDone) end function Database:CancelDynamicWarmup() @@ -110,26 +116,85 @@ function Database:RefreshDynamicCategory(key) return changed end +-- LoadCoreDynamicSearchData is now a no-op stub: every provider runs on +-- its own frame via LoadDeferredSyncProvidersStaggered after PLAYER_LOGIN. +-- Spreading the cost across ~12 frames means no single frame's added +-- work is visible as a stutter, matching the pre-refactor experience. function Database:LoadCoreDynamicSearchData() - if self._loadingCoreDynamic then return false end - self._loadingCoreDynamic = true + return false +end + +-- Run every sync (non-async) provider one per frame. Each provider runs +-- in its own frame, so no single frame stacks the cost of multiple +-- collection / spell-book / EJ scans. ~12 frames (~200ms at 60fps) for +-- all sync providers to populate after login. +function Database:LoadDeferredSyncProvidersStaggered() + -- Batch flag suppresses per-provider ResetSearchCache (which rebuilds + -- the prefix index over the entire uiSearchData each time). Without + -- this each of the ~12 providers triggers a full rebuild — by the + -- end the prefix index has been rebuilt 12 times. We rebuild once + -- at the end of the chain instead. self._dynamicBatchLoading = true self._dynamicBatchChanged = false + local index = 1 + local function step() + while index <= #dynamicProviders do + local provider = dynamicProviders[index] + index = index + 1 + if not provider.asyncFn + and not (provider.loaded and not provider.dirty) then + RunDynamicProvider(self, provider, function() + if C_Timer and C_Timer.After then + C_Timer.After(0, step) + else + step() + end + end) + return + end + end + local changed = self._dynamicBatchChanged + self._dynamicBatchLoading = false + self._dynamicBatchChanged = false + if changed and self.ResetSearchCache then self:ResetSearchCache() end + end + step() +end +-- Synchronous heavy-data load. Used at PLAYER_LOGIN so the load +-- screen absorbs the cost. Only loot (current spec) is scanned here — +-- it's small and matches a common search ("ring", "haste ring"). Boss +-- scanning iterates ~1000+ encounters across every expansion tier and +-- adds noticeable post-login CPU; bosses lazy-load on the first "boss" +-- keyword via LoadHeavyDynamicSearchData instead. +local SYNC_HEAVY_KEYS = { loot = true } +function Database:LoadHeavyDynamicSearchDataSync() for i = 1, #dynamicProviders do local provider = dynamicProviders[i] - if not provider.asyncFn then - RunDynamicProvider(self, provider, function() end) + if provider.asyncFn and SYNC_HEAVY_KEYS[provider.key] + and not (provider.loaded and not provider.dirty) then + local fn = provider.fn and self[provider.fn] + if fn then + local pre = provider.pre and self[provider.pre] + if pre then pre(self) end + -- Loot scans ONLY the current spec at login (scanAllSpecs=false). + -- Spec toggles in the loot filter dropdown trigger a lazy scan + -- for that spec via the async path. + local ok, err = xpcall(fn, Utils.ErrorHandler, self) + if ok then + provider.loaded = true + provider.dirty = false + else + provider.loaded = false + if EasyFind and EasyFind.Print then + EasyFind:Print("|cffff4444" .. provider.key + .. " sync load failed: " .. tostring(err) .. "|r") + end + end + end end end - - local changed = self._dynamicBatchChanged - self._dynamicBatchLoading = false - self._dynamicBatchChanged = false - self._loadingCoreDynamic = false - - if changed and self.ResetSearchCache then self:ResetSearchCache() end - return changed + if self.ResetSearchCache then self:ResetSearchCache() end end function Database:LoadHeavyDynamicSearchData(onDone) @@ -137,12 +202,14 @@ function Database:LoadHeavyDynamicSearchData(onDone) self._loadingHeavyDynamic = true local index = 1 + local anyChanged = false local function step() while index <= #dynamicProviders do local provider = dynamicProviders[index] index = index + 1 if provider.asyncFn then - RunDynamicProvider(self, provider, function() + RunDynamicProvider(self, provider, function(changed) + if changed then anyChanged = true end if C_Timer and C_Timer.After then C_Timer.After(0.05, step) else @@ -153,7 +220,7 @@ function Database:LoadHeavyDynamicSearchData(onDone) end end self._loadingHeavyDynamic = false - if onDone then onDone() end + if onDone then onDone(anyChanged) end end step() diff --git a/MapSearch.lua b/MapSearch.lua index 0566378..608c5c8 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -3111,8 +3111,12 @@ function MapSearch:ScanDungeonEntrances(mapID) -- Helper: classify and append an entrance local function addEntrance(entrance, ex, ey) - if seen[entrance.name] then return end - seen[entrance.name] = true + -- Dedup case-insensitively: Blizzard's API sometimes returns + -- the same instance under different casings ("Chamber Of Heart" + -- vs "Chamber of Heart") which would otherwise both pass. + local key = slower(entrance.name) + if seen[key] then return end + seen[key] = true local cat = "dungeon" if entrance.journalInstanceID and EJ_GetInstanceInfo then local _, _, _, _, _, _, _, _, _, _, _, entIsRaid = EJ_GetInstanceInfo(entrance.journalInstanceID) @@ -3164,7 +3168,7 @@ function MapSearch:ScanDungeonEntrances(mapID) local contEntrances = GetDungeonEntrancesForMap(continentID) if contEntrances then for _, entrance in ipairs(contEntrances) do - if entrance.name and entrance.position and not seen[entrance.name] then + if entrance.name and entrance.position and not seen[slower(entrance.name)] then local ownerZone = owners[slower(entrance.name)] if ownerZone == mapID then local cx, cy = entrance.position.x, entrance.position.y @@ -5829,12 +5833,17 @@ function MapSearch:SearchForUI(query) local nameLower = GetNameLower(zone) zoneNames[nameLower] = true if not existingNames[nameLower] then + -- Pre-set score WITHOUT the +200 zone boost the dedicated + -- map tab uses. In the UI search bar, map zones merge with + -- non-map results (toys, items, abilities), and a +200 + -- boost causes fuzzy zone matches like "Heart of Fear" to + -- outrank prefix matches like "Hearthstone". pois[#pois + 1] = { name = zone.name, category = "zone", icon = 237382, nameLower = zone.nameLower, nameNorm = zone.nameNorm, isZone = true, zoneMapID = zone.mapID, zoneMapType = zone.mapType, zoneParentMapID = zone.parentMapID, - pathPrefix = group.parentPath, score = zone.score + 200, + pathPrefix = group.parentPath, score = zone.score, } end end diff --git a/UI.lua b/UI.lua index 7d8676c..ce9fd93 100644 --- a/UI.lua +++ b/UI.lua @@ -40,8 +40,8 @@ local resultsFrame -- resultsFrame when ShowHierarchicalResults runs. local containerFrame local resultButtons = {} -local MAX_BUTTON_POOL = 50 -local MAX_SEARCH_RESULT_ROWS = 18 +local MAX_BUTTON_POOL = 100 +local MAX_SEARCH_RESULT_ROWS = MAX_BUTTON_POOL local EnsureResultButton local inCombat = false local selectingResult = false -- guard: suppress OnTextChanged re-renders during SelectResult @@ -220,7 +220,7 @@ local function GetActionHint(data) if data.spellID and data.category == "Ability" then return IsSpellbookOnlyAbility(data) and "Select to show in spellbook" or "Select to cast" end - if data.macroIndex then return "Select to run macro" end + if data.macroIndex then return "Select to run macro | Ctrl+click to edit" end if data.itemID and data.category == "Bag" then if data.isEquippable or (data.equipLoc and data.equipLoc ~= "") then return "Select to equip item" @@ -721,6 +721,7 @@ function UI:CreateSearchFrame() self:ClearFocus() return end + if EasyFind.EnsureDynamicLoaded then EasyFind:EnsureDynamicLoaded() end if escCatcher then escCatcher:Hide() end if selectedIndex > 0 then selectedIndex = 0 @@ -3795,8 +3796,14 @@ local function MaybeLoadHeavySearchData(text, needsHeavy) if heavySearchLoading or not ns.Database or not ns.Database.LoadHeavyDynamicSearchData then return end if not needsHeavy then return end heavySearchLoading = true - local started = ns.Database:LoadHeavyDynamicSearchData(function() + local started = ns.Database:LoadHeavyDynamicSearchData(function(anyChanged) heavySearchLoading = false + -- Only re-run search when a provider actually loaded fresh data. + -- Without this gate, every keystroke after providers are loaded + -- triggers heavy-load chain → callback → search → heavy-load + -- chain → ... an infinite loop that allocates ~10 MB/sec across + -- result re-rendering and SearchUI's per-iteration scratch. + if not anyChanged then return end local currentText = searchFrame and searchFrame.editBox and searchFrame.editBox:GetText() if searchFrame and searchFrame.editBox and searchFrame.editBox:HasFocus() and currentText and currentText ~= "" then @@ -3829,22 +3836,27 @@ local function ReadSettingVariable(variable) end local function WriteSettingVariable(variable, value) - if Settings and Settings.SetValue then - local ok = pcall(Settings.SetValue, variable, value) - if ok then return true end - ok = pcall(Settings.SetValue, Settings, variable, value) - if ok then return true end - end + -- Prefer the per-setting object: GetSetting returns nil for variables + -- the Settings panel doesn't know about, so a successful SetValue here + -- means the write actually went somewhere. Settings.SetValue (static) + -- is a silent no-op for unregistered variables, so we skip it. if Settings and Settings.GetSetting then local sok, settObj = pcall(Settings.GetSetting, variable) if sok and settObj and settObj.SetValue then - local ok = pcall(settObj.SetValue, settObj, value) - if ok then return true end + if pcall(settObj.SetValue, settObj, value) then return true end end end + -- CVar fallback for raw CVars not registered with the Settings panel. + -- Booleans need explicit "1"/"0" — tostring(true) gives "true", which + -- a CVar slot would store literally and break the next read. if SetCVar then - local ok = pcall(SetCVar, variable, tostring(value)) - if ok then return true end + local cvarVal + if type(value) == "boolean" then + cvarVal = value and "1" or "0" + else + cvarVal = tostring(value) + end + if pcall(SetCVar, variable, cvarVal) then return true end end return false end @@ -4036,16 +4048,19 @@ function UI:CreateResultButton(index) local function applySettingValue(variable, newVal) if not variable then return end - local applied = false + -- Same priority as WriteSettingVariable: object-based first + -- (only registered settings expose a Setting object), then + -- SetCVar for raw CVars. The slider only ever passes numbers, + -- so type-conversion edge cases don't matter here, but mirror + -- the same shape so the two writers stay in sync. if Settings and Settings.GetSetting then local sok, settObj = pcall(Settings.GetSetting, variable) if sok and settObj and settObj.SetValue then - pcall(settObj.SetValue, settObj, newVal) - applied = true + if pcall(settObj.SetValue, settObj, newVal) then return end end end - if not applied and SetCVar then - SetCVar(variable, newVal) + if SetCVar then + pcall(SetCVar, variable, tostring(newVal)) end end @@ -4450,20 +4465,7 @@ function UI:CreateResultButton(index) -- the secure click. SetScript would replace them and break casts. resultRow:HookScript("OnMouseDown", function(self, button) if button ~= "LeftButton" then return end - if not IsShiftKeyDown() then - local handled = ActivateSettingResult(self.data) - if not handled and IsSpellbookOnlyAbility(self.data) then - UI:SelectResult(self.data) - handled = true - end - if handled then - self._handledNonSecureMouseDown = true - C_Timer.After(0, function() - self._handledNonSecureMouseDown = nil - end) - end - return - end + if not IsShiftKeyDown() then return end if not self.data then return end local x, y = GetCursorPosition() self._dragOriginX, self._dragOriginY = x, y @@ -4526,6 +4528,17 @@ function UI:CreateResultButton(index) return end + -- Ctrl + macro: suppress the secure macro run so PostClick can + -- open MacroFrame for editing instead. SelectResult's macro + -- branch reads IsControlKeyDown() to pick the edit path. + if d and d.macroIndex and IsControlKeyDown() then + self:SetAttribute("type", nil) + self._lastAttrType = nil + self._lastAttrKey = nil + self._lastAttrVal = nil + return + end + -- Outfit equip: place onto a temp action slot, then the secure -- UseAction dispatch fires on the action attribute. local outfitID = d and d.outfitID @@ -4556,10 +4569,6 @@ function UI:CreateResultButton(index) end end) resultRow:SetScript("PostClick", function(self, mouseButton, down) - if self._handledNonSecureMouseDown then - self._handledNonSecureMouseDown = nil - return - end -- Shift+click pickup: cursor is holding the action for the -- user to drop on a bar. Don't navigate away or close. if self._pickedUp then @@ -5194,38 +5203,11 @@ function UI:OnSearchTextChanged(text, force) end local hierarchical = flatEntries - local pins = GetAllPins() - if #pins > 0 then - wipe(pinnedSearchEntries) - local pinnedEntries = pinnedSearchEntries - local pcount = 0 - for _, pin in ipairs(pins) do - pcount = pcount + 1 - local e = pinnedSearchPinEntries[pcount] - if not e then - e = {} - pinnedSearchPinEntries[pcount] = e - end - e.name = pin.name - e.depth = 0 - e.isPathNode = false - e.isMatch = true - e.isPinned = true - e.isFlat = true - e.data = pin - pinnedEntries[pcount] = e - end - for i = 1, #hierarchical do - pcount = pcount + 1 - pinnedEntries[pcount] = hierarchical[i] - end - for i = pcount + 1, #pinnedEntries do - pinnedEntries[i] = nil - end - hierarchical = pinnedEntries - else - wipe(pinnedSearchEntries) - end + -- Pins are quick-access entries that only show when the search bar is + -- empty (handled by ShowPinnedItems). During an active text search we + -- skip them so a user who pinned 6 settings doesn't see them prepended + -- to every unrelated query like "achieve". + wipe(pinnedSearchEntries) local _perfTBuild = ns.PERF and debugprofilestop() or 0 self:ShowHierarchicalResults(hierarchical) if ns.PERF then @@ -5564,6 +5546,9 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) newType, newKey, newVal = "spell", "spell", data.spellName or data.spellID elseif data and data.itemID and data.category == "Bag" then newType, newKey, newVal = "item", "item", data.name + elseif data and data.macroIndex and data.category == "Macro" + and data.macroBody and data.macroBody ~= "" then + newType, newKey, newVal = "macro", "macrotext", data.macroBody end if resultRow._lastAttrType ~= newType or resultRow._lastAttrKey ~= newKey @@ -8318,10 +8303,16 @@ function UI:SelectResult(data, forceGuide) if not data.steps or #data.steps == 0 then return end end - -- Macro: open MacroFrame and select the macro slot. + -- Macro: default click runs the macro (handled by the row's secure + -- macro attribute). Ctrl+click opens MacroFrame for editing — + -- PreClick clears the secure type when Ctrl is held so the macro + -- doesn't also fire. Right-click → Guide opens MacroFrame as a + -- guide via forceGuide=true. if data.macroIndex then if useFast then - UI:OpenMacroFrameAt(data.macroIndex, data.macroIsChar) + if IsControlKeyDown() then + UI:OpenMacroFrameAt(data.macroIndex, data.macroIsChar) + end return end if data.steps then EasyFind:StartGuide(data) end @@ -9055,6 +9046,9 @@ function UI:Hide() if ns.Database and ns.Database.CancelDynamicWarmup then ns.Database:CancelDynamicWarmup() end + -- Close any open filter dropdown / flyouts so they don't linger + -- on screen after the bar is toggled off via keybind. + self:CloseFilterDropdownIfOpen() searchFrame:Hide() searchFrame.setSmartShowVisible(false) self:HideResults() From a04d50d800b19dface4404a10710d1eefe973a4a Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 7 May 2026 07:14:15 -0400 Subject: [PATCH 042/103] ESC: route all paths through HandleEscape; close every popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dropdown's own OnKeyDown ESCAPE handler was bare-self:Hide() — it hit only the main dropdown and skipped flyouts/nested popups, and never refocused the editbox. Same shape on the popup keyboard-nav handler. Both now route through UI:HandleEscape so a single ESC closes every filter menu and refocuses the editbox regardless of which frame held keyboard input at the time. Brute-force close: HandleEscape's CloseFilterDropdownIfOpen now hides every named popup (EasyFindAsClassPopup is parented to UIParent without ever being added to dropdownGuardFrames; the cascade-via-OnHide chain missed it when the parent popup was already hidden), then walks dropdown.flyoutPopups (unnamed Collections / Options popups) and dropdown.guardFrames. Order-independent and idempotent. navFrame ESCAPE consolidated: HandleNavKeyDown's ESCAPE branch resets nav state (toolbarFocus, selectedIndex, toggleFocused, navFrame keyboard) inline and then calls HandleEscape, so single ESC closes the bar when no menus are open instead of just resetting nav state. Refocus is three-tier (immediate / next-frame / 50ms) so dropdown: OnHide's ClearFocus race after a Hide cascade can't strand the editbox unfocused. The dropdown:OnHide ClearFocus is also gated on UI._escClosingMenus to skip when HandleEscape is driving the close. escCatcher reparented to UIParent (not searchFrame) so its OnHide signal isn't entangled with parent-cascade hides during reload / autoHide flows. Shown via searchFrame:OnShow hook + UI:Show explicit call so first-time bar display arms the catcher reliably. --- UI.lua | 186 +++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 141 insertions(+), 45 deletions(-) diff --git a/UI.lua b/UI.lua index ce9fd93..69be011 100644 --- a/UI.lua +++ b/UI.lua @@ -722,7 +722,6 @@ function UI:CreateSearchFrame() return end if EasyFind.EnsureDynamicLoaded then EasyFind:EnsureDynamicLoaded() end - if escCatcher then escCatcher:Hide() end if selectedIndex > 0 then selectedIndex = 0 toggleFocused = false @@ -936,18 +935,8 @@ function UI:CreateSearchFrame() UI:ActivateSelected() end) - editBox:SetScript("OnEscapePressed", function(self) - if UI:CloseFilterDropdownIfOpen() then - self:SetFocus() - return - end - if self:GetText() == "" then - UI:Hide() - return - end - self:SetText("") - self.placeholder:Show() - UI:HideResults() + editBox:SetScript("OnEscapePressed", function(_) + UI:HandleEscape() end) -- Chrome-style inline autocomplete: same helper MapTab uses. @@ -1456,25 +1445,23 @@ function UI:CreateSearchFrame() UI:ActivateSelected() end elseif key == "ESCAPE" then - if UI:CloseFilterDropdownIfOpen() then - if searchFrame and searchFrame.editBox then - searchFrame.editBox:SetFocus() - end - elseif toolbarFocus > 0 then + -- Reset nav state inline (toolbarFocus / selectedIndex / + -- toggleFocused are locals to this closure, so we can't move + -- this into HandleEscape without exposing them). Then route + -- through HandleEscape so the same close-menus / clear-text / + -- hide-bar decision tree runs as the editBox and escCatcher + -- paths. Single ESC closes the bar when no menus are open. + if toolbarFocus > 0 then ClearToolbarFocus() - if selectedIndex == 0 then - Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) - end - elseif toggleFocused then - toggleFocused = false - UI:UpdateSelectionHighlight() - else + end + if selectedIndex > 0 or toggleFocused then selectedIndex = 0 toggleFocused = false - Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) - if searchFrame.StopKeyRepeat then searchFrame.StopKeyRepeat() end UI:UpdateSelectionHighlight(true) end + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + if searchFrame.StopKeyRepeat then searchFrame.StopKeyRepeat() end + UI:HandleEscape() 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. @@ -1543,23 +1530,25 @@ function UI:CreateSearchFrame() if keyRepeat.IsKey(key) then StopKeyRepeat(key) 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) + -- UISpecialFrames fallback: shown whenever the search bar is visible so + -- WoW always has a target for ESC, even after the editbox loses focus + -- (clicking the filter button, hovering a sub-flyout, etc). WoW Hides + -- the catcher on ESC; OnHide runs the unified HandleEscape and re-Shows + -- the catcher if the bar is still open so the next ESC also fires. + -- Parented to UIParent (not searchFrame) so the OnHide signal isn't + -- entangled with parent-cascade hides during reload / autoHide flows. + escCatcher = CreateFrame("Frame", "EasyFindEscCatcher", UIParent) 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() + escCatcher:SetScript("OnHide", function(self) + if not searchFrame or not searchFrame:IsShown() then return end + -- editBox having focus means WoW's ESC pipeline already routed to + -- OnEscapePressed; this OnHide is a side-effect of something else + -- (autoHide hide, etc) and shouldn't drive ESC behavior. + if searchFrame.editBox and searchFrame.editBox:HasFocus() then return end + UI:HandleEscape() + if searchFrame:IsShown() then self:Show() end end) -- Tab confirms autocomplete suggestion only. Toolbar nav (clear / @@ -1714,6 +1703,10 @@ function UI:CreateSearchFrame() if EasyFind.db.autoHide then self:RegisterEvent("GLOBAL_MOUSE_DOWN") end + -- Arm the UISpecialFrames catcher: gives ESC a target even when + -- the editbox lacks focus (clicking the filter button, hovering a + -- flyout). escCatcher's OnHide consolidates the close behavior. + if escCatcher then escCatcher:Show() end end) searchFrame:HookScript("OnHide", function(self) self:UnregisterEvent("GLOBAL_MOUSE_DOWN") @@ -1745,6 +1738,7 @@ function UI:CreateSearchFrame() end UI:Hide() end) + end -- Top-level filter list, alphabetical. Collections stays compact via a @@ -2246,7 +2240,11 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local target = rows[popupFocus] if target and target.Click then target:Click() end elseif key == "ESCAPE" then - self:Hide() + -- Route through HandleEscape: closes the parent dropdown + -- and any sibling popups together, refocuses editbox. + -- Bare self:Hide() only hits this popup and leaves the + -- main dropdown / nested popups behind. + UI:HandleEscape() else Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) end @@ -3476,7 +3474,10 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end elseif key == "ESCAPE" then self._escapedViaKeyboard = true - self:Hide() + -- Route through HandleEscape so flyouts/popups close together + -- and the editbox refocuses, instead of just self:Hide() which + -- only hits the main dropdown. + UI:HandleEscape() else Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) end @@ -3595,7 +3596,12 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) if fb.UnlockHighlight then fb:UnlockHighlight() end end end - if searchFrame.editBox and not searchFrame.editBox:IsMouseOver() then + -- Skip the ClearFocus when HandleEscape is driving the close — + -- it intentionally refocuses the editbox so the user can keep + -- typing. ClearFocus + same-frame SetFocus can lose to internal + -- editbox state, hence the flag instead of relying on order. + if searchFrame.editBox and not searchFrame.editBox:IsMouseOver() + and not UI._escClosingMenus then searchFrame.editBox:ClearFocus() end end @@ -7003,11 +7009,48 @@ end -- whether to consume the ESC keystroke vs fall through to text-clear / -- window-close behavior). Walks dropdown.guardFrames so ESC works the -- same regardless of how deep the user has navigated into sub-filters. +-- Every popup frame the filter dropdown can spawn. Named globals so a +-- brute-force walk catches popups not registered in dropdownGuardFrames +-- (classPopup is parented to UIParent without ever being added to the +-- guard list, etc), and so the close path doesn't depend on the cascade +-- chain firing in the right order. +local FILTER_POPUP_NAMES = { + "EasyFindAsClassPopup", + "EasyFindAsOptionsPopup", + "EasyFindGearOptionsPopup", + "EasyFindDiffPopup", + "EasyFindSpecPopup", + "EasyFindSpecFlyout", + "EasyFindSpecSubFlyout", +} + function UI:CloseFilterDropdownIfOpen() if not searchFrame then return false end local dropdown = searchFrame.filterDropdown if not dropdown then return false end local closedAny = false + -- Brute-force hide every named popup. classPopup is never registered + -- in guardFrames; relying on the cascade-via-OnHide chain misses it + -- when the parent popup is already hidden. Hide by name is idempotent + -- and order-independent. + for i = 1, #FILTER_POPUP_NAMES do + local f = _G[FILTER_POPUP_NAMES[i]] + if f and f.IsShown and f:IsShown() then + f:Hide() + closedAny = true + end + end + -- Collections / Options flyout popups are unnamed; reach them via the + -- dropdown.flyoutPopups registry. + if dropdown.flyoutPopups then + for i = 1, #dropdown.flyoutPopups do + local popup = dropdown.flyoutPopups[i] + if popup and popup:IsShown() then + popup:Hide() + closedAny = true + end + end + end if dropdown.guardFrames then for i = 1, #dropdown.guardFrames do local guard = dropdown.guardFrames[i] @@ -7044,7 +7087,6 @@ function UI:HideResults() containerFrame:SetPoint("BOTTOM", searchFrame, "BOTTOM", 0, 0) ns.SetRoundedRectDivider(containerFrame, 0, false) end - if escCatcher then escCatcher:Hide() end if resultsFrame.pinSeparator then resultsFrame.pinSeparator:Hide() end @@ -9021,6 +9063,10 @@ function UI:Show(andFocus) if not searchFrame then return end if inCombat then return end searchFrame:Show() + -- Belt-and-suspenders: OnShow hook also calls this, but if searchFrame + -- was already shown the hook didn't fire and escCatcher would be left + -- hidden — leaving ESC without a target when the editbox is unfocused. + if escCatcher then escCatcher:Show() end EasyFind.db.visible = true if EasyFind.db.smartShow and not EasyFind.db.autoHide then searchFrame.hoverZone:Show() @@ -9050,6 +9096,7 @@ function UI:Hide() -- on screen after the bar is toggled off via keybind. self:CloseFilterDropdownIfOpen() searchFrame:Hide() + if escCatcher then escCatcher:Hide() end searchFrame.setSmartShowVisible(false) self:HideResults() searchFrame.editBox:ClearFocus() @@ -9059,6 +9106,55 @@ function UI:Hide() searchFrame.hoverZone:SetShown(EasyFind.db.smartShow) end +-- Unified ESC handler: collapses every menu state we care about into one +-- decision tree. Called from editBox:OnEscapePressed (focused path) and +-- escCatcher:OnHide (unfocused path) so ESC behaves the same regardless of +-- which frame currently holds keyboard input. +-- Filter dropdown / flyouts open: close them all + refocus editbox. +-- Editbox has text: clear text + refocus. +-- Otherwise: hide the search bar. +function UI:HandleEscape() + if not searchFrame or not searchFrame:IsShown() then return end + local editBox = searchFrame.editBox + local function Refocus() + if not editBox then return end + -- Three SetFocus attempts spread across timing windows: synchronous + -- (works when dropdown:OnHide already ran), next-frame (handles the + -- normal Hide cascade), and short-deferred (handles editbox state + -- machine quirks after a Hide chain). Whichever lands first wins; + -- subsequent SetFocus on an already-focused editbox is a no-op. + editBox.blockFocus = nil + editBox:SetFocus() + C_Timer.After(0, function() + if not searchFrame or not searchFrame:IsShown() or not editBox then return end + if editBox:HasFocus() then return end + editBox.blockFocus = nil + editBox:SetFocus() + end) + C_Timer.After(0.05, function() + if not searchFrame or not searchFrame:IsShown() or not editBox then return end + if editBox:HasFocus() then return end + editBox.blockFocus = nil + editBox:SetFocus() + end) + end + self._escClosingMenus = true + local closedAny = self:CloseFilterDropdownIfOpen() + self._escClosingMenus = nil + if closedAny then + Refocus() + return + end + if editBox and editBox:GetText() ~= "" then + editBox:SetText("") + if editBox.placeholder then editBox.placeholder:Show() end + self:HideResults() + Refocus() + return + end + self:Hide() +end + -- Helper function to expand a currency header by name function UI:ExpandCurrencyHeader(headerName) -- Click the header button - this is what the game actually responds to. From a7e3cf337ee7e85b58ba2d47751053800eb8eaf7 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 7 May 2026 07:37:29 -0400 Subject: [PATCH 043/103] Settings: invalidate render-skip cache on RefreshResults Setting toggles, slider writes, and dropdown cycles kept the same entry.data reference, so ShowHierarchicalResults' identity-based skip cache short-circuited the next render. The checkbox/value/slider visuals only updated after closing and reopening the bar, since that rebuilt the result list from scratch. Clear _lastRenderSig at the top of RefreshResults so callers force a full per-row layout pass. Drops a dead duplicate definition that the live one at 9350 was already overriding. --- UI.lua | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/UI.lua b/UI.lua index 69be011..bdbbb83 100644 --- a/UI.lua +++ b/UI.lua @@ -6907,25 +6907,6 @@ function UI:ShowResults(results) self:ShowHierarchicalResults(flatEntries) end -function UI:RefreshResults() - -- Re-render current results with the active theme (called when theme changes) - 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 - -- Toggle a boolean setting in place (clicked from a result row). -- Tries the Settings API first (handles non-CVar settings like action -- bar visibility), falls back to GetCVar/SetCVar. @@ -9351,6 +9332,12 @@ function UI:RefreshResults() if cachedHierarchical and resultsFrame and resultsFrame:IsShown() then local savedIndex = selectedIndex local savedToggle = toggleFocused + -- Bypass ShowHierarchicalResults' render-skip cache. Setting + -- toggles, slider writes, and dropdown cycles keep the same + -- entry.data reference, so the row-by-row layout pass would be + -- skipped and the on-screen checkbox/value/slider would stay + -- stale until the result list rebuilt from scratch. + self._lastRenderSig = nil self:ShowHierarchicalResults(cachedHierarchical, true) if savedIndex > 0 then selectedIndex = savedIndex From 0233aed3e4bad3e69d92c2b8ea0266a4c0da9520 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 7 May 2026 08:39:37 -0400 Subject: [PATCH 044/103] Settings: inline dropdown widget on result rows Dropdown-type setting rows previously showed only muted text on the right and routed clicks to the Options panel. They now render the in-game SettingsDropdownWithSteppers control directly: prev/next paddle buttons that cycle values, and a center button that opens a MenuUtil radio menu populated from the setting's options. Atlases match the live panel (common-dropdown-c-button-hover-1/2, common-dropdown-icon-back/next, common-dropdown-c-button-hover-arrow). Body uses the hover atlas as the always-visible background since Midnight only ships -hover variants for these. CVar dropdowns (AUTOLOOTTOGGLE, raidFramesHealthText, chatStyle, the graphics dropdowns, etc.) aren't registered as Setting objects so the SettingsPanel walker can't enumerate their options. Added a hardcoded CVAR_DROPDOWN_OPTIONS table in BlizzOptionsSearch keyed on variable name; GetOptionsForVariable consults it first before walking the live panel. ~25 known CVar dropdowns covered. CycleSettingDropdown gained a direction parameter (+1/-1) so the prev and next paddles share one writer. SetSettingDropdownValue applies a specific value picked from the menu without cycling. Whitelisted MenuUtil in .luacheckrc. --- .luacheckrc | 3 + BlizzOptionsSearch.lua | 187 +++++++++++++++++++++++++++++++++++++++++ UI.lua | 160 ++++++++++++++++++++++++++++++++--- 3 files changed, 340 insertions(+), 10 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 8393475..b722076 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -29,6 +29,9 @@ read_globals = { "debugstack", "debugprofilestart", "debugprofilestop", "strsplit", "strtrim", "wipe", "hooksecurefunc", "format", "time", "date", + -- Menus + "MenuUtil", + -- Core API "CreateFrame", "CreateFont", "CreateVector2D", "CreateColor", "GetTime", "GetLocale", "GetCVar", "SetCVar", "GetCursorPosition", "GetRealmName", diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index 1dad131..f342756 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -205,6 +205,185 @@ local SETTINGS_DATA = { local TYPE_MAP = { c = "checkbox", d = "dropdown", s = "slider" } +-- Hardcoded option lists for CVar-based dropdowns the live SettingsPanel +-- can't enumerate (these aren't registered as Setting objects, so +-- GetOptionsForVariable's layout walk returns nil). Listed in the order +-- the in-game dropdown shows them so the inline picker reads naturally. +-- Each entry: { value, label }. Hardware-specific dropdowns (monitors, +-- sound devices, GPUs, resolutions) are intentionally omitted -- their +-- options are machine-dependent and can't be hardcoded sensibly. +local CVAR_DROPDOWN_OPTIONS = { + AUTOLOOTTOGGLE = { + { value = "NONE", label = "None" }, + { value = "ALT", label = "ALT key" }, + { value = "CTRL", label = "CTRL key" }, + { value = "SHIFT", label = "SHIFT key" }, + }, + PROXY_NPC_NAMES = { + { value = "1", label = "Quest NPCs" }, + { value = "2", label = "Hostile NPCs" }, + { value = "3", label = "Hostile and Interactive NPCs" }, + { value = "4", label = "All NPCs" }, + { value = "5", label = "None" }, + }, + nameplateMotion = { + { value = "0", label = "Overlapping Nameplates" }, + { value = "1", label = "Stacking Nameplates" }, + }, + PROXY_STATUS_TEXT = { + { value = "1", label = "Numeric Value" }, + { value = "2", label = "Percentage" }, + { value = "3", label = "Both" }, + { value = "4", label = "None" }, + }, + PROXY_CHAT_BUBBLES = { + { value = "1", label = "All" }, + { value = "2", label = "None" }, + { value = "3", label = "Exclude party chat" }, + }, + raidFramesHealthText = { + { value = "none", label = "None" }, + { value = "health", label = "Health Remaining" }, + { value = "losthealth", label = "Health Lost" }, + { value = "perc", label = "Health Percentage" }, + }, + PROXY_SELF_HIGHLIGHT = { + { value = "0", label = "Off" }, + { value = "1", label = "Circle" }, + { value = "2", label = "Icon" }, + { value = "3", label = "Circle and Icon" }, + }, + floatingCombatTextFloatMode = { + { value = "1", label = "Scroll Up" }, + { value = "2", label = "Scroll Down" }, + { value = "3", label = "Arc" }, + }, + PROXY_SELF_CAST = { + { value = "1", label = "None" }, + { value = "2", label = "Auto" }, + { value = "3", label = "Key Press" }, + { value = "4", label = "Auto and Key Press" }, + }, + SELFCAST = { + { value = "ALT", label = "ALT key" }, + { value = "CTRL", label = "CTRL key" }, + { value = "SHIFT", label = "SHIFT key" }, + }, + FOCUSCAST = { + { value = "NONE", label = "None" }, + { value = "ALT", label = "ALT key" }, + { value = "CTRL", label = "CTRL key" }, + { value = "SHIFT", label = "SHIFT key" }, + }, + chatStyle = { + { value = "classic", label = "Classic Style" }, + { value = "im", label = "IM Style" }, + }, + whisperMode = { + { value = "inline", label = "In-line" }, + { value = "popout", label = "New Tab" }, + { value = "popout_and_inline", label = "Both" }, + }, + showTimestamps = { + { value = "none", label = "None" }, + { value = "%H:%M ", label = "15:27" }, + { value = "%I:%M ", label = "03:27" }, + { value = "%H:%M:%S ", label = "15:27:32" }, + { value = "%I:%M %p ", label = "03:27 PM" }, + { value = "%I:%M:%S ", label = "03:27:32" }, + { value = "%I:%M:%S %p ", label = "03:27:32 PM" }, + }, + PROXY_SICKNESS_SHAKE = { + { value = "1", label = "None" }, + { value = "2", label = "Full" }, + { value = "3", label = "Reduced" }, + }, + cursorSizePreferred = { + { value = "-1", label = "Default" }, + { value = "0", label = "32x32" }, + { value = "1", label = "48x48" }, + { value = "2", label = "64x64" }, + { value = "3", label = "96x96" }, + { value = "4", label = "128x128" }, + }, + PROXY_INTERACT_ICONS = { + { value = "1", label = "NPCs Only" }, + { value = "2", label = "Show All" }, + { value = "3", label = "Show None" }, + }, + colorblindSimulator = { + { value = "0", label = "None" }, + { value = "1", label = "Protanopia" }, + { value = "2", label = "Deuteranopia" }, + { value = "3", label = "Tritanopia" }, + }, + PROXY_MOVIE_SUBTITLE_BACKGROUND = { + { value = "1", label = "None" }, + { value = "2", label = "Dark" }, + { value = "3", label = "Light" }, + }, + LowLatencyMode = { + { value = "0", label = "Disabled" }, + { value = "1", label = "Built-in" }, + { value = "2", label = "NVIDIA Reflex" }, + { value = "3", label = "NVIDIA Reflex + Boost" }, + { value = "4", label = "Intel XeLL" }, + }, + PROXY_ANTIALIASING = { + { value = "0", label = "None" }, + { value = "1", label = "Image-Based" }, + { value = "2", label = "Multisample" }, + { value = "3", label = "Advanced" }, + }, + PROXY_FXAA = { + { value = "0", label = "None" }, + { value = "1", label = "FXAA Low" }, + { value = "2", label = "FXAA High" }, + { value = "3", label = "CMAA" }, + }, + PROXY_MSAA = { + { value = "0", label = "None" }, + { value = "1", label = "2x" }, + { value = "2", label = "4x" }, + { value = "3", label = "8x" }, + }, + textureFilteringMode = { + { value = "0", label = "Bilinear" }, + { value = "1", label = "Trilinear" }, + { value = "2", label = "2x Anisotropic" }, + { value = "3", label = "4x Anisotropic" }, + { value = "4", label = "8x Anisotropic" }, + { value = "5", label = "16x Anisotropic" }, + }, + shadowrt = { + { value = "0", label = "Disabled" }, + { value = "1", label = "Fair" }, + { value = "2", label = "Good" }, + { value = "3", label = "High" }, + }, + ResampleQuality = { + { value = "0", label = "Point" }, + { value = "1", label = "Bilinear" }, + { value = "2", label = "Bicubic" }, + { value = "3", label = "FidelityFX Super Resolution" }, + }, + vrsValar = { + { value = "0", label = "Disabled" }, + { value = "1", label = "Standard" }, + { value = "2", label = "Aggressive" }, + }, + cameraSmoothStyle = { + { value = "0", label = "Never" }, + { value = "1", label = "Smart" }, + { value = "2", label = "Always" }, + { value = "4", label = "Only when moving" }, + }, + PROXY_GRAPHICS_API = { + { value = "d3d11", label = "DirectX 11" }, + { value = "d3d12", label = "DirectX 12" }, + }, +} + -- Resolved tables, populated once at register time. -- categoryIDByName[lowercaseName] = catID -- categoryIDByVariable[variable] = catID (variable -> owning category) @@ -355,6 +534,14 @@ local function GetOptionsForVariable(variable) if cached ~= nil then return cached ~= false and cached or nil end + -- CVar dropdowns (AUTOLOOTTOGGLE, raidFramesHealthText, etc.) aren't + -- registered as Setting objects, so the SettingsPanel walker below + -- never finds them. Hardcoded list catches them first. + local hardcoded = CVAR_DROPDOWN_OPTIONS[variable] + if hardcoded then + optionsByVariable[variable] = hardcoded + return hardcoded + end if not (SettingsPanel and SettingsPanel.GetLayout and Settings and Settings.GetCategoryList) then return nil diff --git a/UI.lua b/UI.lua index bdbbb83..963d0f1 100644 --- a/UI.lua +++ b/UI.lua @@ -4325,6 +4325,112 @@ function UI:CreateResultButton(index) kb1:SetScript("OnClick", MakeBindingClickHandler(1)) kb2:SetScript("OnClick", MakeBindingClickHandler(2)) + -- Inline dropdown widget for settings whose options enumerate. Matches + -- the in-game SettingsDropdownWithSteppers control: + -- prev/next: common-dropdown-c-button-hover-2 (25x25 paddle body) + -- overlaid with common-dropdown-icon-prev / -icon-next chevron + -- center: common-dropdown-c-button-hover-1 (stretchable body) + -- with common-dropdown-c-button-hover-arrow chevron + gold text + -- WoW Midnight only ships the "-hover" atlases for these (no idle + -- variant), so we use the hover atlas as the always-visible body. + local dropdownGroup = CreateFrame("Frame", nil, resultRow) + dropdownGroup:SetSize(180, 25) + dropdownGroup:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) + dropdownGroup:SetFrameLevel(resultRow:GetFrameLevel() + 5) + dropdownGroup:Hide() + resultRow.settingDropdownGroup = dropdownGroup + + local function MakePaddleButton(parent, iconAtlas) + local btn = CreateFrame("Button", nil, parent) + btn:SetSize(25, 25) + local body = btn:CreateTexture(nil, "BACKGROUND") + body:SetAllPoints() + body:SetAtlas("common-dropdown-c-button-hover-2", false) + local icon = btn:CreateTexture(nil, "OVERLAY") + icon:SetSize(17, 17) + icon:SetAtlas(iconAtlas, false) + icon:SetPoint("CENTER", 0, 0) + local hl = btn:CreateTexture(nil, "HIGHLIGHT") + hl:SetAllPoints() + hl:SetAtlas("common-dropdown-c-button-hover-2", false) + hl:SetBlendMode("ADD") + hl:SetAlpha(0.4) + return btn + end + + local ddPrev = MakePaddleButton(dropdownGroup, "common-dropdown-icon-back") + ddPrev:SetPoint("LEFT", dropdownGroup, "LEFT", 0, 0) + resultRow.settingDropdownPrev = ddPrev + + local ddNext = MakePaddleButton(dropdownGroup, "common-dropdown-icon-next") + ddNext:SetPoint("RIGHT", dropdownGroup, "RIGHT", 0, 0) + resultRow.settingDropdownNext = ddNext + + local ddCenter = CreateFrame("Button", nil, dropdownGroup) + ddCenter:SetPoint("LEFT", ddPrev, "RIGHT", 2, 0) + ddCenter:SetPoint("RIGHT", ddNext, "LEFT", -2, 0) + ddCenter:SetHeight(25) + local ddBg = ddCenter:CreateTexture(nil, "BACKGROUND") + ddBg:SetAllPoints() + ddBg:SetAtlas("common-dropdown-c-button-hover-1", false) + local ddHover = ddCenter:CreateTexture(nil, "HIGHLIGHT") + ddHover:SetAllPoints() + ddHover:SetAtlas("common-dropdown-c-button-hover-1", false) + ddHover:SetBlendMode("ADD") + ddHover:SetAlpha(0.4) + local ddArrow = ddCenter:CreateTexture(nil, "OVERLAY") + ddArrow:SetSize(12, 5) + ddArrow:SetAtlas("common-dropdown-c-button-hover-arrow", false) + ddArrow:SetPoint("RIGHT", ddCenter, "RIGHT", -8, 0) + ddCenter:SetNormalFontObject("GameFontNormal") + local ddTxt = ddCenter:GetFontString() + if ddTxt then + ddTxt:SetTextColor(1, 0.82, 0, 1) + ddTxt:SetPoint("LEFT", ddCenter, "LEFT", 8, 0) + ddTxt:SetPoint("RIGHT", ddArrow, "LEFT", -4, 0) + ddTxt:SetJustifyH("CENTER") + ddTxt:SetWordWrap(false) + end + resultRow.settingDropdownLabel = ddCenter + + -- Open the native Blizzard menu on click. Reads opts/current value + -- from whatever data the row has *now*, since rows are pooled and the + -- same physical button serves different settings across renders. + ddCenter:SetScript("OnClick", function(self) + local rowData = resultRow.data + if not rowData or not rowData.settingVariable or not MenuUtil then return end + local opts = rowData.settingOptions + if not opts and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetOptionsForVariable then + opts = ns.BlizzOptionsSearch.GetOptionsForVariable(rowData.settingVariable) + if opts then rowData.settingOptions = opts end + end + if not opts or #opts == 0 then return end + local var = rowData.settingVariable + MenuUtil.CreateContextMenu(self, function(_, rootDescription) + for i = 1, #opts do + local opt = opts[i] + local optValue = opt.value + rootDescription:CreateRadio(opt.label or tostring(optValue), function() + local cur = ReadSettingVariable(var) + return cur == optValue or tostring(cur) == tostring(optValue) + end, function() + UI:SetSettingDropdownValue(rowData, optValue) + end) + end + end) + end) + + ddPrev:SetScript("OnClick", function() + if resultRow.data then UI:CycleSettingDropdown(resultRow.data, -1) end + end) + ddNext:SetScript("OnClick", function() + if resultRow.data then UI:CycleSettingDropdown(resultRow.data, 1) end + end) + + ddPrev:HookScript("OnMouseUp", refocusEditbox) + ddNext:HookScript("OnMouseUp", refocusEditbox) + ddCenter:HookScript("OnMouseUp", refocusEditbox) + local settingSliderValue = sliderGroup:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") settingSliderValue:SetPoint("BOTTOM", sliderGroup, "TOP", 0, -2) settingSliderValue:SetTextColor(0.7, 0.7, 0.7, 1.0) @@ -6460,6 +6566,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) refresh() resultRow.settingKeybindGroup:Show() if resultRow.settingSlider then resultRow.settingSliderGroup:Hide() end + if resultRow.settingDropdownGroup then resultRow.settingDropdownGroup:Hide() end resultRow.settingState:Hide() resultRow.settingCheck:Hide() resultRow.amountText:Hide() @@ -6486,6 +6593,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.settingCheck:SetShown(isOn) resultRow.amountText:Hide() if resultRow.settingSlider then resultRow.settingSliderGroup:Hide() end + if resultRow.settingDropdownGroup then resultRow.settingDropdownGroup:Hide() end -- Re-anchor text RIGHT to settingState so the row name -- truncates at the checkbox instead of overlapping it. resultRow.text:SetPoint("RIGHT", resultRow.settingState, "LEFT", -4, 0) @@ -6527,6 +6635,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.settingState:Hide() resultRow.settingCheck:Hide() resultRow.amountText:Hide() + if resultRow.settingDropdownGroup then resultRow.settingDropdownGroup:Hide() end resultRow.text:SetPoint("RIGHT", resultRow.settingSliderGroup, "LEFT", -4, 0) else -- Dropdown / other: show current value as text @@ -6553,20 +6662,34 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local opts = ns.BlizzOptionsSearch.GetOptionsForVariable(data.settingVariable) if opts then data.settingOptions = opts end end - if type(data.settingOptions) == "table" and rawVal ~= nil then - for oi = 1, #data.settingOptions do - local o = data.settingOptions[oi] + local optList = (data.settingType == "dropdown" and type(data.settingOptions) == "table") + and data.settingOptions or nil + if optList and rawVal ~= nil then + for oi = 1, #optList do + local o = optList[oi] if o.value == rawVal or tostring(o.value) == tostring(rawVal) then val = o.label or val break end end end - if val and val ~= "" then - resultRow.amountText:SetText("|cFFAAAAaa" .. val .. "|r") - resultRow.amountText:ClearAllPoints() - resultRow.amountText:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) - resultRow.amountText:Show() + if optList and #optList > 0 then + -- Inline dropdown widget: paddle arrows + center + -- button styled like the in-game Settings dropdown. + resultRow.settingDropdownLabel:SetText(val or "") + resultRow.settingDropdownGroup:Show() + resultRow.amountText:Hide() + resultRow.text:SetPoint("RIGHT", resultRow.settingDropdownGroup, "LEFT", -4, 0) + else + -- No enumerable options: muted text fallback. Click + -- opens the panel via OpenSettingNoClose. + if val and val ~= "" then + resultRow.amountText:SetText("|cFFAAAAaa" .. val .. "|r") + resultRow.amountText:ClearAllPoints() + resultRow.amountText:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) + resultRow.amountText:Show() + end + if resultRow.settingDropdownGroup then resultRow.settingDropdownGroup:Hide() end end resultRow.settingState:Hide() resultRow.settingCheck:Hide() @@ -6577,6 +6700,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.settingCheck:Hide() if resultRow.settingSlider then resultRow.settingSliderGroup:Hide() end if resultRow.settingKeybindGroup then resultRow.settingKeybindGroup:Hide() end + if resultRow.settingDropdownGroup then resultRow.settingDropdownGroup:Hide() end end -- Flat-list icon sizing. The LEFT icon (UI/map/pin or flatCatIcon) @@ -6937,7 +7061,8 @@ end -- Advance a dropdown setting to its next value inline. Returns true if -- we found options and applied a new value; false if the variable -- wasn't enumerable (caller should fall back to opening the panel). -function UI:CycleSettingDropdown(data) +-- direction: +1 next (default), -1 prev. Wraps around at either end. +function UI:CycleSettingDropdown(data, direction) if not data or not data.settingVariable then return false end local var = data.settingVariable local opts = data.settingOptions @@ -6957,7 +7082,9 @@ function UI:CycleSettingDropdown(data) break end end - local nextIdx = (curIdx or 0) % #opts + 1 + local n = #opts + local step = direction or 1 + local nextIdx = ((curIdx or 1) - 1 + step) % n + 1 local nextVal = opts[nextIdx].value if not WriteSettingVariable(var, nextVal) then return false end @@ -6971,6 +7098,19 @@ function UI:CycleSettingDropdown(data) return true end +-- Apply a specific value picked from the dropdown popup (no cycle). +function UI:SetSettingDropdownValue(data, value) + if not data or not data.settingVariable then return false end + if not WriteSettingVariable(data.settingVariable, value) then return false end + self:RefreshResults() + if searchFrame and searchFrame.editBox + and not (navFrame and navFrame:IsKeyboardEnabled()) then + searchFrame.editBox.blockFocus = nil + searchFrame.editBox:SetFocus() + end + return true +end + -- Open the Settings panel to a setting (slider/dropdown/etc.) without -- closing the EasyFind search results. Mirrors ToggleSettingCheckbox's -- "stay open" behavior so users can edit one setting in the panel and From 01d372de47e7e3fee172d23e1266195fc7a0d600 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 7 May 2026 17:47:40 -0400 Subject: [PATCH 045/103] Search: pre-warm prefix index after dynamic load The post-login WarmSearchHotPath call was dropped in fe0292c when PLAYER_LOGIN scanning was removed. Without it the prefix index never got built, so every keystroke fell through to a linear scan over the full uiSearchData (~1500 entries once dynamic providers populated). First search after reload felt sluggish for that reason. Build the index lazily at the top of SearchUI as a safety net, and unconditionally pre-warm at the end of LoadDeferredSyncProvidersStaggered and LoadHeavyDynamicSearchDataSync so by the time the user types the fast prefix path is ready. ResetSearchCache already rebuilds when the index was previously built, so subsequent provider refreshes stay in sync automatically. --- Database.lua | 9 +++++++++ DatabaseDynamic.lua | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/Database.lua b/Database.lua index 3b16990..7d14987 100644 --- a/Database.lua +++ b/Database.lua @@ -4104,6 +4104,15 @@ function Database:SearchUI(query, skipCategories) return resultsBuf end + -- First-call build: without a ready prefix index every search falls + -- through to a linear scan over uiSearchData. After dynamic providers + -- populate that's ~1500+ entries per keystroke. Build once on demand + -- so the FIRST search after reload doesn't pay the linear-scan cost + -- on every keystroke that follows. + if not prefixIndexReady then + self:BuildSearchPrefixIndex() + end + query = slower(query) local queryLen = #query diff --git a/DatabaseDynamic.lua b/DatabaseDynamic.lua index 368a7ea..3fc9acf 100644 --- a/DatabaseDynamic.lua +++ b/DatabaseDynamic.lua @@ -157,6 +157,10 @@ function Database:LoadDeferredSyncProvidersStaggered() self._dynamicBatchLoading = false self._dynamicBatchChanged = false if changed and self.ResetSearchCache then self:ResetSearchCache() end + -- Pre-warm the prefix index off the loaded dataset so the very + -- first user search doesn't pay the build cost on the keystroke. + -- Cheap if already ready; rebuilds via ResetSearchCache otherwise. + if self.WarmSearchHotPath then self:WarmSearchHotPath() end end step() end @@ -195,6 +199,7 @@ function Database:LoadHeavyDynamicSearchDataSync() end end if self.ResetSearchCache then self:ResetSearchCache() end + if self.WarmSearchHotPath then self:WarmSearchHotPath() end end function Database:LoadHeavyDynamicSearchData(onDone) From b30b11cbde7b7ab7cfb0f5607e07affb7a2174ce Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 7 May 2026 17:47:41 -0400 Subject: [PATCH 046/103] Minimap button: don't let autoHide eat the toggle click Clicking the minimap icon while the bar was visible used to close and immediately reopen it. The bar's GLOBAL_MOUSE_DOWN autoHide fires on mouseDown and saw the click as "outside the bar", so it ran Hide. The button's OnClick (mouseUp) then ran Toggle, which saw a hidden bar and showed it again -- net effect: the click did nothing. In autoHide, consult GetMouseFoci (the same focus stack WoW uses to dispatch the click) and skip when EasyFindMinimapButton is in it. As a backup, the button sets EasyFind._minimapClickActive in OnMouseDown and clears it in OnMouseUp, so even if event order races on a given frame the click is recognized. --- Core.lua | 15 +++++++++++++++ UI.lua | 23 +++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/Core.lua b/Core.lua index f23da27..96e7781 100644 --- a/Core.lua +++ b/Core.lua @@ -765,6 +765,21 @@ local function CreateMinimapButton() mmBtn:SetHighlightTexture(136477) mmBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + -- The search bar's autoHide handler fires on GLOBAL_MOUSE_DOWN before + -- our OnClick (which runs on mouseUp). Without these flags it would + -- close the bar first, then OnClick's toggle would see a hidden bar + -- and re-open it -- net effect: the click does nothing. Setting the + -- flag synchronously in OnMouseDown lets autoHide skip this click. + mmBtn:HookScript("OnMouseDown", function(self, button) + if button == "LeftButton" then + EasyFind._minimapClickActive = true + end + end) + mmBtn:HookScript("OnMouseUp", function(self, button) + if button == "LeftButton" then + EasyFind._minimapClickActive = nil + end + end) mmBtn:SetScript("OnClick", function(self, button) if button == "LeftButton" then EasyFind:ToggleSearchUI() diff --git a/UI.lua b/UI.lua index 963d0f1..9e19b8f 100644 --- a/UI.lua +++ b/UI.lua @@ -1714,6 +1714,29 @@ function UI:CreateSearchFrame() searchFrame:HookScript("OnEvent", function(self, event) if event ~= "GLOBAL_MOUSE_DOWN" then return end if not EasyFind.db.autoHide then return end + -- Minimap button click is in flight: skip autoHide so the button's + -- own OnClick toggle is the only state change. Set in OnMouseDown + -- of the minimap button (Core.lua), cleared in OnMouseUp. + if EasyFind._minimapClickActive then return end + -- WoW's actual click-target focus stack. Use this rather than + -- IsMouseOver: GetMouseFoci is what the click dispatch itself + -- uses, so a frame in this list is guaranteed to receive the + -- click. Catches the minimap button (and any other "click target + -- that should NOT close the bar") even if our OnMouseDown flag + -- races against this event handler. + local mmBtn = _G["EasyFindMinimapButton"] + if mmBtn then + if GetMouseFoci then + local foci = GetMouseFoci() + if foci then + for i = 1, #foci do + if foci[i] == mmBtn then return end + end + end + elseif GetMouseFocus and GetMouseFocus() == mmBtn then + return + end + end if self:IsMouseOver() then return end if resultsFrame and resultsFrame:IsShown() and resultsFrame:IsMouseOver() then return end if activeKeybindBtn then return end From 0d6405d88ffbfdad54eef6bdcc197059f6398487 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 7 May 2026 18:52:21 -0400 Subject: [PATCH 047/103] Highlight: clear when target hides or ScrollBox repurposes it A 0.1s OnUpdate watcher on the highlight frame polls the target's IsVisible() each tick. IsVisible (unlike IsShown) reflects parent-chain visibility so cascade-hides -- closing PlayerSpellsFrame, switching CharacterFrame tabs, leaving a panel -- clear the highlight that would otherwise stay anchored to a now-invisible button. The watcher also calls an optional validator passed to HighlightFrame. The spellbook reveal path passes a SpellFrameMatchesSelf closure: when the user pages the spellbook, the ScrollBox button pool repurposes the same physical button for a different spell. The frame stays visible but no longer represents the target, so the validator returns false and the highlight clears instead of jumping to the unrelated spell. Pet Dismiss: search rows for a /dismisspet quick action. New static entry under category "Action" with slashCommand = "/dismisspet". UI's secure-attribute mapping sets type=macro + macrotext=slashCommand on those rows; SelectResult early-returns so we don't try to start a guide. FlattenTree passes slashCommand through. Gear filter: the row opens gearOptionsPopup on hover but had no chevron because the chevron logic only fired for flyoutSubFilters. Adds a hasFlyout opt-in flag the loot row uses; chevron now also appears for flyouts that are built inline (not via flyoutSubFilters). --- Database.lua | 15 +++++++++++++++ Highlight.lua | 38 +++++++++++++++++++++++++++++++++++++- UI.lua | 32 ++++++++++++++++++++++++++------ 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/Database.lua b/Database.lua index 7d14987..5779b6e 100644 --- a/Database.lua +++ b/Database.lua @@ -2364,6 +2364,7 @@ function Database:FlattenTree(tree, parentPath, parentSteps, parentButtonFrame, if node.icon then entry.icon = node.icon end if node.available then entry.available = node.available end if node.canQueue then entry.canQueue = true end + if node.slashCommand then entry.slashCommand = node.slashCommand end uiSearchData[#uiSearchData + 1] = entry @@ -3281,6 +3282,20 @@ function Database:BuildUIDatabase() icon = 134939, steps = {{ customText = "Click the clock/time display on your minimap to open the Calendar" }}, }, + + -- Quick action: dismiss any active companion pet via /dismisspet. + -- The slashCommand field maps to a secure macrotext attribute on + -- the result row, so clicking runs the command directly. No need + -- for the user to remember the slash command -- searching for + -- "dismiss" or "pet" surfaces it. + { + name = "Dismiss Pet", + keywords = {"dismiss", "dismiss pet", "pet", "companion", "summon", + "battle pet", "critter", "minion"}, + category = "Action", + icon = 132599, -- INV_Misc_Pet_01 silhouette + slashCommand = "/dismisspet", + }, } -- Flatten the tree into the flat uiSearchData array diff --git a/Highlight.lua b/Highlight.lua index 9eae51b..d5f8435 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -81,6 +81,32 @@ function Highlight:CreateHighlightFrame() alpha:SetToAlpha(0.3) alpha:SetDuration(0.5) highlightFrame.animGroup = animGroup + + -- Visibility + identity watcher. Throttled to 0.1s so it's not a + -- per-frame cost. + -- IsVisible() (unlike IsShown) reflects parent-chain visibility, so + -- cascade-hides (panel close, tab switch) trigger a clear even + -- though OnHide doesn't fire on each child. + -- _targetValidator catches the case where the target frame is still + -- visible but no longer represents the original target -- e.g. a + -- ScrollBox button repurposed for a different spell after the user + -- pages the spellbook. Caller passes a closure that re-checks + -- identity (SpellFrameMatchesSelf etc.). + local watchAccum = 0 + highlightFrame:HookScript("OnUpdate", function(self, elapsed) + watchAccum = watchAccum + elapsed + if watchAccum < 0.1 then return end + watchAccum = 0 + local target = self._targetFrame + if target and target.IsVisible and not target:IsVisible() then + Highlight:HideHighlight() + return + end + local validator = self._targetValidator + if validator and not validator(target) then + Highlight:HideHighlight() + end + end) end function Highlight:CreateIndicatorFrame() @@ -2395,12 +2421,20 @@ function Highlight:GetSideTabButton(frameName, sideTabIndex) return nil end -function Highlight:HighlightFrame(frame, instructionText) +function Highlight:HighlightFrame(frame, instructionText, validator) if not frame or not frame:IsShown() then self:HideHighlight() return end + -- Track this target so the visibility watcher can hide the highlight + -- when the user closes the panel / switches tabs / changes pages and + -- the target frame stops being visible. The OnUpdate poller in + -- CreateHighlightFrame handles cascade-hides AND identity changes + -- (a ScrollBox button repurposed for a different spell after paging). + highlightFrame._targetFrame = frame + highlightFrame._targetValidator = validator + local bs = highlightFrame.borderSize local pad = 4 @@ -2471,6 +2505,8 @@ end function Highlight:HideHighlight() highlightShownAt = nil if highlightFrame then + highlightFrame._targetFrame = nil + highlightFrame._targetValidator = nil highlightFrame:Hide() if highlightFrame.animGroup then highlightFrame.animGroup:Stop() end end diff --git a/UI.lua b/UI.lua index 9e19b8f..9c0a6b1 100644 --- a/UI.lua +++ b/UI.lua @@ -1793,9 +1793,11 @@ local UI_FILTER_OPTIONS = { { key = "currencies", label = "Currencies", iconTex = 136452 }, -- Gear: treasure-chest icon from the Encounter Journal loot tab -- spritesheet (texture 522972) for visual consistency with the - -- in-game loot UI. + -- in-game loot UI. hasFlyout flags the row to draw the chevron -- + -- the actual flyout (difficulty, spec, iLvl) is built inline below + -- via gearOptionsPopup, not via flyoutSubFilters. { key = "loot", label = "Gear", iconTex = 522972, - iconCoords = { 0.730, 0.824, 0.618, 0.660 } }, + iconCoords = { 0.730, 0.824, 0.618, 0.660 }, hasFlyout = true }, { key = "map", label = "Map Search", iconTex = 1121272, iconCoords = { 0.3457, 0.3856, 0.2549, 0.2951 } }, { key = "options", label = "Options", iconTex = 1121272, @@ -2370,8 +2372,10 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- Right-pointing chevron on rows that have a flyout, signalling -- the row expands to the right. Mirrors the standard submenu - -- indicator used elsewhere in the WoW UI. - if opt.flyoutSubFilters then + -- indicator used elsewhere in the WoW UI. flyoutSubFilters drives + -- the auto-built sub-filter popup; hasFlyout opts in rows whose + -- flyout is built inline below (Gear → gearOptionsPopup). + if opt.flyoutSubFilters or opt.hasFlyout then local chev = row:CreateTexture(nil, "OVERLAY") chev:SetAtlas("common-icon-forwardarrow") chev:SetSize(ICON_SIZE - 2, ICON_SIZE - 2) @@ -5684,6 +5688,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) elseif data and data.macroIndex and data.category == "Macro" and data.macroBody and data.macroBody ~= "" then newType, newKey, newVal = "macro", "macrotext", data.macroBody + elseif data and data.slashCommand then + newType, newKey, newVal = "macro", "macrotext", data.slashCommand end if resultRow._lastAttrType ~= newType or resultRow._lastAttrKey ~= newKey @@ -8231,10 +8237,19 @@ function UI:OpenAbilityInSpellbook(data) end end + -- Validator passed to HighlightFrame's watcher. ScrollBox button + -- pools repurpose the same physical button for different spells + -- when the user pages the spellbook, so the frame stays visible + -- but stops representing the search target. This re-checks the + -- spell identity each tick and clears the highlight on mismatch. + local function stillRepresentsTarget(f) + return SpellFrameMatchesSelf(f, data) + end + if targetElement then local elementBtn = FindVisibleButtonForElement(paged, targetElement) if elementBtn and highlight then - highlight:HighlightFrame(elementBtn) + highlight:HighlightFrame(elementBtn, nil, stillRepresentsTarget) HideHighlightOnHover(elementBtn) return end @@ -8242,7 +8257,7 @@ function UI:OpenAbilityInSpellbook(data) local btn = FindSpellbookButton(paged, data, false) if btn and highlight then - highlight:HighlightFrame(btn) + highlight:HighlightFrame(btn, nil, stillRepresentsTarget) HideHighlightOnHover(btn) return end @@ -8284,6 +8299,11 @@ function UI:SelectResult(data, forceGuide) self:HideResults() end + -- Slash-command actions (e.g. Pet Dismiss → /dismisspet) fire via + -- the secure macrotext attribute set when the row was rendered. The + -- click already ran the command; nothing else for SelectResult to do. + if data.slashCommand then return end + -- Transmogrification panel: load and show TransmogFrame if data.steps and data.steps[1] and data.steps[1].loadTransmog then From c1bed54f031e248e8b6e28b1cd6881574b8d2a69 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 7 May 2026 18:58:15 -0400 Subject: [PATCH 048/103] Talents: searchable + click to highlight node in talents tree PopulateDynamicTalents walks the active class talent config via C_ClassTalents.GetActiveConfigID + C_Traits and emits one search entry per selectable talent. Choice nodes (pick one of two) emit both entries so either branch is reachable by name. Each entry's steps end with a talentNodeID step. Highlight's UpdateGuide gains a branch that enumerates PlayerSpellsFrame.TalentsFrame.ButtonsParent children, matches against GetNodeID() / .nodeID, and highlights the button. Validator closure passes to HighlightFrame so the watcher clears the highlight if the user navigates away from the talents tree. Filter: new "Talents" entry in UI_FILTER_OPTIONS, maps category="Talent" to the "talents" bucket so the filter checkbox toggles them on/off. Whitelisted C_ClassTalents and C_Traits in .luacheckrc. Talent loadouts (saved builds, click to activate) not included -- that's a separate provider still on the queue. --- .luacheckrc | 6 ++- Database.lua | 90 +++++++++++++++++++++++++++++++++++++++++++++ DatabaseDynamic.lua | 1 + Highlight.lua | 42 +++++++++++++++++++++ UI.lua | 4 ++ 5 files changed, 141 insertions(+), 2 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index b722076..08e6b84 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -70,11 +70,13 @@ read_globals = { "Menu", "ScrollBoxConstants", -- C_* namespaces - "C_AddOns", "C_AchievementInfo", "C_AreaPoiInfo", "C_CurrencyInfo", "C_EquipmentSet", "C_Item", + "C_AddOns", "C_AchievementInfo", "C_AreaPoiInfo", "C_ClassTalents", + "C_CurrencyInfo", "C_EquipmentSet", "C_Item", "C_EncounterJournal", "C_GossipInfo", "C_Heirloom", "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_TransmogCollection", "C_TransmogOutfitInfo", "C_TransmogSets", "C_VignetteInfo", + "C_ToyBox", "C_Traits", "C_TransmogCollection", "C_TransmogOutfitInfo", + "C_TransmogSets", "C_VignetteInfo", -- UI utility functions "UIFrameFadeIn", "UIFrameFadeOut", "UIFrameFadeRemoveFrame", diff --git a/Database.lua b/Database.lua index 5779b6e..d5f53ba 100644 --- a/Database.lua +++ b/Database.lua @@ -2229,6 +2229,96 @@ function Database:PopulateDynamicBossesAsync(done) end) end +-- Enumerate the player's class/spec talent tree(s) via C_Traits and +-- emit one search entry per selectable talent. Each entry remembers the +-- nodeID + entryID so the click handler can scroll the talents tree +-- into view, find the matching node button under PlayerSpellsFrame. +-- TalentsFrame.ButtonsParent, and highlight it. +function Database:PopulateDynamicTalents() + RemoveEntriesByCategory("Talent") + if self.ResetSearchCache then self:ResetSearchCache() end + + if not C_ClassTalents or not C_ClassTalents.GetActiveConfigID + or not C_Traits or not C_Traits.GetConfigInfo + or not C_Traits.GetTreeNodes or not C_Traits.GetNodeInfo + or not C_Traits.GetEntryInfo or not C_Traits.GetDefinitionInfo then + return false + end + local configID = C_ClassTalents.GetActiveConfigID() + if not configID then return false end + + local cfgOk, configInfo = pcall(C_Traits.GetConfigInfo, configID) + if not cfgOk or type(configInfo) ~= "table" or type(configInfo.treeIDs) ~= "table" then + return false + end + + local seen = {} + + local function injectEntry(treeID, nodeID, entryID, isChoice) + local eok, entryInfo = pcall(C_Traits.GetEntryInfo, configID, entryID) + if not eok or type(entryInfo) ~= "table" then return end + local defID = entryInfo.definitionID + if not defID then return end + local dok, defInfo = pcall(C_Traits.GetDefinitionInfo, defID) + if not dok or type(defInfo) ~= "table" then return end + + local spellID = defInfo.spellID or defInfo.overriddenSpellID + local name = defInfo.overrideName + local icon + if spellID and C_Spell and C_Spell.GetSpellInfo then + local sok, spellInfo = pcall(C_Spell.GetSpellInfo, spellID) + if sok and spellInfo then + if not name or name == "" then name = spellInfo.name end + icon = spellInfo.iconID + end + end + if not name or name == "" then return end + if seen[name .. "|" .. (spellID or 0)] then return end + seen[name .. "|" .. (spellID or 0)] = true + + local nameLower = slower(name) + local kw = { "talent", "talents", "spec", nameLower } + uiSearchData[#uiSearchData + 1] = { + name = name, + nameLower = nameLower, + keywords = kw, + keywordsLower = kw, + category = "Talent", + icon = icon, + spellID = spellID, + spellName = name, + talentConfigID = configID, + talentTreeID = treeID, + talentNodeID = nodeID, + talentEntryID = entryID, + talentIsChoice = isChoice or false, + buttonFrame = "PlayerSpellsMicroButton", + path = { "Talents" }, + steps = { + { buttonFrame = "PlayerSpellsMicroButton" }, + { waitForFrame = "PlayerSpellsFrame", tabIndex = 2 }, + { talentNodeID = nodeID, talentTreeID = treeID }, + }, + } + end + + for _, treeID in ipairs(configInfo.treeIDs) do + local nok, nodeIDs = pcall(C_Traits.GetTreeNodes, treeID) + if nok and type(nodeIDs) == "table" then + for _, nodeID in ipairs(nodeIDs) do + local niok, nodeInfo = pcall(C_Traits.GetNodeInfo, configID, nodeID) + if niok and type(nodeInfo) == "table" and type(nodeInfo.entryIDs) == "table" then + local isChoice = #nodeInfo.entryIDs > 1 + for _, entryID in ipairs(nodeInfo.entryIDs) do + injectEntry(treeID, nodeID, entryID, isChoice) + end + end + end + end + end + return true +end + -- Inject one entry per unique item carried in the player's bags. The -- entry stores the first occupied location so guide mode can highlight -- the right slot; drag-to-pickup uses the item ID to put the item on diff --git a/DatabaseDynamic.lua b/DatabaseDynamic.lua index 3fc9acf..2d537e9 100644 --- a/DatabaseDynamic.lua +++ b/DatabaseDynamic.lua @@ -17,6 +17,7 @@ local dynamicProviders = { { key = "gearSets", category = "Gear Set", fn = "PopulateDynamicGearSets" }, { key = "macros", category = "Macro", fn = "PopulateDynamicMacros" }, { key = "abilities", category = "Ability", fn = "PopulateDynamicAbilities" }, + { key = "talents", category = "Talent", fn = "PopulateDynamicTalents" }, { key = "bags", category = "Bag", fn = "PopulateDynamicBags" }, { key = "transmogSets", category = "Appearance Set", fn = "PopulateDynamicTransmogSets", pre = "SyncTransmogSetFiltersFromUI" }, { key = "loot", category = "Loot", fn = "PopulateDynamicLoot", asyncFn = "PopulateDynamicLootAsync" }, diff --git a/Highlight.lua b/Highlight.lua index d5f8435..0c14059 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -1472,6 +1472,48 @@ function Highlight:UpdateGuide() return end + -- Talent node: enumerate ButtonsParent children and find the + -- button whose nodeID matches. Talent buttons expose GetNodeID() + -- (and/or carry the id on .nodeID), so scan once and call + -- HighlightFrame with a validator so the watcher clears if the + -- user navigates away. Pass a closure that re-checks the nodeID. + if step.talentNodeID then + local talentsFrame = PlayerSpellsFrame and PlayerSpellsFrame.TalentsFrame + local parent = talentsFrame and talentsFrame.ButtonsParent + if not (parent and parent.GetChildren) then + return + end + local targetNode = step.talentNodeID + local function nodeIDOf(btn) + if not btn then return nil end + if btn.GetNodeID then + local ok, id = pcall(btn.GetNodeID, btn) + if ok then return id end + end + return btn.nodeID + end + local match + local children = { parent:GetChildren() } + for ci = 1, #children do + if nodeIDOf(children[ci]) == targetNode then + match = children[ci] + break + end + end + if match then + self:HighlightFrame(match, nil, function(f) + return nodeIDOf(f) == targetNode + end) + if canHoverDismiss() and match:IsMouseOver() then + self:Cancel() + return + end + else + self:ShowInstruction(step.text or "Look for this talent in the talents tree") + end + return + end + -- Text-only final step (when we've navigated but can't highlight specific element) if step.text and not step.regionFrames and not step.regionFrame and not step.searchButtonText then self:ShowInstruction(step.text) diff --git a/UI.lua b/UI.lua index 9c0a6b1..14cee80 100644 --- a/UI.lua +++ b/UI.lua @@ -1808,6 +1808,9 @@ local UI_FILTER_OPTIONS = { } }, { key = "reputations", label = "Reputations", iconTex = 1121272, iconCoords = { 0.3783, 0.4072, 0.9066, 0.9350 } }, + -- Talents: leaf icon from the talents atlas spritesheet (4556093), + -- visually consistent with the in-game talent tree. + { key = "talents", label = "Talents", iconAtlas = "talents-icon-learnableplus" }, -- Title icon from PaperDollSidebarTab2 (Titles tab) spritesheet 514608. { key = "titles", label = "Titles", iconTex = 514608, iconCoords = { 0.016, 0.531, 0.324, 0.461 } }, @@ -1840,6 +1843,7 @@ local UI_BUCKET_BY_CATEGORY = { ["Reputation"] = "reputations", ["Bag"] = "bags", ["Macro"] = "macros", + ["Talent"] = "talents", ["Game Settings"] = "gameOptions", ["AddOn Settings"] = "addonOptions", } From 97e53d7d93219bd92c4068666dd72916951ba6b5 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 7 May 2026 19:09:08 -0400 Subject: [PATCH 049/103] Talents: proper icon, convention layout, tooltip, instant nav - FLAT_CATEGORY_ICONS.talent uses the talents-node-circle-gray atlas instead of the green-plus glyph that was placeholder-y. - Talent rows now follow the collection-style convention: category icon on the left (talent badge), spell icon on the right -- mirrors abilities/mounts/toys. - Row OnEnter shows the spell tooltip (SetSpellByID) for talent and ability rows so the user gets the full talent description. - Click on a talent row routes through OpenTalentInTalentsTab, which prefers PlayerSpellsUtil.TogglePlayerSpellsFrame(2) for an atomic open-to-Talents-tab. Then hands off the talentNodeID highlight step to Highlight:StartGuideAtStep so the matching node highlights as soon as the tree renders. Previously the click did nothing because the generic spellID branch in SelectResult swallowed the call. - Secure cast mapping skips data.category == "Talent" -- talents share the spellID field but should never auto-cast on click. --- UI.lua | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/UI.lua b/UI.lua index 14cee80..3601b70 100644 --- a/UI.lua +++ b/UI.lua @@ -78,6 +78,7 @@ local FLAT_CATEGORY_ICONS = { -- icon (spell icon / boss portrait) is pushed to the RIGHT side. ability = { tex = 522972, coords = { 0.904, 0.996, 0.707, 0.748 } }, boss = { tex = 522972, coords = { 0.855, 0.949, 0.524, 0.566 } }, + talent = { atlas = "talents-node-circle-gray" }, macro = { tex = "Interface\\MacroFrame\\MacroFrame-Icon" }, bag = { atlas = "bag-main" }, loot = { tex = 522972, coords = { 0.730, 0.824, 0.618, 0.660 } }, @@ -133,6 +134,7 @@ local function GetFlatCategoryIcon(data) if data.heirloomItemID then return FLAT_CATEGORY_ICONS.heirloom end if data.transmogSetID then return FLAT_CATEGORY_ICONS.appearanceSet end if data.spellID and data.category == "Ability" then return FLAT_CATEGORY_ICONS.ability end + if data.category == "Talent" then return FLAT_CATEGORY_ICONS.talent end if data.encounterID and data.category == "Boss" then return FLAT_CATEGORY_ICONS.boss end if data.macroIndex and data.category == "Macro" then return FLAT_CATEGORY_ICONS.macro end if data.bagID and data.category == "Bag" then return FLAT_CATEGORY_ICONS.bag end @@ -4851,6 +4853,20 @@ function UI:CreateResultButton(index) resultRow:SetScript("OnEnter", function(self) -- Hover-based action hint (mirrors keyboard selection hint). ApplyActionHint(self) + -- Talent / Ability rows: show the spell tooltip (talents share the + -- spell tooltip surface). Mirrors the icon-OnEnter path so the row + -- itself produces a tooltip even when the cursor is on the name. + if self.data and self.data.spellID + and (self.data.category == "Talent" or self.data.category == "Ability") then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + if GameTooltip.SetSpellByID then + GameTooltip:SetSpellByID(self.data.spellID) + else + GameTooltip:SetHyperlink("spell:" .. self.data.spellID) + end + GameTooltip:Show() + return + end -- Keybinding row: show the action name plus current bindings. if self.data and self.data.settingType == "keybind" and self.data.bindingAction then local action = self.data.bindingAction @@ -5685,7 +5701,12 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) newType, newKey, newVal = "macro", "macrotext", "/cancelform [form]" elseif data and data.outfitID then newType, newKey, newVal = "action", "action", 0 - elseif data and data.spellID and not IsSpellbookOnlyAbility(data) then + elseif data and data.spellID and data.category ~= "Talent" + and not IsSpellbookOnlyAbility(data) then + -- Talents share the spellID field but should never cast + -- on click -- the click navigates to the talents tree + -- and highlights the node. Skip the secure cast type so + -- only PostClick / SelectResult handle the talent path. newType, newKey, newVal = "spell", "spell", data.spellName or data.spellID elseif data and data.itemID and data.category == "Bag" then newType, newKey, newVal = "item", "item", data.name @@ -6160,7 +6181,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 or data.outfitID or data.heirloomItemID or data.transmogSetID or (data.spellID and data.category == "Ability") or (data.encounterID and data.category == "Boss") or (data.macroIndex and data.category == "Macro") or (data.bagID and data.category == "Bag")) then + elseif not iconSet and data and (data.mountID or data.toyItemID or data.petID or data.outfitID or data.heirloomItemID or data.transmogSetID or (data.spellID and data.category == "Ability") or (data.spellID and data.category == "Talent") or (data.encounterID and data.category == "Boss") or (data.macroIndex and data.category == "Macro") or (data.bagID and data.category == "Bag")) then local iconFileID = data.icon local rightOffset = -5 @@ -6745,7 +6766,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local d = entry.data local rightSideIcon = d and (d.mountID or d.toyItemID or d.petID or d.outfitID or d.transmogSetID or d.category == "Currency" - or (d.itemID and d.category == "Loot")) + or (d.itemID and d.category == "Loot") + or (d.spellID and d.category == "Talent")) if rightSideIcon then local rightSize = entryRowH - 20 if rightSize < (theme.iconSize or 16) then @@ -6769,7 +6791,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local d = data local mainIconOnRight = d and (d.mountID or d.toyItemID or d.petID or d.outfitID or d.transmogSetID or d.category == "Currency" - or (d.itemID and d.category == "Loot")) + or (d.itemID and d.category == "Loot") + or (d.spellID and d.category == "Talent")) local leftAnchor if catShown then @@ -8166,6 +8189,51 @@ local function FindSpellbookButton(root, target, scroll, candidate) return nil end +-- Open PlayerSpellsFrame to the Talents tab (2) and highlight the +-- talent node matching data.talentNodeID. Mirrors OpenAbilityInSpellbook's +-- shape: prefer PlayerSpellsUtil.TogglePlayerSpellsFrame(tabID) for an +-- atomic open-to-tab; fall back to clicking the micro button + tab. +-- The talent step's own Highlight branch (in Highlight.lua) finds the +-- node button by GetNodeID() and highlights it once the tree is up. +function UI:OpenTalentInTalentsTab(data) + local highlight = ns.Highlight + local TALENTS_TAB = 2 + + local function ensureFrameOnTab(attempt) + local frame = _G["PlayerSpellsFrame"] + if not (frame and frame:IsShown()) then + local util = _G.PlayerSpellsUtil + if util and util.TogglePlayerSpellsFrame then + pcall(util.TogglePlayerSpellsFrame, TALENTS_TAB) + else + ClickButton(_G["PlayerSpellsMicroButton"]) + end + if attempt < 30 then + C_Timer.After(0.05, function() ensureFrameOnTab(attempt + 1) end) + end + return + end + if highlight and highlight.IsTabSelected + and not highlight:IsTabSelected("PlayerSpellsFrame", TALENTS_TAB) then + local tab = highlight.GetTabButton + and highlight:GetTabButton("PlayerSpellsFrame", TALENTS_TAB) + if tab then ClickButton(tab) end + if attempt < 30 then + C_Timer.After(0, function() ensureFrameOnTab(attempt + 1) end) + end + return + end + -- Frame open and on Talents tab: hand off the node-highlight step + -- to the Highlight system. data.steps[3] is the talentNodeID step. + if highlight and highlight.StartGuideAtStep then + data.noCourseCorrect = true + highlight:StartGuideAtStep(data, 3) + end + end + + ensureFrameOnTab(1) +end + function UI:OpenAbilityInSpellbook(data) local highlight = ns.Highlight local categoryClicked = false @@ -8440,6 +8508,14 @@ function UI:SelectResult(data, forceGuide) -- Toy: handled by SecureActionButton on mousedown (UseToyByItemID is protected) if data.toyItemID then return end + -- Talents: open Talents tab and highlight the matching node. Routed + -- here (before the generic spellID branch) because talents share the + -- spellID field with abilities but should never cast. + if data.category == "Talent" and data.talentNodeID then + self:OpenTalentInTalentsTab(data) + return + end + if data.spellID then if forceGuide or IsSpellbookOnlyAbility(data) then self:OpenAbilityInSpellbook(data) From 6db9182c523b51a83adfb6570706200d9dc096f9 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 7 May 2026 19:11:23 -0400 Subject: [PATCH 050/103] Map: surface dungeons/raids/delves in UI search The UI search bar's map results path skipped the global instance cache when the cache hadn't been built yet (gated on `if globalInstanceCache then`). The cache is built lazily by GetGlobalInstanceCache, but nothing outside MapTab triggered the build, so dungeons / raids / delves were invisible to UI search until the user opened MapTab manually. Replaced the gate with an unconditional GetGlobalInstanceCache call (which self-builds if nil and returns the cached table on subsequent calls). First UI search that needs instances now triggers the build once; every search after that hits the cache. --- MapSearch.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/MapSearch.lua b/MapSearch.lua index 608c5c8..9c0c23d 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -5849,7 +5849,13 @@ function MapSearch:SearchForUI(query) end end - if globalInstanceCache then + -- Always build/use the instance cache so dungeons / raids / delves + -- show up in UI search alongside zones and POIs. Previously gated + -- on `globalInstanceCache ~= nil`, which meant instances were + -- invisible until something else (e.g. opening MapTab) built the + -- cache. GetGlobalInstanceCache self-builds if nil; the build is + -- cached after the first call. + do local instancePOIs = self:GetGlobalInstanceCache() wipe(reuseUISearchInstanceNameNorm) From ae2a74af4f63d4c38e6b5ce173aaca5f86a9ab74 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 7 May 2026 19:23:25 -0400 Subject: [PATCH 051/103] Result rows: standardize right-side icon size; unusable toy nav Map / dungeon / delve rows now go through the same flat-mode right-side icon sizing path as toys / mounts / talents (entryRowH-20 clamped to theme.iconSize). Previously map rows fell through to rowIconSize and rendered ~4px smaller than the toy icons next to them. Unusable toys (faction-restricted, race-restricted) now route to the ToyBox on click instead of silently no-op'ing through the secure type=toy use. The populator tags entries with isToyboxOnly when IsUsableItem returns false; the secure-attribute mapping skips type=toy for those rows; SelectResult intercepts and dispatches to OpenToyInToyBox, which opens Collections > Toys and runs SetFilterString(toyName) so only the matched toy is visible. Pet Dismiss: /dismisspet alone is hunter/warlock-pet only, so the macrotext now also toggles the active battle pet via SummonPetByGUID(GetSummonedPetGUID()). One click covers both kinds of "pet" the player might mean. --- Database.lua | 26 ++++++++++++++------- UI.lua | 64 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/Database.lua b/Database.lua index d5f53ba..d4e4828 100644 --- a/Database.lua +++ b/Database.lua @@ -963,12 +963,22 @@ function Database:PopulateDynamicToys() if itemID and itemID > 0 then local _, toyName, toyIcon = GetToyInfo(itemID) if toyName and toyName ~= "" then - uiSearchData[#uiSearchData + 1] = setmetatable({ + -- Faction-restricted toys (Horde Only / Alliance Only), + -- race-restricted toys, etc. flunk IsUsableItem. Tag them so + -- the click handler routes to ToyBox highlight instead of + -- attempting a secure use that would silently no-op. + local isUsable = true + if IsUsableItem then + isUsable = IsUsableItem(itemID) and true or false + end + local entry = setmetatable({ name = toyName, icon = toyIcon, toyItemID = itemID, nameLower = slower(toyName), }, TOY_MT) + if not isUsable then entry.isToyboxOnly = true end + uiSearchData[#uiSearchData + 1] = entry end end end @@ -3373,18 +3383,18 @@ function Database:BuildUIDatabase() steps = {{ customText = "Click the clock/time display on your minimap to open the Calendar" }}, }, - -- Quick action: dismiss any active companion pet via /dismisspet. - -- The slashCommand field maps to a secure macrotext attribute on - -- the result row, so clicking runs the command directly. No need - -- for the user to remember the slash command -- searching for - -- "dismiss" or "pet" surfaces it. + -- Quick action: dismiss any active companion. /dismisspet alone + -- only handles hunter/warlock pets, so the macrotext also toggles + -- the active battle-pet companion via SummonPetByGUID with the + -- currently-summoned GUID (which dismisses it). Two lines so one + -- click covers both kinds of "pet" the player might mean. { name = "Dismiss Pet", keywords = {"dismiss", "dismiss pet", "pet", "companion", "summon", "battle pet", "critter", "minion"}, category = "Action", - icon = 132599, -- INV_Misc_Pet_01 silhouette - slashCommand = "/dismisspet", + icon = 132599, + slashCommand = "/dismisspet\n/run local g = C_PetJournal and C_PetJournal.GetSummonedPetGUID and C_PetJournal.GetSummonedPetGUID(); if g then C_PetJournal.SummonPetByGUID(g) end", }, } diff --git a/UI.lua b/UI.lua index 3601b70..2f0ae02 100644 --- a/UI.lua +++ b/UI.lua @@ -5695,7 +5695,10 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- up no-ops here. if not InCombatLockdown() then local newType, newKey, newVal - if data and data.toyItemID then + if data and data.toyItemID and not data.isToyboxOnly then + -- Unusable toys (faction-restricted etc.) skip the + -- secure use type so PostClick can route them to the + -- ToyBox instead of silently no-op'ing on click. newType, newKey, newVal = "toy", "toy", data.toyItemID elseif data and data.mountID then newType, newKey, newVal = "macro", "macrotext", "/cancelform [form]" @@ -6767,7 +6770,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local rightSideIcon = d and (d.mountID or d.toyItemID or d.petID or d.outfitID or d.transmogSetID or d.category == "Currency" or (d.itemID and d.category == "Loot") - or (d.spellID and d.category == "Talent")) + or (d.spellID and d.category == "Talent") + or d.mapSearchResult) if rightSideIcon then local rightSize = entryRowH - 20 if rightSize < (theme.iconSize or 16) then @@ -6792,7 +6796,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local mainIconOnRight = d and (d.mountID or d.toyItemID or d.petID or d.outfitID or d.transmogSetID or d.category == "Currency" or (d.itemID and d.category == "Loot") - or (d.spellID and d.category == "Talent")) + or (d.spellID and d.category == "Talent") + or d.mapSearchResult) local leftAnchor if catShown then @@ -8189,6 +8194,46 @@ local function FindSpellbookButton(root, target, scroll, candidate) return nil end +-- Open the Collections > Toys tab and surface the target toy. For +-- unusable toys (faction-restricted etc.) the secure use no-ops, so we +-- route here instead. SetFilterString filters the ToyBox to just this +-- toy's name -- the cleanest "highlight": the player sees only the toy +-- they were looking for. Restoring the prior filter is left to the +-- player; once they're done they can clear the search field. +function UI:OpenToyInToyBox(data) + if not data or not data.toyItemID then return end + local highlight = ns.Highlight + local TOY_TAB = 3 + + local function step(attempt) + local journal = _G["CollectionsJournal"] + if not (journal and journal:IsShown()) then + local micro = _G["CollectionsMicroButton"] + if micro then ClickButton(micro) end + if attempt < 30 then + C_Timer.After(0.05, function() step(attempt + 1) end) + end + return + end + if highlight and highlight.IsTabSelected + and not highlight:IsTabSelected("CollectionsJournal", TOY_TAB) then + local tab = highlight.GetTabButton + and highlight:GetTabButton("CollectionsJournal", TOY_TAB) + if tab then ClickButton(tab) end + if attempt < 30 then + C_Timer.After(0, function() step(attempt + 1) end) + end + return + end + if C_ToyBox and C_ToyBox.SetFilterString then + C_ToyBox.SetFilterString(data.name or "") + if C_ToyBox.ForceToyRefilter then C_ToyBox.ForceToyRefilter() end + end + end + + step(1) +end + -- Open PlayerSpellsFrame to the Talents tab (2) and highlight the -- talent node matching data.talentNodeID. Mirrors OpenAbilityInSpellbook's -- shape: prefer PlayerSpellsUtil.TogglePlayerSpellsFrame(tabID) for an @@ -8505,8 +8550,17 @@ function UI:SelectResult(data, forceGuide) return end - -- Toy: handled by SecureActionButton on mousedown (UseToyByItemID is protected) - if data.toyItemID then return end + -- Toy: usable toys fire via the SecureActionButton type=toy attribute + -- (set on the row when rendered). Unusable toys (faction-restricted, + -- etc.) fall through here -- route them to the ToyBox so the player + -- can at least see the toy in their collection. Mirrors how unusable + -- abilities navigate to the spellbook instead of attempting a cast. + if data.toyItemID then + if data.isToyboxOnly then + self:OpenToyInToyBox(data) + end + return + end -- Talents: open Talents tab and highlight the matching node. Routed -- here (before the generic spellID branch) because talents share the From 37455bbd26e943915f71097c016528181711be3d Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 7 May 2026 19:26:27 -0400 Subject: [PATCH 052/103] Talents: SearchIcon spyglass; broader nodeID lookup Talent rows used the standard yellow-border highlight (which sometimes failed to anchor because the node-button lookup only checked TalentsFrame.ButtonsParent and missed hero / sub-tree talents). Two fixes: 1) Look up the target button by nodeID via TalentsFrame's own GetTalentButtonByNodeID when present, then fall back to walking ButtonsParent + HeroTalentsContainer + SubTreeContainer with one level of nested children. Hero talents were the main miss. 2) Render via Blizzard's native SearchIcon spyglass on the talent node instead of our yellow border. Matches the in-game talent search visual the player already knows. New HighlightTalentSearch reuses highlightFrame's identity/visibility watcher (so it clears on cascade-hide / repurpose) but hides the yellow border textures while the spyglass is active. HideHighlight restores the textures on cleanup so subsequent normal HighlightFrame calls render correctly. --- Highlight.lua | 99 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 15 deletions(-) diff --git a/Highlight.lua b/Highlight.lua index 0c14059..72728a4 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -1472,17 +1472,14 @@ function Highlight:UpdateGuide() return end - -- Talent node: enumerate ButtonsParent children and find the - -- button whose nodeID matches. Talent buttons expose GetNodeID() - -- (and/or carry the id on .nodeID), so scan once and call - -- HighlightFrame with a validator so the watcher clears if the - -- user navigates away. Pass a closure that re-checks the nodeID. + -- Talent node: prefer TalentsFrame's own button-by-nodeID lookup + -- when available (Midnight's TalentFrameBaseMixin exposes it), and + -- fall back to enumerating known button containers. ButtonsParent + -- holds class talents; hero/sub-tree talents live under their own + -- containers, so we walk each candidate parent. if step.talentNodeID then local talentsFrame = PlayerSpellsFrame and PlayerSpellsFrame.TalentsFrame - local parent = talentsFrame and talentsFrame.ButtonsParent - if not (parent and parent.GetChildren) then - return - end + if not talentsFrame then return end local targetNode = step.talentNodeID local function nodeIDOf(btn) if not btn then return nil end @@ -1492,16 +1489,52 @@ function Highlight:UpdateGuide() end return btn.nodeID end + local match - local children = { parent:GetChildren() } - for ci = 1, #children do - if nodeIDOf(children[ci]) == targetNode then - match = children[ci] - break + -- 1) Direct lookup if the frame exposes it. + if talentsFrame.GetTalentButtonByNodeID then + local ok, btn = pcall(talentsFrame.GetTalentButtonByNodeID, talentsFrame, targetNode) + if ok and btn and btn:IsShown() then match = btn end + end + -- 2) Walk likely button containers as a fallback. + if not match then + local containers = { + talentsFrame.ButtonsParent, + talentsFrame.HeroTalentsContainer, + talentsFrame.SubTreeContainer, + } + for _, parent in ipairs(containers) do + if parent and parent.GetChildren then + local children = { parent:GetChildren() } + for ci = 1, #children do + local child = children[ci] + if nodeIDOf(child) == targetNode then + match = child + break + end + -- One level deeper: hero subtree puts buttons + -- inside a subtree-specific child frame. + if child and child.GetChildren then + local grand = { child:GetChildren() } + for gi = 1, #grand do + if nodeIDOf(grand[gi]) == targetNode then + match = grand[gi] + break + end + end + if match then break end + end + end + if match then break end + end end end + if match then - self:HighlightFrame(match, nil, function(f) + -- Use Blizzard's own SearchIcon spyglass on the talent + -- node instead of our yellow border. Matches the in-game + -- talent search visual the player already recognizes. + self:HighlightTalentSearch(match, function(f) return nodeIDOf(f) == targetNode end) if canHoverDismiss() and match:IsMouseOver() then @@ -2544,13 +2577,49 @@ function Highlight:ShowInstruction(text) instructionFrame:Show() end +-- Talent search: show the Blizzard-native SearchIcon spyglass on the +-- target talent button (matches the in-game talent search visual). +-- Reuses highlightFrame's watcher for visibility / identity tracking +-- but suppresses the yellow border textures and indicator chevron. +function Highlight:HighlightTalentSearch(button, validator) + if not button or not button:IsShown() then + self:HideHighlight() + return + end + self:HideHighlight() + if button.SearchIcon and button.SearchIcon.Show then + button.SearchIcon:Show() + end + highlightFrame._targetFrame = button + highlightFrame._targetValidator = validator + highlightFrame._talentSearchBtn = button + -- Hide the yellow border textures so only the SearchIcon shows. + -- Restored in HideHighlight for the next normal HighlightFrame call. + if highlightFrame.top then highlightFrame.top:Hide() end + if highlightFrame.bottom then highlightFrame.bottom:Hide() end + if highlightFrame.left then highlightFrame.left:Hide() end + if highlightFrame.right then highlightFrame.right:Hide() end + highlightFrame:Show() + if highlightFrame.animGroup then highlightFrame.animGroup:Stop() end +end + function Highlight:HideHighlight() highlightShownAt = nil if highlightFrame then + if highlightFrame._talentSearchBtn + and highlightFrame._talentSearchBtn.SearchIcon then + highlightFrame._talentSearchBtn.SearchIcon:Hide() + end + highlightFrame._talentSearchBtn = nil highlightFrame._targetFrame = nil highlightFrame._targetValidator = nil highlightFrame:Hide() if highlightFrame.animGroup then highlightFrame.animGroup:Stop() end + -- Restore yellow border textures hidden by HighlightTalentSearch. + if highlightFrame.top then highlightFrame.top:Show() end + if highlightFrame.bottom then highlightFrame.bottom:Show() end + if highlightFrame.left then highlightFrame.left:Show() end + if highlightFrame.right then highlightFrame.right:Show() end end if indicatorFrame then indicatorFrame:Hide() From ce679714618cd3895cb0cc69d9bda7a2779fa3b2 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 8 May 2026 08:45:45 -0400 Subject: [PATCH 053/103] Lint: allowlist achievement and token globals --- .luacheckrc | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.luacheckrc b/.luacheckrc index 08e6b84..c41ef29 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -46,6 +46,7 @@ read_globals = { "UnitFactionGroup", "UnitPosition", "StaticPopup_Show", "GameTooltip_Hide", "PlaySound", "ReloadUI", "GetSpellInfo", "GetItemInfo", "UseToyByItemID", + "GetMacroSpell", "GetMacroItem", "PanelTemplates_GetSelectedTab", -- Frames and UI objects @@ -54,7 +55,7 @@ read_globals = { "CharacterStatsPane", "PaperDollTitlesPane", "PaperDollEquipmentManagerPane", "CurrencyFrame", "SpellBookFrame", "PlayerSpellsFrame", "CollectionsJournal", - "PVEFrame", "ReputationFrame", "TokenFrame", + "PVEFrame", "ReputationFrame", "TokenFrame", "TokenFramePopup", "GroupFinderFrame", "LFGListFrame", "HelpFrame", "ClassTalentFrame", "GameMenuFrame", "MacroFrame", "GuildMicroButton", "StoreMicroButton", "PlayerFrame", "StoreFrame", @@ -64,6 +65,15 @@ read_globals = { "PVPQueueFrame", "AchievementFrameCategories_ExpandToCategory", "AchievementFrameCategories_UpdateDataProvider", + "AchievementFrame_LoadUI", "AchievementFrame_SelectAchievement", + "OpenAchievementFrameToAchievement", + "AchievementFrame", "GetCategoryList", "GetCategoryNumAchievements", + "GetAchievementInfo", + "SetAchievementSearchString", "GetNumFilteredAchievements", + "GetFilteredAchievementID", + "AddTrackedAchievement", "RemoveTrackedAchievement", + "IsTrackedAchievement", "AchievementFrame_ToggleTracking", + "C_ContentTracking", "Transmog_LoadUI", "EncounterJournal_LoadUI", "EncounterJournal_DisplayInstance", "EncounterJournal_DisplayEncounter", "PanelTemplates_SetTab", From 00533e7ac366f7ac366d108db339bb7e7d0cee5c Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 8 May 2026 08:46:03 -0400 Subject: [PATCH 054/103] MapTab: restore Blizzard displayMode on hide Stash QuestMapFrame's displayMode when our panel takes over and put it back when we hide. Without this, closing the map while our panel was active leaves displayMode nil and the next reopen renders a blank sidebar. --- MapTab.lua | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/MapTab.lua b/MapTab.lua index 9b6f3b8..d000533 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -112,6 +112,11 @@ local RefreshCurrentSearch local ReleaseMapTabMemory local panel local selectedIsOurs = false +-- The Blizzard QuestMapFrame display mode that was active when our panel +-- took over. SetDisplayMode(nil) clears qmf.displayMode, so when the map +-- closes + reopens, Blizzard has no mode to restore and the sidebar +-- renders blank. We stash the prior mode here and put it back on hide. +local prevBlizzardDisplayMode local rowPool = {} local headerPool = {} local rowPoolCursor = 1 @@ -244,6 +249,7 @@ local function ShowOurPanel() -- uses. Without it, clicking a Blizzard tab afterwards is a no-op -- (same-mode transition) and the panel stays hidden. if qmf.SetDisplayMode then + prevBlizzardDisplayMode = qmf.displayMode local ok, err = pcall(qmf.SetDisplayMode, qmf) if not ok then if qmf.QuestsFrame then qmf.QuestsFrame:Hide() end @@ -282,6 +288,19 @@ local function HideOurPanel() if panel.outer then panel.outer:Hide() else panel:Hide() end if panel.searchBox then panel.searchBox:ClearFocus() end end + -- Restore Blizzard's display mode if we cleared it. If a Blizzard tab + -- was clicked, qmf.displayMode is already non-nil (the new tab set + -- it), so we leave that alone. Only restore when displayMode is nil, + -- which happens when the map simply closed while our panel was + -- active. Without this, reopening the map shows a blank sidebar. + local qmf = _G["QuestMapFrame"] + if qmf and qmf.SetDisplayMode and qmf.displayMode == nil then + local restore = prevBlizzardDisplayMode or qmf.QuestsFrame + if restore then + pcall(qmf.SetDisplayMode, qmf, restore) + end + end + prevBlizzardDisplayMode = nil -- Tear down any in-flight hover preview when the panel closes so the -- zone outline / pin / saved-state don't survive into another tab. -- Mirrors EndHoverPreview's full cleanup rather than just the From 0dfa1dfe911e5f128b581a1650b99040760ac915 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 8 May 2026 08:46:20 -0400 Subject: [PATCH 055/103] MapSearch: include global instances in local mode Local-mode results now also pull from the global instance cache, so abbreviations like "rfc" surface Ragefire Chasm regardless of current zone. Drop the +200 bonus on cache-promoted zones (already scored on their own merit) and switch keyword scoring to MAX-not-SUM so an entry whose keyword list duplicates the name doesn't double-count the same fuzzy hit. --- MapSearch.lua | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/MapSearch.lua b/MapSearch.lua index 9c0c23d..c44a994 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -4183,7 +4183,7 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) zoneMapType = zone.mapType, zoneParentMapID = zone.parentMapID, pathPrefix = parentPath, - score = zone.score + 200, + score = zone.score, } allPOIs[#allPOIs + 1] = entry end @@ -4336,6 +4336,23 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) tinsert(allPOIs, loc) end end + + -- Local mode normally only sees POIs in the current zone, so + -- abbreviations like "rfc" → Ragefire Chasm fall flat unless + -- the player happens to be standing in Orgrimmar. The UI search + -- bar always pulls the global instance cache; the map search + -- bar should match that for consistency. Add globally-cached + -- dungeon / raid / delve entrances as additional candidates so + -- a name or abbreviation hit surfaces them regardless of + -- current zone. They still get scored alongside local POIs. + local globalInstances = self:GetGlobalInstanceCache() + for _, poi in ipairs(globalInstances) do + local nameLower = GetNameLower(poi) + if not zoneNames[nameLower] and not existingNames[nameLower] then + tinsert(allPOIs, poi) + existingNames[nameLower] = true + end + end end local results = self:SearchPOIs(allPOIs, text) @@ -4555,12 +4572,9 @@ function MapSearch:SearchPOIs(pois, query, noCache) if poi.keywords then if not poi.kwLower then PreparePOI(poi) end - score = score + ns.Database:ScoreKeywords(poi.kwLower, query, #query) - end - -- Instance cache entries promoted to zone-style get the same - -- sorting boost, but only if they matched on their own merit - if poi.isZone and score >= 50 then - score = score + 200 + -- MAX, not SUM, so an entry whose keyword list duplicates + -- the name doesn't double-count the same fuzzy hit. + score = mmax(score, ns.Database:ScoreKeywords(poi.kwLower, query, #query)) end end From 5f7af93190eda246fde00a29e4d4f86da432a86f Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 8 May 2026 08:46:36 -0400 Subject: [PATCH 056/103] Talents: match by name; dismiss highlight on hover Talent navigation now walks the talents-frame tree and matches by GetName() against the guide's talent name, with depth-capped recursion so choice-node options nested under ButtonsParent are reachable. The old nodeID-based lookup was unreliable across talent revisions. Also auto-dismiss the highlight as soon as the cursor enters the highlighted target -- once the player has the thing under the mouse, they've found it and the visual is just clutter. --- Highlight.lua | 201 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 138 insertions(+), 63 deletions(-) diff --git a/Highlight.lua b/Highlight.lua index 72728a4..cbdf314 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -105,6 +105,13 @@ function Highlight:CreateHighlightFrame() local validator = self._targetValidator if validator and not validator(target) then Highlight:HideHighlight() + return + end + -- Hover-to-dismiss: once the player moves the cursor onto the + -- highlighted target, they've found it -- clear the visual so + -- the talent panel / spellbook / etc. isn't cluttered. + if target and target.IsMouseOver and target:IsMouseOver() then + Highlight:HideHighlight() end end) end @@ -1478,65 +1485,57 @@ function Highlight:UpdateGuide() -- holds class talents; hero/sub-tree talents live under their own -- containers, so we walk each candidate parent. if step.talentNodeID then + -- Match by talent NAME against the talent button frames, + -- which are frame-named after their talent (per inspect: + -- "PlayerSpellsFrame.TalentsFrame.ButtonsParent." with + -- the parent path showing "Forestwalk" / "Thick Hide" / + -- etc.). nodeID-based lookup has been unreliable across + -- talent revisions; matching by GetName() works for class + -- talents AND hero talents. local talentsFrame = PlayerSpellsFrame and PlayerSpellsFrame.TalentsFrame if not talentsFrame then return end - local targetNode = step.talentNodeID - local function nodeIDOf(btn) - if not btn then return nil end - if btn.GetNodeID then - local ok, id = pcall(btn.GetNodeID, btn) - if ok then return id end + local targetLower = (currentGuide.name or ""):lower() + + local function nameOf(btn) + if not btn or not btn.GetName then return nil end + local n = btn:GetName() + return n and n:lower() or nil + end + + -- Recursive search: choice-node options nest one extra level + -- below ButtonsParent's direct children, so a fixed 2-level + -- walk misses them. Cap depth to avoid parent loops. + local function searchTree(frame, depth) + if not frame or depth > 5 then return nil end + if frame.SearchIcon and nameOf(frame) == targetLower then + return frame + end + if frame.GetChildren then + local kids = { frame:GetChildren() } + for i = 1, #kids do + local found = searchTree(kids[i], depth + 1) + if found then return found end + end end - return btn.nodeID + return nil end local match - -- 1) Direct lookup if the frame exposes it. - if talentsFrame.GetTalentButtonByNodeID then - local ok, btn = pcall(talentsFrame.GetTalentButtonByNodeID, talentsFrame, targetNode) - if ok and btn and btn:IsShown() then match = btn end - end - -- 2) Walk likely button containers as a fallback. - if not match then - local containers = { - talentsFrame.ButtonsParent, - talentsFrame.HeroTalentsContainer, - talentsFrame.SubTreeContainer, - } - for _, parent in ipairs(containers) do - if parent and parent.GetChildren then - local children = { parent:GetChildren() } - for ci = 1, #children do - local child = children[ci] - if nodeIDOf(child) == targetNode then - match = child - break - end - -- One level deeper: hero subtree puts buttons - -- inside a subtree-specific child frame. - if child and child.GetChildren then - local grand = { child:GetChildren() } - for gi = 1, #grand do - if nodeIDOf(grand[gi]) == targetNode then - match = grand[gi] - break - end - end - if match then break end - end - end - if match then break end - end - end + local containers = { + talentsFrame.ButtonsParent, + talentsFrame.HeroTalentsContainer, + talentsFrame.SubTreeContainer, + } + for _, parent in ipairs(containers) do + match = searchTree(parent, 0) + if match then break end end if match then - -- Use Blizzard's own SearchIcon spyglass on the talent - -- node instead of our yellow border. Matches the in-game - -- talent search visual the player already recognizes. - self:HighlightTalentSearch(match, function(f) - return nodeIDOf(f) == targetNode - end) + if match.SearchIcon and match.SearchIcon.Show then + match.SearchIcon:Show() + end + self:RegisterTalentSearchIcon(match, targetLower, nameOf) if canHoverDismiss() and match:IsMouseOver() then self:Cancel() return @@ -2577,24 +2576,93 @@ function Highlight:ShowInstruction(text) instructionFrame:Show() end --- Talent search: show the Blizzard-native SearchIcon spyglass on the --- target talent button (matches the in-game talent search visual). --- Reuses highlightFrame's watcher for visibility / identity tracking --- but suppresses the yellow border textures and indicator chevron. -function Highlight:HighlightTalentSearch(button, validator) - if not button or not button:IsShown() then +-- SearchIcon registration: caller has already shown button.SearchIcon +-- (Blizzard's native spyglass on talent / spellbook nodes). This hooks +-- the button into highlightFrame's visibility watcher so the spyglass +-- clears on cascade-hide / repurpose / hover-dismiss without us drawing +-- our own yellow border on top. +-- +-- button - the talent / spell button whose SearchIcon was shown. +-- validator - (frame) -> bool. Returns true while the frame still +-- represents the original target; false on identity +-- change (e.g. a ScrollBox row repurposed for a +-- different spell). Optional: pass nil for "always +-- valid until the button hides". +function Highlight:RegisterSearchIconWatch(button, validator) + if not button then return end + -- Clear any previous registration so only one spyglass is live. + if highlightFrame._talentSearchBtn + and highlightFrame._talentSearchBtn ~= button + and highlightFrame._talentSearchBtn.SearchIcon then + highlightFrame._talentSearchBtn.SearchIcon:Hide() + end + highlightFrame._talentSearchBtn = button + highlightFrame._targetFrame = button + highlightFrame._targetValidator = validator + -- Hide the yellow border textures so the watcher's OnUpdate ticks + -- without the standard highlight visuals showing on top of the + -- native spyglass. + if highlightFrame.top then highlightFrame.top:Hide() end + if highlightFrame.bottom then highlightFrame.bottom:Hide() end + if highlightFrame.left then highlightFrame.left:Hide() end + if highlightFrame.right then highlightFrame.right:Hide() end + highlightFrame:Show() + if highlightFrame.animGroup then highlightFrame.animGroup:Stop() end +end + +-- Backwards-compatible alias used by the talent-name search path. +function Highlight:RegisterTalentSearchIcon(button, targetNameLower, nameOf) + self:RegisterSearchIconWatch(button, function(f) + return nameOf and nameOf(f) == targetNameLower + end) +end + +-- Spellbook glow: matches the in-game spellbook-item-unassigned-glow +-- visual without hijacking the real glow texture (which signals +-- "spell not on an action bar"). We attach our own texture child to +-- the button using the same atlas and run a pulse animation, then +-- register with the watcher so it clears on cascade-hide / repurpose +-- / hover-dismiss like the talent spyglass. +function Highlight:HighlightSpellbookSpell(row, validator) + if not row or not row:IsShown() then self:HideHighlight() return end self:HideHighlight() - if button.SearchIcon and button.SearchIcon.Show then - button.SearchIcon:Show() - end - highlightFrame._targetFrame = button + + -- The visible spellbook entry has an inner .Button child holding just + -- the spell icon (~40x39); the parent row container also includes + -- the spell name label and other artwork. Glow only the icon. + local iconBtn = (row.Button and row.Button.IsShown and row.Button:IsShown() and row.Button) or row + + local glow = iconBtn._efSearchGlow + if not glow then + local w, h = iconBtn:GetSize() + if not w or w < 1 then w = 40 end + if not h or h < 1 then h = 39 end + glow = iconBtn:CreateTexture(nil, "OVERLAY", nil, 1) + glow:SetAtlas("spellbook-item-unassigned-glow") + glow:SetPoint("CENTER", iconBtn, "CENTER", 0, 0) + glow:SetSize(w * 1.4, h * 1.4) + glow:SetBlendMode("ADD") + local ag = glow:CreateAnimationGroup() + ag:SetLooping("BOUNCE") + local pulse = ag:CreateAnimation("Alpha") + pulse:SetFromAlpha(0.85) + pulse:SetToAlpha(1.6) + pulse:SetDuration(0.7) + glow._efPulse = ag + iconBtn._efSearchGlow = glow + end + glow:SetAlpha(1.6) + glow:Show() + if glow._efPulse and not glow._efPulse:IsPlaying() then + glow._efPulse:Play() + end + + highlightFrame._spellbookGlowBtn = iconBtn + highlightFrame._targetFrame = row highlightFrame._targetValidator = validator - highlightFrame._talentSearchBtn = button - -- Hide the yellow border textures so only the SearchIcon shows. - -- Restored in HideHighlight for the next normal HighlightFrame call. if highlightFrame.top then highlightFrame.top:Hide() end if highlightFrame.bottom then highlightFrame.bottom:Hide() end if highlightFrame.left then highlightFrame.left:Hide() end @@ -2610,7 +2678,14 @@ function Highlight:HideHighlight() and highlightFrame._talentSearchBtn.SearchIcon then highlightFrame._talentSearchBtn.SearchIcon:Hide() end + if highlightFrame._spellbookGlowBtn + and highlightFrame._spellbookGlowBtn._efSearchGlow then + local g = highlightFrame._spellbookGlowBtn._efSearchGlow + if g._efPulse then g._efPulse:Stop() end + g:Hide() + end highlightFrame._talentSearchBtn = nil + highlightFrame._spellbookGlowBtn = nil highlightFrame._targetFrame = nil highlightFrame._targetValidator = nil highlightFrame:Hide() From 2ef75826a19320b7f57aca454298f4cf89a98762 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 8 May 2026 08:46:51 -0400 Subject: [PATCH 057/103] Settings: skip phantom variables when registry resolved When the live Settings registry resolves at least one curated variable, treat it as authoritative: any unresolved variable is a phantom (CVar removed from the live panel in this client version) and gets skipped instead of injected as a dead row. If nothing resolves, the registry isn't ready yet -- emit everything and let the late re-pass at 3.0s prune. --- BlizzOptionsSearch.lua | 61 ++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index f342756..cfade63 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -806,6 +806,18 @@ local function CollectEntries() -- on demand when the user clicks an entry. ResolveCategoryIDs() + -- Treat the live registry as authoritative: if at least one curated + -- variable resolves, assume the registry is up and any unresolved + -- variable is a phantom entry (CVar removed from the live panel in + -- this client version). Skip those instead of injecting dead rows + -- whose click does nothing. If nothing resolved, the registry + -- isn't ready yet — fall back to emitting all curated entries and + -- let the late re-pass at 3.0s prune. + local registryReady = false + for var in pairs(categoryIDByVariable) do + if var then registryReady = true break end + end + -- Curated individual settings (Auto Loot, Sticky Targeting, etc.). -- These run unconditionally so they're always searchable, even on -- a clean install where Settings.GetCategoryList isn't ready until @@ -818,30 +830,33 @@ local function CollectEntries() local catLower = slower(catName) local resolved = TYPE_MAP[typeCode] or "other" local kw = { "setting", "option", "config", catLower, nameLower } - local catID = GetCategoryIDForVariable(var) or GetCategoryID(catName) - tinsert(entries, { - name = name, - nameLower = nameLower, - keywords = kw, - keywordsLower = kw, - category = "Game Settings", - path = { "Game Settings", catName }, - -- Icon comes from category="Game Settings" cogwheel routing in UI.lua. - settingsCategory = catName, - settingCategoryID = catID, - settingVariable = var, - settingType = resolved, - settingMin = sMin, - settingMax = sMax, - settingStep = sStep, - steps = { - { - settingsCategory = catName, - settingCategoryID = catID, - settingVariable = var, + local resolvedCatID = GetCategoryIDForVariable(var) + if not (registryReady and not resolvedCatID) then + local catID = resolvedCatID or GetCategoryID(catName) + tinsert(entries, { + name = name, + nameLower = nameLower, + keywords = kw, + keywordsLower = kw, + category = "Game Settings", + path = { "Game Settings", catName }, + -- Icon comes from category="Game Settings" cogwheel routing in UI.lua. + settingsCategory = catName, + settingCategoryID = catID, + settingVariable = var, + settingType = resolved, + settingMin = sMin, + settingMax = sMax, + settingStep = sStep, + steps = { + { + settingsCategory = catName, + settingCategoryID = catID, + settingVariable = var, + }, }, - }, - }) + }) + end end -- Top-level + subcategory entries from the live registry. Optional: From d1bfa7c140e8ad411bb201b4f35d60668d247200 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 8 May 2026 08:47:11 -0400 Subject: [PATCH 058/103] Database: warband currency fill, name-weighted scoring, toggles - Inject warband-transferable currencies the player has on other characters (or has never touched) so the All Warband Transferable filter mode shows the full set. - Discount fields-search hits whose match came purely from keyword text with no name overlap, so an item whose keyword list happens to contain the query doesn't outrank the literal entry that has those words in its name. - ScoreName/ScoreKeywords combine via MAX, not SUM. Entries whose keyword list mirrors the name no longer double-count the same fuzzy hit. - Promote loot name and source-keyword (boss/dungeon) prefix scores above unrelated 1-edit fuzzy hits so encounter loot ranks above misspellings of unrelated settings. - Convert World Map / Zone Map / Minimap / Calendar / Game Menu nav rows from "press M..." text hints to slashCommand toggles that actually open or close the panel. - Tag toys via C_ToyBox.IsToyUsable for accurate restricted-toy routing; ScoreEntryFields prefers name matches over keyword matches. --- Database.lua | 211 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 178 insertions(+), 33 deletions(-) diff --git a/Database.lua b/Database.lua index d4e4828..f240e99 100644 --- a/Database.lua +++ b/Database.lua @@ -92,6 +92,35 @@ function Database:Initialize() self:BuildUIDatabase() end +-- Defensive multi-spelling check for "is this currency warband-shared". +-- WoW has shipped at least three names / shapes for this concept across +-- builds; check all of them so detection doesn't regress when Blizzard +-- renames a field. Reused by both the per-currency populator and the +-- warband-fill pass. +function Database:IsCurrencyAccountTransferable(currencyID) + if not currencyID or not C_CurrencyInfo then return false end + local fns = { + C_CurrencyInfo.IsAccountTransferableCurrency, + C_CurrencyInfo.IsAccountWideCurrency, + } + for i = 1, #fns do + local fn = fns[i] + if fn then + local ok, val = pcall(fn, currencyID) + if ok and val then return true end + end + end + if C_CurrencyInfo.GetCurrencyInfo then + local ok, info = pcall(C_CurrencyInfo.GetCurrencyInfo, currencyID) + if ok and type(info) == "table" then + if info.isAccountTransferable then return true end + if info.isAccountWide then return true end + if info.transferPercentage and info.transferPercentage > 0 then return true end + end + end + return false +end + -- Called after PLAYER_LOGIN when C_CurrencyInfo is available -- Scans the WoW currency list and injects any currencies not already in the static database function Database:PopulateDynamicCurrencies() @@ -228,6 +257,7 @@ function Database:PopulateDynamicCurrencies() end words[#words + 1] = slower(immediateHeaderName) .. " " .. currNameLower + local isAccountTransferable = Database:IsCurrencyAccountTransferable(info.currencyID) local entry = { name = currName, keywords = words, @@ -237,6 +267,8 @@ function Database:PopulateDynamicCurrencies() steps = currSteps, flashLabel = "Currency", icon = info.iconFileID or nil, + currencyID = info.currencyID, + isAccountTransferable = isAccountTransferable, } entry.nameLower = slower(entry.name) entry.keywordsLower = {} @@ -282,6 +314,55 @@ function Database:PopulateDynamicCurrencies() end if not didCollapse then break end end + + -- Warband-fill pass: surface warband-transferable currencies the + -- player has on OTHER characters (or has never touched on any + -- character but still exist account-wide). Without this, the + -- "All Warband Transferable" filter mode only shows currencies + -- the current character has interacted with — defeating the point. + -- Enumerate via the modern C_CurrencyInfo accessor when available; + -- fall back to a bounded ID scan only if nothing else works. + do + local extraIDs + if C_CurrencyInfo.GetAccountCurrencyTypes then + local ok, list = pcall(C_CurrencyInfo.GetAccountCurrencyTypes) + if ok and type(list) == "table" then extraIDs = list end + end + if extraIDs then + for _, cid in ipairs(extraIDs) do + if not knownCurrencyIDs[cid] + and self:IsCurrencyAccountTransferable(cid) then + local fok, finfo = pcall(C_CurrencyInfo.GetCurrencyInfo, cid) + local cname = (fok and type(finfo) == "table" and finfo.name) or nil + if cname and cname ~= "" then + local cnameLower = slower(cname) + local kw = { cnameLower, "currency", "warband" } + local entry = { + name = cname, + keywords = kw, + category = "Currency", + buttonFrame = "CharacterMicroButton", + path = { "Character Info", "Currency" }, + steps = { + { buttonFrame = "CharacterMicroButton" }, + { waitForFrame = "CharacterFrame", tabIndex = 3 }, + { waitForFrame = "CharacterFrame", currencyID = cid }, + }, + flashLabel = "Currency", + icon = (fok and finfo.iconFileID) or nil, + currencyID = cid, + isAccountTransferable = true, + } + entry.nameLower = cnameLower + entry.keywordsLower = { cnameLower, "currency", "warband" } + uiSearchData[#uiSearchData + 1] = entry + knownCurrencyIDs[cid] = true + end + end + end + end + end + return true end @@ -555,6 +636,7 @@ local GEAR_SET_PROTO = { } local GEAR_SET_MT = { __index = GEAR_SET_PROTO } + -- Map equip location strings to user-friendly search keywords local SLOT_KEYWORDS = { INVTYPE_HEAD = {"helm", "helmet", "head"}, @@ -963,13 +1045,17 @@ function Database:PopulateDynamicToys() if itemID and itemID > 0 then local _, toyName, toyIcon = GetToyInfo(itemID) if toyName and toyName ~= "" then - -- Faction-restricted toys (Horde Only / Alliance Only), - -- race-restricted toys, etc. flunk IsUsableItem. Tag them so - -- the click handler routes to ToyBox highlight instead of - -- attempting a secure use that would silently no-op. + -- Tag faction/class-restricted toys so the click handler + -- routes to ToyBox highlight instead of attempting a + -- secure use that would silently no-op. C_ToyBox.IsToyUsable + -- mirrors what Blizzard's own ToyBox UI uses for the Use + -- button enable state, unlike the broader IsUsableItem + -- which can flunk usable toys when the item info hasn't + -- been cached yet at PLAYER_LOGIN time. local isUsable = true - if IsUsableItem then - isUsable = IsUsableItem(itemID) and true or false + if C_ToyBox and C_ToyBox.IsToyUsable then + local ok, usable = pcall(C_ToyBox.IsToyUsable, itemID) + if ok and usable == false then isUsable = false end end local entry = setmetatable({ name = toyName, @@ -1119,6 +1205,7 @@ function Database:PopulateDynamicTitles() return true end + -- Scan the player's saved Equipment Manager gear sets and inject as -- search entries. Click on a row equips the set via -- C_EquipmentSet.UseEquipmentSet (no protected-frame issues outside @@ -2264,7 +2351,7 @@ function Database:PopulateDynamicTalents() local seen = {} - local function injectEntry(treeID, nodeID, entryID, isChoice) + local function injectEntry(treeID, nodeID, entryID, isChoice, nodeInfo) local eok, entryInfo = pcall(C_Traits.GetEntryInfo, configID, entryID) if not eok or type(entryInfo) ~= "table" then return end local defID = entryInfo.definitionID @@ -2286,6 +2373,17 @@ function Database:PopulateDynamicTalents() if seen[name .. "|" .. (spellID or 0)] then return end seen[name .. "|" .. (spellID or 0)] = true + -- Allocation: choice nodes pick one of their entries via + -- activeEntry; regular nodes report activeRank > 0 when the + -- player has put points in. Used by the row renderer to + -- desaturate the per-talent icon for unspecced talents. + local isAllocated + if isChoice then + isAllocated = nodeInfo.activeEntry and nodeInfo.activeEntry.entryID == entryID + else + isAllocated = (nodeInfo.activeRank or 0) > 0 + end + local nameLower = slower(name) local kw = { "talent", "talents", "spec", nameLower } uiSearchData[#uiSearchData + 1] = { @@ -2302,6 +2400,7 @@ function Database:PopulateDynamicTalents() talentNodeID = nodeID, talentEntryID = entryID, talentIsChoice = isChoice or false, + talentIsAllocated = isAllocated and true or false, buttonFrame = "PlayerSpellsMicroButton", path = { "Talents" }, steps = { @@ -2320,7 +2419,7 @@ function Database:PopulateDynamicTalents() if niok and type(nodeInfo) == "table" and type(nodeInfo.entryIDs) == "table" then local isChoice = #nodeInfo.entryIDs > 1 for _, entryID in ipairs(nodeInfo.entryIDs) do - injectEntry(treeID, nodeID, entryID, isChoice) + injectEntry(treeID, nodeID, entryID, isChoice, nodeInfo) end end end @@ -3297,12 +3396,18 @@ function Database:BuildUIDatabase() }, -- GAME MENU / HELP / SHOP + -- buttonFrame is kept solely so GetButtonIcon can derive the + -- "UI-HUD-MicroMenu-GameMenu-Up" atlas from MainMenuMicroButton's + -- textureName. The actual click goes through slashCommand because + -- the micro button's OnClick (and ToggleGameMenu) call + -- SpellStopCasting() when a spell is targeting -- protected, and + -- it taints when fired from a /run script context. { name = "Game Menu", keywords = {"menu", "settings", "options", "escape", "esc", "logout", "quit", "exit", "interface"}, category = "Menu Bar", buttonFrame = "MainMenuMicroButton", - steps = {{ buttonFrame = "MainMenuMicroButton" }}, + slashCommand = "/run if GameMenuFrame:IsShown() then HideUIPanel(GameMenuFrame) else ShowUIPanel(GameMenuFrame) end", }, { name = "Help", @@ -3369,18 +3474,32 @@ function Database:BuildUIDatabase() steps = {{ buttonFrame = "QuickJoinToastButton" }}, }, { - name = "World Map", - keywords = {"map", "world map", "zone map", "navigation"}, + name = "Toggle World Map", + keywords = {"map", "world map", "navigation", "toggle"}, + category = "Navigation", + icon = 134269, + slashCommand = "/run if WorldMapFrame:IsShown() then HideUIPanel(WorldMapFrame) else ShowUIPanel(WorldMapFrame) end", + }, + { + name = "Toggle Zone Map", + keywords = {"zone map", "battlefield map", "floating map", "area map", "navigation", "toggle"}, + category = "Navigation", + icon = 134269, + slashCommand = "/run C_AddOns.LoadAddOn('Blizzard_BattlefieldMap'); if BattlefieldMapFrame then BattlefieldMapFrame:SetShown(not BattlefieldMapFrame:IsShown()) end", + }, + { + name = "Toggle Minimap", + keywords = {"minimap", "mini map", "tracking", "navigation", "toggle"}, category = "Navigation", icon = 134269, - steps = {{ customText = "Press M to open the World Map" }}, + slashCommand = "/run MinimapCluster:SetShown(not MinimapCluster:IsShown())", }, { name = "Calendar", keywords = {"calendar", "events", "holidays", "schedule"}, category = "Social", icon = 134939, - steps = {{ customText = "Click the clock/time display on your minimap to open the Calendar" }}, + slashCommand = "/click GameTimeFrame", }, -- Quick action: dismiss any active companion. /dismisspet alone @@ -3393,7 +3512,7 @@ function Database:BuildUIDatabase() keywords = {"dismiss", "dismiss pet", "pet", "companion", "summon", "battle pet", "critter", "minion"}, category = "Action", - icon = 132599, + icon = 631719, slashCommand = "/dismisspet\n/run local g = C_PetJournal and C_PetJournal.GetSummonedPetGUID and C_PetJournal.GetSummonedPetGUID(); if g then C_PetJournal.SummonPetByGUID(g) end", }, } @@ -3878,15 +3997,6 @@ function Database:ScoreName(nameLower, query, queryLen, optQueryWords) end end - -- Precision bonus: names that closely match the query length are more - -- relevant (e.g. "Reputation" should rank above "Reputation (Achievements)" - -- when searching "reputation"). Up to 50 bonus for exact-length match. - if score >= 100 then - local nameLen = #nameLower - local ratio = queryLen / nameLen -- 1.0 for exact, lower for longer names - score = score + mmax(0, mmin(50, ratio * 50)) - end - return score end @@ -4057,6 +4167,7 @@ function Database:ScoreEntryFields(data, queryWords) if not queryWords or #queryWords < 2 then return 0 end local total = 0 local matched = 0 + local nameMatches = 0 local nameWords = GetWords(data.nameLower or "") local keywordsLower = data.keywordsLower @@ -4064,7 +4175,8 @@ function Database:ScoreEntryFields(data, queryWords) local qw = queryWords[qi] local qwLen = #qw if qwLen >= 2 or (qwLen == 1 and qi == #queryWords and matched > 0) then - local best = ScoreFieldWords(nameWords, qw, qwLen) + local nameBest = ScoreFieldWords(nameWords, qw, qwLen) + local kwBest = 0 if keywordsLower then for ki = 1, #keywordsLower do local kw = keywordsLower[ki] @@ -4072,9 +4184,16 @@ function Database:ScoreEntryFields(data, queryWords) if kwScore < 90 then kwScore = mmax(kwScore, ScoreFieldWords(GetWords(kw), qw, qwLen)) end - if kwScore > best then best = kwScore end + if kwScore > kwBest then kwBest = kwScore end end end + local best + if nameBest > 0 then + best = nameBest + nameMatches = nameMatches + 1 + else + best = kwBest + end if best == 0 then return 0 end total = total + best matched = matched + 1 @@ -4082,6 +4201,15 @@ function Database:ScoreEntryFields(data, queryWords) end if matched < 2 then return 0 end + -- Discount entries whose match came purely from embedded keyword + -- text (no query word actually appeared in the entry's own name). + -- Without this, an item whose keyword list happens to contain + -- "Eastern Kingdoms" outranks the literal Eastern Kingdoms zone + -- because keyword sums (90 per word) beat name-only avg-capped + -- scoring (110 max). + if nameMatches == 0 then + total = math.floor(total * 0.45) + end return total + matched * 5 end @@ -4380,13 +4508,17 @@ function Database:SearchUI(query, skipCategories) local qwLen = #qw local bestWord = 0 - -- Score against item name words (prefix match per word) + -- Score against item name words (prefix match per word). + -- Name match must outrank lootSourceKw (boss name) + -- match below; otherwise loot whose own name contains + -- the query gets buried under same-boss loot that + -- only matched via the encounter name. for ni = 1, #nameWords do local nw = nameWords[ni] if nw == qw then - bestWord = mmax(bestWord, qi == 1 and 100 or 90) + bestWord = mmax(bestWord, qi == 1 and 130 or 120) elseif ssub(nw, 1, qwLen) == qw then - bestWord = mmax(bestWord, qi == 1 and 95 or 85) + bestWord = mmax(bestWord, qi == 1 and 115 or 105) end end @@ -4414,14 +4546,19 @@ function Database:SearchUI(query, skipCategories) end end - -- Score against source keywords (boss/dungeon name) + -- Score against source keywords (boss/dungeon name). + -- Prefix match on a boss name is a strong real signal + -- ("nexu" -> "Nexus-Point Xenas"), so its score must + -- beat a 1-edit fuzzy hit (85) on an unrelated setting + -- name like "Next View" -- otherwise gear from the + -- searched encounter ranks below misspellings. if data.lootSourceKw then for ki = 1, #data.lootSourceKw do local kw = data.lootSourceKw[ki] if kw == qw then - bestWord = mmax(bestWord, 80) + bestWord = mmax(bestWord, 110) elseif sfind(kw, qw, 1, true) == 1 then - bestWord = mmax(bestWord, 70) + bestWord = mmax(bestWord, 100) end end end @@ -4458,8 +4595,16 @@ function Database:SearchUI(query, skipCategories) score = 0 end else - score = Database:ScoreName(nameLower, query, queryLen, queryWords) - score = score + Database:ScoreKeywords(data.keywordsLower, query, queryLen, queryWords) + -- MAX, not SUM. Many entries put their lowercased name into + -- keywordsLower (talents, abilities, mounts, ...), so a sum + -- double-counts the same fuzzy hit -- "hearth" matching + -- "heart" once via the name and once via the keyword + -- silently doubles into 170, beating real 150 prefix + -- matches like "Hearthstone Board". + score = mmax( + Database:ScoreName(nameLower, query, queryLen, queryWords), + Database:ScoreKeywords(data.keywordsLower, query, queryLen, queryWords) + ) if #queryWords >= 2 then score = mmax(score, Database:ScoreEntryFields(data, queryWords)) end From adf970b9dbf8868897b4564b2b1033282663ba0e Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 8 May 2026 08:47:32 -0400 Subject: [PATCH 059/103] Core: pre-warm search; tooltip and currency-filter defaults Eagerly warm the search prefix index post-login and kick off dynamic providers in the background, so the first keystroke after opening the search bar is instant. Pre-warm Blizzard's achievement search index 2 seconds later so per-keystroke achievement results don't pay the one-time index build cost. Add hideTooltips per-flyout toggle and currencyFilterMode defaults to DB_DEFAULTS so the new filter options have a place to persist. --- Core.lua | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Core.lua b/Core.lua index 96e7781..65efd48 100644 --- a/Core.lua +++ b/Core.lua @@ -117,6 +117,17 @@ local DB_DEFAULTS = { lootSearchStats = true, -- Loot search: match by stat keywords (haste, crit, etc.) lootUpgradesOnly = false, -- Loot search: only show items above equipped ilvl lootDifficulty = "normal", + -- Per-flyout "Hide tooltips" toggles. Keys mirror filter top-level + -- groups; when true, OnEnter on rows in that group skips the + -- gear/item tooltip entirely. + hideTooltips = { + collections = false, -- mounts/toys/pets/outfits/heirlooms/appearance sets + loot = false, -- gear search results + }, + -- Currencies filter mode (mirrors the in-game CurrencyFrame + -- dropdown). "all" = show every currency the character has; + -- "warband" = only surface warband-transferable currencies. + currencyFilterMode = "all", appearanceSetClass = nil, -- nil = player class, "all" = all, {classID=N} = specific appearanceSetCollected = true, -- Show collected sets appearanceSetNotCollected = true, -- Show uncollected sets @@ -501,6 +512,28 @@ local function OnPlayerLogin() end InstallTransmogClassFilterHook() + -- Pre-warm the search hot path eagerly: build the prefix index over + -- the static dataset immediately, then kick off the deferred dynamic + -- providers in the background so by the time the user opens the + -- search bar (post-login UI fade-in, minimap button click, etc.) the + -- index is ready and no first-keystroke build cost remains. + if ns.Database then + if ns.Database.WarmSearchHotPath then + xpcall(ns.Database.WarmSearchHotPath, ErrorHandler, ns.Database) + end + EasyFind:EnsureDynamicLoaded() + end + + -- Pre-warm Blizzard's achievement search index off the user's typing + -- path. The index build is the lag source we used to hit on the + -- first achievement-related search; doing it once in the background + -- here makes per-keystroke achievement results instant later. + SafeAfter(2.0, function() + if ns.UI and ns.UI.PrewarmAchievementSearch then + xpcall(ns.UI.PrewarmAchievementSearch, ErrorHandler, ns.UI) + end + end) + -- Minimap button (delayed slightly so Minimap frame is ready) SafeAfter(0.6, function() if EasyFind.db.showMinimapButton then From 077a988a3185449e4d0cb31c969d86d45d1847a8 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 8 May 2026 08:47:52 -0400 Subject: [PATCH 060/103] Utils: Raycast scrollbar, pooled cursor menus, autocomplete Replace the chrome-heavy scrollbar with a Raycast-style overlay: 3px pill thumb, edge-hugging with 4px buffer, alpha-faded so it only shows when scrolling. Wheel and click-track scrolling routes through an eased target so movement glides at 30% per frame instead of jumping. Right-click cursor menus now use a per-globalName pool of menus (recreated on hide) instead of a single cached menu, so state never leaks across opens. Rows are children of the menu, separators render as a thin texture, and width grows to fit the widest label. Anchor TOPLEFT to cursor, not BOTTOMLEFT. Autocomplete suffix renders smoothly when the user types the next candidate character (no flicker), and aborts mid-render if the live typed text has diverged so the keystroke race can't drop input. --- Utils.lua | 531 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 344 insertions(+), 187 deletions(-) diff --git a/Utils.lua b/Utils.lua index 656a8f6..5482923 100644 --- a/Utils.lua +++ b/Utils.lua @@ -191,6 +191,23 @@ function Utils.AttachAutocomplete(editBox, opts) return false end + -- Abort if a user keystroke landed between when the search was + -- scheduled and now: WoW defers OnTextChanged by one frame, so + -- the in-flight char hasn't updated typedText yet but is already + -- in the editbox. Calling SetText here would overwrite it, and + -- the deferred OnTextChanged would then see no change vs. + -- typedText (we'd just set it to candidate) and silently drop + -- the keystroke. + local liveText = editBox:GetText() or "" + local liveCursor = editBox:GetCursorPosition() or #liveText + local hasLiveSuggestion = currentCandidate ~= nil + and liveText == currentCandidate + and liveCursor == #typedText + local liveTyped = ssub(liveText, 1, liveCursor) + if not hasLiveSuggestion and liveTyped ~= typedText then + return false + end + local typedLen = #typedText typedText = ssub(candidate, 1, typedLen) currentCandidate = candidate @@ -250,46 +267,41 @@ function Utils.AttachAutocomplete(editBox, opts) local prevLen = #typedText typedText = typed local grew = #typedText > prevLen - if #typedText > prevLen - and currentCandidate and typedText ~= "" - and #typedText < #currentCandidate - and slower(typedText) == slower(ssub(currentCandidate, 1, #typedText)) then - smoothExtendDone = RenderCandidate(currentCandidate) - else + -- Smooth-extend: when the user types a character that matches + -- the next character of the current candidate, re-attach the + -- suggestion in-place instead of clearing it and waiting for + -- the throttled re-render. This is the "Chrome omnibox" feel: + -- the highlighted suffix shrinks character by character without + -- flicker. Safe because we run synchronously inside + -- OnTextChanged (post-keystroke), not from the throttle's + -- OnUpdate, so there's no in-flight char to clobber. + local extended = false + if grew and currentCandidate + and #typed < #currentCandidate + and slower(ssub(currentCandidate, 1, #typed)) == slower(typed) then + local typedLen = #typed + programmatic = true + if self:GetText() ~= currentCandidate then + self:SetText(currentCandidate) + end + self:SetCursorPosition(typedLen) + self:HighlightText(typedLen, #currentCandidate) + programmatic = false + extended = true + end + if not extended then currentCandidate = nil self:HighlightText(0, 0) end if onTypedChanged then onTypedChanged(self, typedText, prevText, grew) end end) - editBox:HookScript("OnChar", function(self, char) - if not currentCandidate or not char or char == "" then return end - local current = self:GetText() or "" - if current == currentCandidate then return end - local cursorPos = self:GetCursorPosition() or #current - local typed = ssub(current, 1, cursorPos) - if #typed <= #typedText then return end - local candidatePrefix = ssub(currentCandidate, 1, #typed) - if slower(typed) ~= slower(candidatePrefix) then - currentCandidate = nil - self:HighlightText(0, 0) - return - end - - local prevText = typedText - typedText = candidatePrefix - if not RenderCandidate(currentCandidate) then return end - smoothExtendDone = true - if onTypedChanged then - charDispatchedTyped = typedText - if C_Timer then - C_Timer.After(0, function() - charDispatchedTyped = nil - end) - end - onTypedChanged(self, typedText, prevText, true) - end - end) + -- OnChar smooth-extend hook removed: it raced with OnTextChanged + -- when the user typed quickly, calling SetText mid-keystroke and + -- occasionally swallowing the next character. OnTextChanged below + -- still handles smooth extension when the typed prefix continues + -- to match the candidate, so the suggestion still grows as you + -- type without an extra SetText pass per character. editBox:HookScript("OnEditFocusLost", StripAutocomplete) @@ -861,157 +873,173 @@ end -- Thin scrollbar using minimal-scrollbar-* atlas textures, overlaid on the right edge. function Utils.CreateMinimalScrollBar(scrollFrame, parent) - local MIN_THUMB_H = 20 - local TRACK_PAD = 2 - local VERT_PAD = 4 - - -- Query native atlas sizes so we never hardcode sprite dimensions - local arrowInfo = C_Texture.GetAtlasInfo("minimal-scrollbar-arrow-top") - local trackCapInfo = C_Texture.GetAtlasInfo("minimal-scrollbar-track-top") - local ARROW_W = arrowInfo and arrowInfo.width or 17 - local ARROW_H = arrowInfo and arrowInfo.height or 11 - local BAR_W = trackCapInfo and trackCapInfo.width or 6 + local THUMB_W = 3 + local EDGE_INSET = 4 + local VERT_PAD = 4 + local MIN_THUMB_H = 14 + local FADE_HOLD = 0.6 + local FADE_OUT = 0.25 + local WHEEL_STEP = 90 + local SCROLL_LERP = 0.30 + local SCROLL_EPS = 0.5 local bar = CreateFrame("Frame", nil, parent) - bar:SetWidth(ARROW_W) - bar:SetPoint("RIGHT", scrollFrame, "RIGHT", 0, 0) + bar:SetWidth(THUMB_W) + bar:SetPoint("TOPRIGHT", scrollFrame, "TOPRIGHT", -EDGE_INSET, -VERT_PAD) + bar:SetPoint("BOTTOMRIGHT", scrollFrame, "BOTTOMRIGHT", -EDGE_INSET, VERT_PAD) bar:SetFrameStrata(parent:GetFrameStrata()) bar:SetFrameLevel(parent:GetFrameLevel() + 5) + bar:EnableMouse(true) - local function UpdateBarHeight() - bar:SetHeight(scrollFrame:GetHeight() - VERT_PAD * 2) - end - bar.UpdateBarHeight = UpdateBarHeight - UpdateBarHeight() - - -- Up arrow - local backBtn = CreateFrame("Button", nil, bar) - backBtn:SetSize(ARROW_W, ARROW_H) - backBtn:SetPoint("TOP", bar, "TOP", 0, 0) - local backTex = backBtn:CreateTexture(nil, "BACKGROUND") - backTex:SetAtlas("minimal-scrollbar-arrow-top", true) - backTex:SetPoint("CENTER") - backBtn:SetScript("OnEnter", function() backTex:SetAtlas("minimal-scrollbar-arrow-top-over", true) end) - backBtn:SetScript("OnLeave", function() backTex:SetAtlas("minimal-scrollbar-arrow-top", true) end) - backBtn:SetScript("OnClick", function() - local cur = scrollFrame:GetVerticalScroll() - scrollFrame:SetVerticalScroll(mmax(0, cur - 24)) - end) - - -- Down arrow - local fwdBtn = CreateFrame("Button", nil, bar) - fwdBtn:SetSize(ARROW_W, ARROW_H) - fwdBtn:SetPoint("BOTTOM", bar, "BOTTOM", 0, 0) - local fwdTex = fwdBtn:CreateTexture(nil, "BACKGROUND") - fwdTex:SetAtlas("minimal-scrollbar-arrow-bottom", true) - fwdTex:SetPoint("CENTER") - fwdBtn:SetScript("OnEnter", function() fwdTex:SetAtlas("minimal-scrollbar-arrow-bottom-over", true) end) - fwdBtn:SetScript("OnLeave", function() fwdTex:SetAtlas("minimal-scrollbar-arrow-bottom", true) end) - fwdBtn:SetScript("OnClick", function() - local cur = scrollFrame:GetVerticalScroll() - local range = scrollFrame:GetVerticalScrollRange() - scrollFrame:SetVerticalScroll(mmin(range, cur + 24)) - end) + bar.UpdateBarHeight = function() end - -- Track fills the bar width so track center = arrow center local track = CreateFrame("Frame", nil, bar) - track:SetPoint("TOPLEFT", backBtn, "BOTTOMLEFT", 0, -TRACK_PAD) - track:SetPoint("BOTTOMRIGHT", fwdBtn, "TOPRIGHT", 0, TRACK_PAD) - - local trackTopTex = track:CreateTexture(nil, "BACKGROUND") - trackTopTex:SetAtlas("minimal-scrollbar-track-top", true) - trackTopTex:SetPoint("TOP") - - local trackBotTex = track:CreateTexture(nil, "BACKGROUND") - trackBotTex:SetAtlas("minimal-scrollbar-track-bottom", true) - trackBotTex:SetPoint("BOTTOM") + track:SetAllPoints(bar) - local trackMidTex = track:CreateTexture(nil, "BACKGROUND") - trackMidTex:SetAtlas("!minimal-scrollbar-track-middle", true) - trackMidTex:SetPoint("TOP", trackTopTex, "BOTTOM") - trackMidTex:SetPoint("BOTTOM", trackBotTex, "TOP") - - -- Thumb (draggable, same width as track) local thumb = CreateFrame("Button", nil, track) - thumb:SetWidth(BAR_W) + thumb:SetWidth(THUMB_W) thumb:EnableMouse(true) - local thumbTopTex = thumb:CreateTexture(nil, "ARTWORK") - thumbTopTex:SetAtlas("minimal-scrollbar-small-thumb-top", true) - thumbTopTex:SetPoint("TOP") - - local thumbBotTex = thumb:CreateTexture(nil, "ARTWORK") - thumbBotTex:SetAtlas("minimal-scrollbar-small-thumb-bottom", true) - thumbBotTex:SetPoint("BOTTOM") - - local thumbMidTex = thumb:CreateTexture(nil, "ARTWORK") - thumbMidTex:SetAtlas("minimal-scrollbar-small-thumb-middle", true) - thumbMidTex:SetPoint("TOP", thumbTopTex, "BOTTOM") - thumbMidTex:SetPoint("BOTTOM", thumbBotTex, "TOP") - - local function SetThumbNormal() - thumbTopTex:SetAtlas("minimal-scrollbar-small-thumb-top", true) - thumbBotTex:SetAtlas("minimal-scrollbar-small-thumb-bottom", true) - thumbMidTex:SetAtlas("minimal-scrollbar-small-thumb-middle", true) - end - local function SetThumbOver() - thumbTopTex:SetAtlas("minimal-scrollbar-small-thumb-top-over", true) - thumbBotTex:SetAtlas("minimal-scrollbar-small-thumb-bottom-over", true) - thumbMidTex:SetAtlas("minimal-scrollbar-small-thumb-middle-over", true) + -- Pill shape: rect body + masked half-circle caps top/bottom. + local capH = THUMB_W * 0.5 + + local thumbBody = thumb:CreateTexture(nil, "ARTWORK") + thumbBody:SetColorTexture(1, 1, 1, 1) + thumbBody:SetPoint("TOPLEFT", thumb, "TOPLEFT", 0, -capH) + thumbBody:SetPoint("BOTTOMRIGHT", thumb, "BOTTOMRIGHT", 0, capH) + + local thumbTop = thumb:CreateTexture(nil, "ARTWORK") + thumbTop:SetColorTexture(1, 1, 1, 1) + thumbTop:SetSize(THUMB_W, capH) + thumbTop:SetPoint("TOP", thumb, "TOP", 0, 0) + + local topMask = thumb:CreateMaskTexture() + topMask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask", "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE") + topMask:SetSize(THUMB_W, THUMB_W) + topMask:SetPoint("TOP", thumb, "TOP", 0, 0) + thumbTop:AddMaskTexture(topMask) + + local thumbBot = thumb:CreateTexture(nil, "ARTWORK") + thumbBot:SetColorTexture(1, 1, 1, 1) + thumbBot:SetSize(THUMB_W, capH) + thumbBot:SetPoint("BOTTOM", thumb, "BOTTOM", 0, 0) + + local botMask = thumb:CreateMaskTexture() + botMask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask", "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE") + botMask:SetSize(THUMB_W, THUMB_W) + botMask:SetPoint("BOTTOM", thumb, "BOTTOM", 0, 0) + thumbBot:AddMaskTexture(botMask) + + local function SetThumbAlpha(a) + thumbBody:SetAlpha(a) + thumbTop:SetAlpha(a) + thumbBot:SetAlpha(a) end + local function SetThumbNormal() SetThumbAlpha(0.55) end + local function SetThumbOver() SetThumbAlpha(0.85) end + SetThumbNormal() thumb:SetScript("OnEnter", SetThumbOver) thumb:SetScript("OnLeave", function() if not bar.isDragging then SetThumbNormal() end end) - -- Thumb dragging + -- Activity-driven visibility. Bar is shown but at alpha 0 by default; + -- each interaction bumps it to full opacity, and after FADE_HOLD + -- seconds of idle it fades back over FADE_OUT seconds. + bar:SetAlpha(0) + bar._lastActivity = 0 + bar._fadingOut = false + bar._scrollTarget = nil bar.isDragging = false bar.dragOffset = 0 + function bar:NudgeVisible() + self._lastActivity = GetTime() + self._fadingOut = false + if self:IsShown() then self:SetAlpha(1) end + end + + local function ScrollByDelta(delta) + local range = scrollFrame:GetVerticalScrollRange() + if range <= 0 then return end + local current = bar._scrollTarget or scrollFrame:GetVerticalScroll() + bar._scrollTarget = mmax(0, mmin(range, current - delta * WHEEL_STEP)) + bar:NudgeVisible() + end + bar.ScrollByDelta = function(_, delta) ScrollByDelta(delta) end + thumb:SetScript("OnMouseDown", function(self, button) if button ~= "LeftButton" then return end bar.isDragging = true + bar._scrollTarget = nil local _, cursorY = GetCursorPosition() local scale = self:GetEffectiveScale() bar.dragOffset = cursorY / scale - self:GetTop() SetThumbOver() + bar:NudgeVisible() end) thumb:SetScript("OnMouseUp", function(self, button) if button ~= "LeftButton" then return end bar.isDragging = false if not self:IsMouseOver() then SetThumbNormal() end + bar:NudgeVisible() end) bar:SetScript("OnUpdate", function(self) - if not self.isDragging then return end - -- Mouse-up can fire outside the thumb (drag off the edge, release - -- on another frame). The thumb's OnMouseUp doesn't fire then, so - -- detect button release here and end the drag. - if not IsMouseButtonDown("LeftButton") then - self.isDragging = false - if not thumb:IsMouseOver() then SetThumbNormal() end + if self.isDragging then + if not IsMouseButtonDown("LeftButton") then + self.isDragging = false + if not thumb:IsMouseOver() then SetThumbNormal() end + else + local range = scrollFrame:GetVerticalScrollRange() + if range > 0 then + local _, cursorY = GetCursorPosition() + local scale = track:GetEffectiveScale() + cursorY = cursorY / scale + local trackT = track:GetTop() + local thumbH = thumb:GetHeight() + local travel = track:GetHeight() - thumbH + if travel > 0 then + local pos = trackT - (cursorY - self.dragOffset) + local ratio = mmax(0, mmin(1, pos / travel)) + scrollFrame:SetVerticalScroll(ratio * range) + end + end + end + self:NudgeVisible() return end - local range = scrollFrame:GetVerticalScrollRange() - if range <= 0 then return end - local _, cursorY = GetCursorPosition() - local scale = track:GetEffectiveScale() - cursorY = cursorY / scale - - local trackT = track:GetTop() - local thumbH = thumb:GetHeight() - local travel = track:GetHeight() - thumbH - if travel <= 0 then return end + if self._scrollTarget then + local cur = scrollFrame:GetVerticalScroll() + local diff = self._scrollTarget - cur + if mabs(diff) < SCROLL_EPS then + scrollFrame:SetVerticalScroll(self._scrollTarget) + self._scrollTarget = nil + else + scrollFrame:SetVerticalScroll(cur + diff * SCROLL_LERP) + end + self:NudgeVisible() + end - local pos = trackT - (cursorY - self.dragOffset) - local ratio = mmax(0, mmin(1, pos / travel)) - scrollFrame:SetVerticalScroll(ratio * range) + if self:GetAlpha() <= 0 then return end + local idle = GetTime() - self._lastActivity + if idle < FADE_HOLD then return end + if not self._fadingOut then + self._fadingOut = true + self._fadeStart = GetTime() + end + local t = (GetTime() - self._fadeStart) / FADE_OUT + if t >= 1 then + self:SetAlpha(0) + self._fadingOut = false + else + self:SetAlpha(1 - t) + end end) - -- Click track to jump track:EnableMouse(true) track:SetScript("OnMouseDown", function(self, button) if button ~= "LeftButton" then return end @@ -1027,17 +1055,14 @@ function Utils.CreateMinimalScrollBar(scrollFrame, parent) if trackH <= 0 then return end local ratio = (trackT - cursorY) / trackH - scrollFrame:SetVerticalScroll(mmax(0, mmin(range, ratio * range))) + bar._scrollTarget = mmax(0, mmin(range, ratio * range)) + bar:NudgeVisible() end) - -- Update thumb position and size from current scroll state. - -- Optional explicit contentH/viewH avoid layout-timing issues on first render. - -- Values are cached so deferred calls (OnShow) can reuse them. bar._contentH = nil bar._viewH = nil function bar:UpdateThumb(contentH, viewH) - self:UpdateBarHeight() if contentH then self._contentH = contentH end if viewH then self._viewH = viewH end contentH = contentH or self._contentH @@ -1067,22 +1092,24 @@ function Utils.CreateMinimalScrollBar(scrollFrame, parent) thumb:Show() end - -- Sync thumb when scroll position changes scrollFrame:SetScript("OnVerticalScroll", function() bar:UpdateThumb() + bar:NudgeVisible() end) - -- Mouse wheel on the scrollbar itself bar:EnableMouseWheel(true) - bar:SetScript("OnMouseWheel", function(_, delta) - local range = scrollFrame:GetVerticalScrollRange() - local cur = scrollFrame:GetVerticalScroll() - scrollFrame:SetVerticalScroll(mmax(0, mmin(range, cur - delta * 72))) - end) + bar:SetScript("OnMouseWheel", function(_, delta) ScrollByDelta(delta) end) + + -- Route the host scrollFrame's wheel through the same eased path so + -- wheel events on the content (not just over the thumb) feel smooth. + scrollFrame:EnableMouseWheel(true) + scrollFrame:SetScript("OnMouseWheel", function(_, delta) ScrollByDelta(delta) end) - -- Recompute bar height and thumb on show so layout matches current frame size bar:SetScript("OnShow", function(self) - self:UpdateBarHeight() + self:SetAlpha(0) + self._lastActivity = 0 + self._fadingOut = false + self._scrollTarget = nil C_Timer.After(0, function() if self:IsShown() then self:UpdateThumb() end end) @@ -1178,11 +1205,47 @@ function Utils.CreateClearButton(parent, globalName) return btn end +-- Pool of (menu, rows) tuples keyed by globalName. We never destroy +-- WoW frames (they persist for the session), but we cycle through pool +-- members so each open uses a freshly-laid-out menu rather than mutating +-- one we just hid. This avoids a class of redraw / state-leak bugs where +-- the second open of a cached menu inherits stale flags from the first +-- close (e.g. backdrop tile dropped by Hide/Show, alpha stuck after +-- click, etc.). +local cursorMenuPool = {} +local cursorMenuCounter = 0 + +local function FindFreeMenu(globalName) + local pool = cursorMenuPool[globalName] + if not pool then return nil end + for i = 1, #pool do + local menu = pool[i] + if menu and not menu:IsShown() then return menu end + end + return nil +end + +local function HideOtherMenus(globalName, except) + local pool = cursorMenuPool[globalName] + if not pool then return end + for i = 1, #pool do + local menu = pool[i] + if menu and menu ~= except and menu:IsShown() then menu:Hide() end + end +end + function Utils.ShowCursorMenu(globalName, rows, opts) opts = opts or {} - local menu = _G[globalName] + + -- Hide any sibling already-shown instance under this globalName so + -- we never have two cursor menus visible at once. + HideOtherMenus(globalName, nil) + + local menu = FindFreeMenu(globalName) if not menu then - menu = CreateFrame("Frame", globalName, UIParent, "BackdropTemplate") + cursorMenuCounter = cursorMenuCounter + 1 + local frameName = globalName .. "_" .. cursorMenuCounter + menu = CreateFrame("Frame", frameName, UIParent, "BackdropTemplate") menu:EnableMouse(true) menu.rows = {} menu:SetBackdrop({ @@ -1219,7 +1282,8 @@ function Utils.ShowCursorMenu(globalName, rows, opts) self:UnregisterEvent("GLOBAL_MOUSE_UP") if self.rows then for i = 1, #self.rows do - self.rows[i]:Hide() + local row = self.rows[i] + if row then row:Hide() end end end end) @@ -1244,11 +1308,14 @@ function Utils.ShowCursorMenu(globalName, rows, opts) if self._showedAt and (GetTime() - self._showedAt) < (self.clickGrace or 0.05) then return end if not MenuHasMouse(self) then self:Hide() end end) + cursorMenuPool[globalName] = cursorMenuPool[globalName] or {} + local pool = cursorMenuPool[globalName] + pool[#pool + 1] = menu + if not _G[globalName] then _G[globalName] = menu end end menu:SetFrameStrata(opts.strata or "TOOLTIP") menu:SetFrameLevel(opts.level or 10000) - menu:SetToplevel(opts.toplevel ~= false) menu.outsideDelay = opts.outsideDelay or 0.3 menu.clickGrace = opts.clickGrace or 0.05 @@ -1262,7 +1329,7 @@ function Utils.ShowCursorMenu(globalName, rows, opts) shown = shown + 1 local row = menu.rows[shown] if not row then - row = CreateFrame("Button", nil, UIParent) + row = CreateFrame("Button", nil, menu) row:EnableMouse(true) row:SetHeight(rowH) row:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD") @@ -1271,26 +1338,46 @@ function Utils.ShowCursorMenu(globalName, rows, opts) row.icon = row:CreateTexture(nil, "OVERLAY") row.icon:SetSize(14, 14) row.icon:SetPoint("RIGHT", row, "RIGHT", -8, 0) + row.sep = row:CreateTexture(nil, "ARTWORK") + row.sep:SetColorTexture(1, 1, 1, 0.18) + row.sep:SetHeight(1) + row.sep:SetPoint("LEFT", row, "LEFT", 6, 0) + row.sep:SetPoint("RIGHT", row, "RIGHT", -6, 0) + row.sep:Hide() menu.rows[shown] = row end - row:SetParent(UIParent) - row:SetFrameStrata(menu:GetFrameStrata()) - row:SetHeight(rowH) - row:SetFrameLevel(menu:GetFrameLevel() + 20 + shown) - row.label:SetText(def.text or "") - if def.icon then - row.icon:SetTexture(def.icon) - row.icon:Show() - else + local isSep = def.isSeparator + local sepH = 7 + row:SetHeight(isSep and sepH or rowH) + if isSep then + row.label:SetText("") row.icon:Hide() + row.sep:Show() + row:EnableMouse(false) + local hl = row:GetHighlightTexture() + if hl then hl:SetAlpha(0) end + row:SetScript("OnMouseDown", nil) + row:SetScript("OnClick", nil) + else + row.label:SetText(def.text or "") + if def.icon then + row.icon:SetTexture(def.icon) + row.icon:Show() + else + row.icon:Hide() + end + row.sep:Hide() + row:EnableMouse(true) + local hl = row:GetHighlightTexture() + if hl then hl:SetAlpha(1) end + local onClick = def.onClick + row:SetScript("OnMouseDown", function(_, button) + if button ~= "LeftButton" then return end + menu:Hide() + if onClick then onClick() end + end) + row:SetScript("OnClick", nil) end - local onClick = def.onClick - row:SetScript("OnMouseDown", function(_, button) - if button ~= "LeftButton" then return end - menu:Hide() - if onClick then onClick() end - end) - row:SetScript("OnClick", nil) row:ClearAllPoints() if shown == 1 then row:SetPoint("TOPLEFT", menu, "TOPLEFT", 4, -4) @@ -1300,35 +1387,105 @@ function Utils.ShowCursorMenu(globalName, rows, opts) row:SetPoint("TOPRIGHT", lastRow, "BOTTOMRIGHT", 0, 0) end row:Show() - if row.Raise then row:Raise() end lastRow = row end end for i = shown + 1, #menu.rows do - menu.rows[i]:Hide() - menu.rows[i]:SetScript("OnClick", nil) - menu.rows[i]:SetScript("OnMouseDown", nil) + local row = menu.rows[i] + if row then + row:Hide() + row:SetScript("OnClick", nil) + row:SetScript("OnMouseDown", nil) + end end - menu:SetSize(width, rowH * shown + 8) + -- Auto-grow width to fit the widest row's label + icon. Use the + -- caller-provided width as a floor so short menus stay compact. + -- 8px LEFT pad + label + 4px gap + 14px icon (when present) + 8px + -- right pad ≈ label + 34. + local needed = width + local totalH = 0 + for i = 1, shown do + local row = menu.rows[i] + if row then + totalH = totalH + (row:GetHeight() or rowH) + if row.label and row.label:IsShown() then + local getter = row.label.GetUnboundedStringWidth or row.label.GetStringWidth + local lblW = getter and getter(row.label) or 0 + local hasIcon = row.icon and row.icon:IsShown() + local rowW = lblW + (hasIcon and 34 or 24) + if rowW > needed then needed = rowW end + end + end + end + menu:SetSize(needed, totalH + 8) local scale = UIParent:GetEffectiveScale() local x, y = GetCursorPosition() menu:ClearAllPoints() - menu:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", + -- Anchor TOPLEFT to cursor so rows extend down-right like every + -- standard right-click menu (Windows / macOS / Blizzard's own). + menu:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", x / scale + (opts.offsetX or 0), y / scale + (opts.offsetY or 0)) menu:Show() return menu end -function Utils.ShowPinMenu(globalName, isPinned, onPin, onGuide, onAddAlias, opts) +function Utils.ShowPinMenu(globalName, isPinned, onPin, onGuide, onAddAlias, opts, extra) local rows = {} + -- Base section: same three rows on every entry, in a fixed order + -- (Add Alias → Pin → Guide). Each is gated by whether the + -- underlying action is meaningful for the entry; an unsupported + -- one is simply omitted rather than shown disabled, but the + -- relative order of the rows that ARE shown is stable. + if onAddAlias then + rows[#rows + 1] = { text = "Add Alias", onClick = onAddAlias } + end + rows[#rows + 1] = { text = isPinned and "Unpin" or "Pin", onClick = onPin } if onGuide then rows[#rows + 1] = { text = "Guide", icon = ns.EYE_ICON_TEX, onClick = onGuide } end - rows[#rows + 1] = { text = isPinned and "Unpin" or "Pin", onClick = onPin } - if onAddAlias then - rows[#rows + 1] = { text = "Add Alias", onClick = onAddAlias } + + -- Extras section: category-specific actions. Collect into a local + -- list first so we can decide whether to emit a separator above it. + local extras = {} + if extra and extra.onTrack then + extras[#extras + 1] = { + text = extra.isTracked and "Untrack" or "Track", + onClick = extra.onTrack, + } + end + if extra and extra.onToggleBackpack then + extras[#extras + 1] = { + text = extra.isOnBackpack and "Remove from backpack" or "Show on backpack", + onClick = extra.onToggleBackpack, + } + end + if extra and extra.onTransfer then + extras[#extras + 1] = { text = "Transfer", onClick = extra.onTransfer } end + if extra and extra.onSummon then + extras[#extras + 1] = { text = "Summon", onClick = extra.onSummon } + end + if extra and extra.onRename then + extras[#extras + 1] = { text = "Rename", onClick = extra.onRename } + end + if extra and extra.onToggleFavorite then + extras[#extras + 1] = { + text = extra.isFavorite and "Remove favorite" or "Set favorite", + onClick = extra.onToggleFavorite, + } + end + if extra and extra.onCageOrRelease then + extras[#extras + 1] = { + text = extra.isCageable and "Put in cage" or "Release", + onClick = extra.onCageOrRelease, + } + end + if #extras > 0 then + rows[#rows + 1] = { isSeparator = true } + for i = 1, #extras do rows[#rows + 1] = extras[i] end + end + return Utils.ShowCursorMenu(globalName, rows, opts) end From ca31520081289f4d92d6b9e43080fd72ce6d7baf Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 8 May 2026 08:48:18 -0400 Subject: [PATCH 061/103] UI: tooltip placement, right-click menus, achievement search - Tooltips for flat results anchor BOTTOMRIGHT at the cursor with a diagonal offset (matches default UI), follow the cursor while owned, and use a separate gear-anchor that pins to the results panel edge. Per-flyout 'Hide Tooltips' toggle suppresses tooltips when on. - Right-click row menu standardized: Add Alias, Pin, Guide, then a separator and category-specific extras (Track, Show on Backpack, Transfer, Summon Pet, Rename, Set Favorite, Cage/Release, Set/Clear Transmog Favorite). Achievements support tracking via Add/Remove TrackedAchievement; transmog favorites via base set ID. - Inline achievement search drives Blizzard's SetAchievementSearchString index so per-keystroke results stay fast without preloading the full achievement table. Subtext shows only the immediate parent category. - Currency 'Show on Backpack' uses SetCurrencyBackpackByID (the index-based variant silently no-ops); enforces the MAX_WATCHED_TOKENS cap; pokes BackpackTokenFrame and ContainerFrame Update hooks so the tracker refreshes in real time. - Currency filter mode flyout (all / warband transferable). Map filter options surfaced as a sub-flyout in the UI search filter menu. - Result-row icon resets clear stale tooltip IDs after data assignment so the first row never inherits the previous render's tooltip. - Pin state included in render-skip cache so toggling Pin updates rows in real time. - Raycast scrollbar overlays content (no reserved inset); wheel and re-anchor wiring delegated to the new scrollbar implementation. --- UI.lua | 1580 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 1461 insertions(+), 119 deletions(-) diff --git a/UI.lua b/UI.lua index 2f0ae02..853cb64 100644 --- a/UI.lua +++ b/UI.lua @@ -41,7 +41,11 @@ local resultsFrame local containerFrame local resultButtons = {} local MAX_BUTTON_POOL = 100 -local MAX_SEARCH_RESULT_ROWS = MAX_BUTTON_POOL +-- Hard render cap on the search-match portion of the result list. Pinned +-- items are counted separately. Pre-render cap in OnSearchTextChanged +-- already trims to 15 before flattening; this is a backstop for any +-- other call path (alias views, container expansion) that lands here. +local MAX_SEARCH_RESULT_ROWS = 15 local EnsureResultButton local inCombat = false local selectingResult = false -- guard: suppress OnTextChanged re-renders during SelectResult @@ -78,7 +82,8 @@ local FLAT_CATEGORY_ICONS = { -- icon (spell icon / boss portrait) is pushed to the RIGHT side. ability = { tex = 522972, coords = { 0.904, 0.996, 0.707, 0.748 } }, boss = { tex = 522972, coords = { 0.855, 0.949, 0.524, 0.566 } }, - talent = { atlas = "talents-node-circle-gray" }, + talent = { atlas = "UI-HUD-MicroMenu-SpellbookAbilities-Up" }, + achievement = { atlas = "UI-HUD-MicroMenu-Achievements-Up" }, macro = { tex = "Interface\\MacroFrame\\MacroFrame-Icon" }, bag = { atlas = "bag-main" }, loot = { tex = 522972, coords = { 0.730, 0.824, 0.618, 0.660 } }, @@ -135,6 +140,7 @@ local function GetFlatCategoryIcon(data) if data.transmogSetID then return FLAT_CATEGORY_ICONS.appearanceSet end if data.spellID and data.category == "Ability" then return FLAT_CATEGORY_ICONS.ability end if data.category == "Talent" then return FLAT_CATEGORY_ICONS.talent end + if data.achievementID and data.category == "Achievement" then return FLAT_CATEGORY_ICONS.achievement end if data.encounterID and data.category == "Boss" then return FLAT_CATEGORY_ICONS.boss end if data.macroIndex and data.category == "Macro" then return FLAT_CATEGORY_ICONS.macro end if data.bagID and data.category == "Bag" then return FLAT_CATEGORY_ICONS.bag end @@ -157,9 +163,78 @@ end -- Compose the small subtext shown under a flat-list result (Alfred-style). -- UI entries get their breadcrumb path; collection items and map results fall -- back to a category label or zone name so every row carries some context. +-- Abbreviate a binding string for display in the inline keybind buttons. +-- "SHIFT-PAGEDOWN" -> "S-PgDn", etc. WoW returns keynames in +-- MODIFIER-KEY uppercase form; we only shorten the parts that commonly +-- overflow the 60-ish-px keybind button. +local KEY_ABBREV = { + SHIFT = "S", CTRL = "C", ALT = "A", META = "M", + PAGEDOWN = "PgDn", PAGEUP = "PgUp", + HOME = "Home", END = "End", + DELETE = "Del", INSERT = "Ins", + BACKSPACE = "BkSp", SPACE = "Spc", + ESCAPE = "Esc", ENTER = "Ent", + PRINTSCREEN = "PrtSc", + MOUSEWHEELUP = "MWU", MOUSEWHEELDOWN = "MWD", + BUTTON3 = "MB3", BUTTON4 = "MB4", BUTTON5 = "MB5", +} +local function AbbrevBinding(binding) + if not binding or binding == "" then return "Not Bound" end + local out = {} + for part in binding:gmatch("[^%-]+") do + local up = part:upper() + local short = KEY_ABBREV[up] + if not short then + -- NUMPAD0-9 -> Num0, NUMPADDIVIDE -> Num/, etc. + local n = up:match("^NUMPAD(%d)$") + if n then short = "Num" .. n end + end + out[#out + 1] = short or part + end + return table.concat(out, "-") +end + +-- Set a FontString's text and append "..." when the rendered string +-- exceeds the FontString's anchor-bounded width. Used for result-row +-- titles and any other single-line label that must clip cleanly +-- instead of overflowing into the next row's space (WoW doesn't +-- auto-add ellipses for anchor-clipped FontStrings). +local function SetClippedText(fs, text) + if not fs then return end + fs:SetText(text or "") + if not text or text == "" then return end + -- Use anchor-derived bounds (GetLeft / GetRight) instead of + -- GetWidth(): GetWidth() on an L+R anchored FontString can return + -- the natural string width when the text overflows, which then + -- causes the trim loop to stop at a string that's far shorter than + -- the actual visible bound (premature ellipsis with empty space + -- before the next element). + local left, right = fs:GetLeft(), fs:GetRight() + local maxW + if left and right and right > left then + maxW = right - left + else + maxW = fs:GetWidth() or 0 + end + if not maxW or maxW <= 0 then return end + local getW = fs.GetUnboundedStringWidth + local strW = getW and fs:GetUnboundedStringWidth() or fs:GetStringWidth() or 0 + if strW <= maxW then return end + for cut = #text - 1, 1, -1 do + local trimmed = text:sub(1, cut) .. "..." + fs:SetText(trimmed) + local w = getW and fs:GetUnboundedStringWidth() or fs:GetStringWidth() or 0 + if w <= maxW then return end + end +end + local function GetFlatSubtext(data) if not data then return "" end if data.path and #data.path > 0 then + local cat = data.category + if data.achievementID or cat == "Achievement" or cat == "Achievements" then + return data.path[#data.path] + end return tconcat(data.path, " > ") end if data.mapSearchResult then @@ -209,24 +284,114 @@ end -- cluttering every other row. Returns nil for entries whose action -- isn't worth labelling (UI navigation, settings — the row name itself -- already tells you what happens). +-- Default tooltip placement for non-gear results. The default UI sets +-- the tooltip's bottom-right corner just up-and-left of the cursor +-- (with a small diagonal buffer so the tooltip doesn't sit literally +-- under the cursor arrow). ANCHOR_CURSOR puts it bottom-center at the +-- cursor instead, so we anchor manually. We also hook OnUpdate to +-- track cursor motion while the tooltip is owned by the row, mirroring +-- how default ANCHOR_CURSOR follows the mouse. +local TOOLTIP_CURSOR_OFFSET_X = -8 +local TOOLTIP_CURSOR_OFFSET_Y = 16 + +local function PlaceTooltipBottomRightAtCursor(tooltip) + tooltip:ClearAllPoints() + local cx, cy = GetCursorPosition() + local scale = UIParent:GetEffectiveScale() or 1 + if scale == 0 then scale = 1 end + tooltip:SetPoint( + "BOTTOMRIGHT", UIParent, "BOTTOMLEFT", + (cx / scale) + TOOLTIP_CURSOR_OFFSET_X, + (cy / scale) + TOOLTIP_CURSOR_OFFSET_Y + ) +end + +local function AnchorTooltipAtCursor(tooltip, ownerFrame) + tooltip:SetOwner(ownerFrame, "ANCHOR_NONE") + PlaceTooltipBottomRightAtCursor(tooltip) + tooltip._easyFindCursorFollow = ownerFrame + if not tooltip._easyFindCursorHooked then + tooltip._easyFindCursorHooked = true + tooltip:HookScript("OnUpdate", function(self) + if self._easyFindCursorFollow and self:IsOwned(self._easyFindCursorFollow) then + PlaceTooltipBottomRightAtCursor(self) + end + end) + tooltip:HookScript("OnHide", function(self) + self._easyFindCursorFollow = nil + end) + end +end + +-- Special placement for GEAR tooltips: items render with an attached +-- item-compare frame doubled in width, so cursor-anchored tooltips +-- end up covering the result row that spawned them. Anchor to the +-- search/results panel's outside edge (whichever side has more screen +-- room) so the cursor and our own UI both stay clear. +local function AnchorGearTooltip(tooltip, ownerFrame) + tooltip:SetOwner(ownerFrame, "ANCHOR_NONE") + tooltip:ClearAllPoints() + + local panel = (resultsFrame and resultsFrame:IsShown()) and resultsFrame + or (searchFrame and searchFrame:IsShown()) and searchFrame + or ownerFrame + + local left = panel and panel:GetLeft() + local right = panel and panel:GetRight() + local top = panel and panel:GetTop() + local screenW = UIParent:GetWidth() or 0 + + if left and right and top and screenW > 0 then + local roomRight = screenW - right + local roomLeft = left + local gap = 8 + if roomRight >= roomLeft then + tooltip:SetPoint("TOPLEFT", panel, "TOPRIGHT", gap, 0) + else + tooltip:SetPoint("TOPRIGHT", panel, "TOPLEFT", -gap, 0) + end + return + end + + -- Fallback when the panel isn't measurable. + tooltip:SetOwner(ownerFrame, "ANCHOR_RIGHT") +end + local function GetActionHint(data) if not data then return nil end if data.titleID then return "Select to apply as your title" end if data.mountID then return "Select to summon mount" end if data.petID then return "Select to summon pet" end - if data.toyItemID then return "Select to use toy" end + if data.toyItemID then + return data.isToyboxOnly and "Select to show in Toy Box" or "Select to use toy" + end if data.heirloomItemID then return "Select to add heirloom to bags" end if data.outfitID then return "Select to wear outfit" end if data.gearSetID then return "Select to equip gear set" end - if data.transmogSetID then return "Select to preview appearance set" end + if data.transmogSetID then return "Select to preview | Ctrl+click to try on" end if data.spellID and data.category == "Ability" then return IsSpellbookOnlyAbility(data) and "Select to show in spellbook" or "Select to cast" end if data.macroIndex then return "Select to run macro | Ctrl+click to edit" end if data.itemID and data.category == "Bag" then - if data.isEquippable or (data.equipLoc and data.equipLoc ~= "") then + -- Only treat items with a real gear slot as "equippable". Some + -- consumables flunk IsEquippableItem at the database level, so + -- key off equipLoc directly: empty / NON_EQUIP / AMMO / QUIVER + -- aren't gear slots and should still read "Select to use". + local slot = data.equipLoc + if slot and slot ~= "" and slot ~= "INVTYPE_NON_EQUIP" + and slot ~= "INVTYPE_AMMO" and slot ~= "INVTYPE_QUIVER" then return "Select to equip item" end + -- Containers / quest items / wrapped boxes: the right-click + -- action opens the contents, not "use". GetItemInfo returns the + -- item type as the 6th value. + if GetItemInfo then + local itemType = select(6, GetItemInfo(data.itemID)) + if itemType == "Container" or itemType == "Quest" then + return "Select to open item" + end + end return "Select to use item" end if data.mapSearchResult then @@ -237,11 +402,14 @@ local function GetActionHint(data) return "Select to open Encounter Journal" end if data.settingType == "dropdown" and data.settingVariable then - return "Select to cycle value" + return "Select to open setting" end if data.settingType == "checkbox" and data.settingVariable then return "Select to toggle" end + if data.settingType == "keybind" and data.bindingAction then + return "Hover a slot then click to rebind" + end return nil end @@ -304,13 +472,13 @@ function UI:SyncOutfitPins() UIPins.SyncOutfits() end -local function ShowPinPopup(_, isPinned, onPinAction, onGuide, onAddAlias) +local function ShowPinPopup(_, isPinned, onPinAction, onGuide, onAddAlias, extra) Utils.ShowPinMenu("EasyFindPinPopup", isPinned, onPinAction, onGuide, onAddAlias, { strata = "TOOLTIP", level = 100, width = 96, rowHeight = 22, - }) + }, extra) end local unearnedTooltip @@ -599,7 +767,11 @@ function UI:CreateSearchFrame() searchFrame = CreateFrame("Frame", "EasyFindSearchFrame", UIParent, "BackdropTemplate") UI.searchFrame = searchFrame searchFrame:SetSize(250, ns.SEARCHBAR_HEIGHT) - searchFrame:SetFrameStrata("MEDIUM") + -- FULLSCREEN_DIALOG keeps the search bar above the default UI's + -- DIALOG-strata menus (Game Menu, Options panel, etc.) so opening + -- the bar from inside any in-game menu still puts our results on + -- top instead of getting buried. + searchFrame:SetFrameStrata("FULLSCREEN_DIALOG") searchFrame:SetMovable(true) searchFrame:EnableMouse(true) searchFrame:SetClampedToScreen(true) @@ -623,7 +795,7 @@ function UI:CreateSearchFrame() -- searchFrame so it follows movement / resizing. containerFrame = CreateFrame("Frame", "EasyFindContainerFrame", UIParent) UI.containerFrame = containerFrame - containerFrame:SetFrameStrata("MEDIUM") + containerFrame:SetFrameStrata("FULLSCREEN_DIALOG") containerFrame:SetFrameLevel(math.max(0, searchFrame:GetFrameLevel() - 1)) containerFrame:SetPoint("TOPLEFT", searchFrame, "TOPLEFT", 0, 0) containerFrame:SetPoint("TOPRIGHT", searchFrame, "TOPRIGHT", 0, 0) @@ -946,6 +1118,23 @@ function UI:CreateSearchFrame() -- handlers don't get clobbered. The candidate source is the first -- visible result row name, so the suggested completion always -- aligns with what the user lands on if they press Enter. + -- Strip WoW inline markup from item / quest names so the autocomplete + -- suggestion doesn't leak atlas / color / texture / hyperlink codes + -- ("|A:professions-chaticon-quality-...|a", "|cffrrggbb...|r", etc.). + -- Markup is usually preceded by a space ("Item Name |A:...|a"); after + -- stripping the markup, also collapse runs of whitespace and trim the + -- result so the suggestion ends cleanly. + local function StripMarkup(s) + if not s then return s end + s = s:gsub("|A:[^|]*|a", "") + s = s:gsub("|T[^|]*|t", "") + s = s:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "") + s = s:gsub("|H[^|]+|h(.-)|h", "%1") + s = s:gsub("%s+", " ") + s = s:match("^%s*(.-)%s*$") or s + return s + end + Utils.AttachAutocomplete(editBox, { findCandidate = function(typed) if not typed or typed == "" then return nil end @@ -953,7 +1142,8 @@ function UI:CreateSearchFrame() for i = 1, MAX_BUTTON_POOL do local row = resultButtons[i] if not row or not row:IsShown() then break end - local nm = row.data and row.data.name + local rawName = row.data and row.data.name + local nm = StripMarkup(rawName) if nm and #nm >= #typed then local prefix = nm:sub(1, #typed):lower() if prefix == lower and nm:lower() ~= lower then @@ -1492,7 +1682,7 @@ function UI:CreateSearchFrame() local rd = selRow and selRow.data if rd and (rd.outfitID or rd.toyItemID or (rd.spellID and not IsSpellbookOnlyAbility(rd)) - or rd.mountID or rd.macroIndex + or rd.mountID or rd.macroIndex or rd.slashCommand or (rd.itemID and rd.category == "Bag")) then Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) return @@ -1720,6 +1910,15 @@ function UI:CreateSearchFrame() -- own OnClick toggle is the only state change. Set in OnMouseDown -- of the minimap button (Core.lua), cleared in OnMouseUp. if EasyFind._minimapClickActive then return end + -- An inline setting-dropdown popup is open. It lives on UIParent + -- as a separate top-level frame, so a click inside it is "outside" + -- our search bar and would otherwise close the bar before the + -- popup's OnClick callback runs. + if EasyFind._inlineDropdownMenuOpen then return end + -- Grace window after a popup row was selected. Some setting + -- writes dispatch follow-up events (CVAR updates etc.) that + -- reach this handler with the popup already hidden. + if EasyFind._popupGraceUntil and GetTime() < EasyFind._popupGraceUntil then return end -- WoW's actual click-target focus stack. Use this rather than -- IsMouseOver: GetMouseFoci is what the click dispatch itself -- uses, so a frame in this list is guaranteed to receive the @@ -1792,7 +1991,14 @@ local UI_FILTER_OPTIONS = { { key = "pets", label = "Pets", iconTex = 631719 }, { key = "toys", label = "Toys", iconTex = 454046 }, } }, - { key = "currencies", label = "Currencies", iconTex = 136452 }, + { key = "currencies", label = "Currencies", iconTex = 136452, + flyoutRadio = { + dbKey = "currencyFilterMode", + options = { + { value = "all", label = "This Character Only" }, + { value = "warband", label = "All Warband Transferable" }, + }, + } }, -- Gear: treasure-chest icon from the Encounter Journal loot tab -- spritesheet (texture 522972) for visual consistency with the -- in-game loot UI. hasFlyout flags the row to draw the chevron -- @@ -1801,7 +2007,15 @@ local UI_FILTER_OPTIONS = { { key = "loot", label = "Gear", iconTex = 522972, iconCoords = { 0.730, 0.824, 0.618, 0.660 }, hasFlyout = true }, { key = "map", label = "Map Search", iconTex = 1121272, - iconCoords = { 0.3457, 0.3856, 0.2549, 0.2951 } }, + iconCoords = { 0.3457, 0.3856, 0.2549, 0.2951 }, + flyoutSubFilters = { + { key = "zones", label = "Zones", dbTable = "mapTabFilters" }, + { key = "instances", label = "Instances", dbTable = "mapTabFilters" }, + { key = "flightpath", label = "Flight Paths", dbTable = "mapTabFilters" }, + { key = "travel", label = "Travel", dbTable = "mapTabFilters" }, + { key = "services", label = "Services", dbTable = "mapTabFilters" }, + { key = "rares", label = "Rares", dbTable = "mapTabFilters" }, + } }, { key = "options", label = "Options", iconTex = 1121272, iconCoords = { 0.4451, 0.4705, 0.8079, 0.8344 }, flyoutSubFilters = { @@ -1812,7 +2026,7 @@ local UI_FILTER_OPTIONS = { iconCoords = { 0.3783, 0.4072, 0.9066, 0.9350 } }, -- Talents: leaf icon from the talents atlas spritesheet (4556093), -- visually consistent with the in-game talent tree. - { key = "talents", label = "Talents", iconAtlas = "talents-icon-learnableplus" }, + { key = "talents", label = "Talents", iconAtlas = "UI-HUD-MicroMenu-SpellbookAbilities-Up" }, -- Title icon from PaperDollSidebarTab2 (Titles tab) spritesheet 514608. { key = "titles", label = "Titles", iconTex = 514608, iconCoords = { 0.016, 0.531, 0.324, 0.461 } }, @@ -1838,6 +2052,7 @@ end local UI_BUCKET_BY_CATEGORY = { ["Ability"] = "abilities", ["Boss"] = "bosses", + ["Achievement"] = "achievements", ["Achievements"] = "achievements", ["Guild Achievements"] = "achievements", ["Statistics"] = "achievements", @@ -1846,6 +2061,8 @@ local UI_BUCKET_BY_CATEGORY = { ["Bag"] = "bags", ["Macro"] = "macros", ["Talent"] = "talents", + ["Title"] = "titles", + ["Gear Set"] = "gearSets", ["Game Settings"] = "gameOptions", ["AddOn Settings"] = "addonOptions", } @@ -2381,7 +2598,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- indicator used elsewhere in the WoW UI. flyoutSubFilters drives -- the auto-built sub-filter popup; hasFlyout opts in rows whose -- flyout is built inline below (Gear → gearOptionsPopup). - if opt.flyoutSubFilters or opt.hasFlyout then + if opt.flyoutSubFilters or opt.flyoutRadio or opt.hasFlyout then local chev = row:CreateTexture(nil, "OVERLAY") chev:SetAtlas("common-icon-forwardarrow") chev:SetSize(ICON_SIZE - 2, ICON_SIZE - 2) @@ -2500,7 +2717,9 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) subHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) subRow:SetScript("OnClick", function(self) - EasyFind.db.uiSearchFilters[sub.key] = self:GetChecked() + local target = sub.dbTable and EasyFind.db[sub.dbTable] + or EasyFind.db.uiSearchFilters + target[sub.key] = self:GetChecked() if searchEditBox:GetText() ~= "" then UI:OnSearchTextChanged(searchEditBox:GetText()) end @@ -2566,14 +2785,56 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end row.flyoutSubRows = subRows - popup:SetSize(SUB_POPUP_WIDTH, SUB_PAD * 2 + #opt.flyoutSubFilters * SUB_ROW_H) + + -- "Hide tooltips" checkbox at the bottom of the collections + -- flyout. Toggles the per-group EasyFind.db.hideTooltips + -- setting that the OnEnter handlers consult before showing + -- mount / toy / pet / heirloom / appearance set tooltips. + local extraRows = 0 + local hideTipRow + if opt.key == "collections" then + hideTipRow = CreateFrame("CheckButton", nil, popup) + hideTipRow:SetSize(SUB_POPUP_WIDTH - SUB_PAD * 2, SUB_ROW_H) + hideTipRow:SetHitRectInsets(0, 0, 0, 0) + hideTipRow:SetPoint("TOPLEFT", popup, "TOPLEFT", + SUB_PAD, -(SUB_PAD + #opt.flyoutSubFilters * SUB_ROW_H)) + hideTipRow:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") + hideTipRow:GetNormalTexture():SetSize(CHK, CHK) + hideTipRow:GetNormalTexture():ClearAllPoints() + hideTipRow:GetNormalTexture():SetPoint("LEFT", 4, 0) + hideTipRow:SetCheckedTexture("Interface\\Buttons\\UI-CheckBox-Check") + hideTipRow:GetCheckedTexture():SetSize(CHK, CHK) + hideTipRow:GetCheckedTexture():ClearAllPoints() + hideTipRow:GetCheckedTexture():SetPoint("LEFT", 4, 0) + local lbl = hideTipRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + lbl:SetPoint("LEFT", hideTipRow:GetNormalTexture(), "RIGHT", 4, 0) + lbl:SetText("Hide tooltips") + local hl = hideTipRow:CreateTexture(nil, "HIGHLIGHT") + hl:SetAllPoints() + hl:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + hideTipRow:SetScript("OnClick", function(self) + EasyFind.db.hideTooltips = EasyFind.db.hideTooltips or {} + EasyFind.db.hideTooltips.collections = self:GetChecked() and true or false + end) + row.hideTooltipsRow = hideTipRow + extraRows = 1 + end + popup:SetSize(SUB_POPUP_WIDTH, + SUB_PAD * 2 + (#opt.flyoutSubFilters + extraRows) * SUB_ROW_H) -- Sync sub-row checked state from current DB values. local function SyncSubChecks() - local f = EasyFind.db.uiSearchFilters for _, sub in ipairs(opt.flyoutSubFilters) do local sr = subRows[sub.key] - if sr then sr:SetChecked(f[sub.key] ~= false) end + if sr then + local target = sub.dbTable and EasyFind.db[sub.dbTable] + or EasyFind.db.uiSearchFilters + sr:SetChecked(target[sub.key] ~= false) + end + end + if hideTipRow then + local ht = EasyFind.db.hideTooltips + hideTipRow:SetChecked(ht and ht.collections == true) end end row.SyncFlyoutSubChecks = SyncSubChecks @@ -2636,6 +2897,126 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) dropdown:HookScript("OnHide", function() popup:Hide() end) end + -- Radio flyout (e.g. Currencies > "All Warband Transferable" / + -- "This Character Only"). Mirrors the checkbox flyout above + -- but writes a single dbKey to the chosen option's value, and + -- sub-rows render as radio bullets that uncheck siblings. + if opt.flyoutRadio and not opt.flyoutSubFilters then + local radio = opt.flyoutRadio + local SUB_POPUP_WIDTH = 200 + local SUB_ROW_H = 22 + local SUB_PAD = 6 + + local popup = CreateFrame("Frame", nil, UIParent, "BackdropTemplate") + popup:SetFrameStrata("TOOLTIP") + StylePopup(popup) + popup:EnableMouse(true) + popup:Hide() + row.flyoutPopup = popup + dropdownGuardFrames[#dropdownGuardFrames + 1] = popup + dropdown.flyoutPopups = dropdown.flyoutPopups or {} + dropdown.flyoutPopups[#dropdown.flyoutPopups + 1] = popup + + popup:HookScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end) + popup:HookScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + end) + popup:HookScript("OnEvent", function(self, event) + if event ~= "GLOBAL_MOUSE_DOWN" then return end + if self:IsMouseOver() or row:IsMouseOver() then return end + self:Hide() + end) + + local radioRows = {} + for ri, optionDef in ipairs(radio.options) do + local rRow = CreateFrame("Button", nil, popup) + rRow:SetSize(SUB_POPUP_WIDTH - SUB_PAD * 2, SUB_ROW_H) + rRow:SetPoint("TOPLEFT", popup, "TOPLEFT", + SUB_PAD, -(SUB_PAD + (ri - 1) * SUB_ROW_H)) + + local bullet = rRow:CreateTexture(nil, "ARTWORK") + bullet:SetAtlas("common-dropdown-tickradial") + bullet:SetSize(14, 14) + bullet:SetPoint("LEFT", 4, 0) + + local tick = rRow:CreateTexture(nil, "OVERLAY") + tick:SetAtlas("common-dropdown-icon-radialtick-yellow") + tick:SetSize(14, 14) + tick:SetPoint("LEFT", 4, 0) + tick:Hide() + + local lbl = rRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + lbl:SetPoint("LEFT", bullet, "RIGHT", 6, 0) + lbl:SetText(optionDef.label) + + local hl = rRow:CreateTexture(nil, "HIGHLIGHT") + hl:SetAllPoints() + hl:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + + rRow.tick = tick + rRow.value = optionDef.value + rRow:SetScript("OnClick", function(self) + EasyFind.db[radio.dbKey] = self.value + for _, otherRow in ipairs(radioRows) do + otherRow.tick:SetShown(otherRow.value == self.value) + end + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end) + + radioRows[ri] = rRow + end + + popup:SetSize(SUB_POPUP_WIDTH, SUB_PAD * 2 + #radio.options * SUB_ROW_H) + + local function SyncRadio() + local cur = EasyFind.db[radio.dbKey] + for _, rRow in ipairs(radioRows) do + rRow.tick:SetShown(rRow.value == cur) + end + end + row.SyncFlyoutSubChecks = SyncRadio + + local function PositionPopup() + popup:ClearAllPoints() + popup:SetPoint("TOPLEFT", row, "TOPRIGHT", 4, 0) + end + local hideTimer + local function ShowPopup() + if hideTimer then hideTimer:Cancel(); hideTimer = nil end + for _, sibling in ipairs(dropdown.flyoutPopups or {}) do + if sibling ~= popup and sibling:IsShown() then sibling:Hide() end + end + SyncRadio() + popup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + PositionPopup() + popup:Show() + end + local function MaybeHide() + if popup:IsMouseOver() or row:IsMouseOver() then return end + popup:Hide() + end + local function ScheduleHide() + if hideTimer then hideTimer:Cancel() end + hideTimer = C_Timer.NewTimer(0.15, function() + hideTimer = nil + MaybeHide() + end) + end + row.ShowFlyoutPopup = ShowPopup + row.ScheduleHideFlyoutPopup = ScheduleHide + popup:HookScript("OnLeave", ScheduleHide) + popup:HookScript("OnEnter", function() + if hideTimer then hideTimer:Cancel(); hideTimer = nil end + end) + row:HookScript("OnEnter", ShowPopup) + row:HookScript("OnLeave", ScheduleHide) + dropdown:HookScript("OnHide", function() popup:Hide() end) + end + -- Loot/Gear: side popup with difficulty + spec selector + iLvl -- upgrades checkbox. Opens to the right of the Gear filter row @@ -2654,6 +3035,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local lootSubDefs = { { dbKey = "lootUpgradesOnly", label = "iLvl Upgrades Only" }, + { dbKey = "hideTooltips.loot", label = "Hide tooltips" }, } local lootSubRows = {} for si, sub in ipairs(lootSubDefs) do @@ -2682,12 +3064,27 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) subRow.dbKey = sub.dbKey lootSubRows[si] = subRow + -- Resolve "a.b" dotted keys into a getter/setter so the + -- nested hideTooltips.loot toggle lives alongside the + -- flat lootUpgradesOnly checkbox without duplicating + -- this whole subRow setup. + local function resolveDbPath() + local parent, leaf = sub.dbKey:match("^(.-)%.([^%.]+)$") + if parent then + EasyFind.db[parent] = EasyFind.db[parent] or {} + return EasyFind.db[parent], leaf + end + return EasyFind.db, sub.dbKey + end + subRow:SetScript("OnClick", function(self) - EasyFind.db[sub.dbKey] = self:GetChecked() + local tbl, leaf = resolveDbPath() + tbl[leaf] = self:GetChecked() and true or false if searchEditBox:GetText() ~= "" then UI:OnSearchTextChanged(searchEditBox:GetText()) end end) + subRow.resolveDbPath = resolveDbPath end -- Separator line between iLvl Upgrades checkbox and the @@ -3381,8 +3778,9 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) row.updateLootToggle = function() for _, sr in ipairs(lootSubRows) do - if sr.dbKey and sr.SetChecked then - sr:SetChecked(EasyFind.db[sr.dbKey] ~= false) + if sr.SetChecked and sr.resolveDbPath then + local tbl, leaf = sr.resolveDbPath() + sr:SetChecked(tbl[leaf] == true) end end UpdateSpecLabel() @@ -3525,12 +3923,14 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) uncheckRow:SetScript("OnClick", function() local filters = EasyFind.db.uiSearchFilters local allUnchecked = true - ForEachFilterKey(function(key) - if filters[key] ~= false then allUnchecked = false end + ForEachFilterKey(function(key, opt) + local target = opt.dbTable and EasyFind.db[opt.dbTable] or filters + if target[key] ~= false then allUnchecked = false end end) local newState = allUnchecked - ForEachFilterKey(function(key) - filters[key] = newState + ForEachFilterKey(function(key, opt) + local target = opt.dbTable and EasyFind.db[opt.dbTable] or filters + target[key] = newState end) for _, opt in ipairs(UI_FILTER_OPTIONS) do local row = checkRows[opt.key] @@ -3689,7 +4089,7 @@ function UI:CreateResultsFrame() resultsFrame = CreateFrame("Frame", "EasyFindResultsFrame", searchFrame, "BackdropTemplate") resultsFrame:SetWidth(380) -- Wide to accommodate tree indentation resultsFrame:SetPoint("TOP", searchFrame, "BOTTOM", 0, 2) - resultsFrame:SetFrameStrata("MEDIUM") + resultsFrame:SetFrameStrata("FULLSCREEN_DIALOG") resultsFrame:SetFrameLevel(searchFrame:GetFrameLevel() + 1) resultsFrame:SetBackdrop({ @@ -3751,14 +4151,9 @@ function UI:CreateResultsFrame() end) end) - -- Plain ScrollFrame for clipping + mouse wheel + -- Plain ScrollFrame for clipping. Wheel handler is wired up by + -- CreateMinimalScrollBar so wheel events route through the eased path. local scrollFrame = CreateFrame("ScrollFrame", nil, resultsFrame) - scrollFrame:EnableMouseWheel(true) - scrollFrame:SetScript("OnMouseWheel", function(self, delta) - local range = self:GetVerticalScrollRange() - local cur = self:GetVerticalScroll() - self:SetVerticalScroll(mmax(0, mmin(range, cur - delta * 72))) - end) resultsFrame.scrollFrame = scrollFrame local scrollChild = CreateFrame("Frame", nil, scrollFrame) @@ -3813,6 +4208,125 @@ local pinnedSearchEntries = {} local pinnedSearchPinEntries = {} local pinnedOnlyEntries = {} +-- Inline achievement search: drive Blizzard's own indexed achievement +-- search system (the one that powers the AchievementFrame's search box) +-- in the background. SetAchievementSearchString builds an internal +-- index on first call and emits ACHIEVEMENT_SEARCH_UPDATED when results +-- are ready; GetFilteredAchievementID(i) reads them out. We pre-warm +-- the index once after login so per-keystroke searches are instant, +-- and we synthesize result rows on the fly without pre-loading any of +-- the ~25k achievement entries into our own search index. +local ACHIEVEMENT_PROTO = { + keywords = {}, + keywordsLower = {}, + category = "Achievement", + buttonFrame = "AchievementMicroButton", + path = { "Achievements" }, +} +local ACHIEVEMENT_MT = { __index = ACHIEVEMENT_PROTO } +local achievementEntryByID = {} +local achSearchCache = {} +local achSearchPending = nil +local achSearchListener +local achSearchPrewarmed = false +local ACH_MAX_RESULTS = 8 + +local function GetOrCreateAchievementEntry(id, name, icon) + local entry = achievementEntryByID[id] + if entry then + if name and entry.name ~= name then + entry.name = name + entry.nameLower = slower(name) + end + if icon and entry.icon ~= icon then entry.icon = icon end + return entry + end + entry = setmetatable({ + name = name, + nameLower = slower(name or ""), + achievementID = id, + icon = icon, + steps = { { buttonFrame = "AchievementMicroButton", achievementID = id } }, + }, ACHIEVEMENT_MT) + achievementEntryByID[id] = entry + return entry +end + +local function CollectAchievementSearchResults(query) + local getNum = _G["GetNumFilteredAchievements"] + local getID = _G["GetFilteredAchievementID"] + local getInfo = _G["GetAchievementInfo"] + if not getNum or not getID or not getInfo then return nil end + local count = getNum() or 0 + if count == 0 then return {} end + local capped = count > ACH_MAX_RESULTS and ACH_MAX_RESULTS or count + local results = {} + for i = 1, capped do + local id = getID(i) + if id then + local _, name, _, _, _, _, _, _, _, icon = getInfo(id) + if name and name ~= "" then + results[#results + 1] = GetOrCreateAchievementEntry(id, name, icon) + end + end + end + return results +end + +local function EnsureAchievementSearchListener() + if achSearchListener then return end + achSearchListener = CreateFrame("Frame") + achSearchListener:RegisterEvent("ACHIEVEMENT_SEARCH_UPDATED") + achSearchListener:SetScript("OnEvent", function() + local pending = achSearchPending + if not pending then return end + achSearchPending = nil + local results = CollectAchievementSearchResults(pending) + if results then achSearchCache[pending] = results end + local eb = searchFrame and searchFrame.editBox + if eb then + -- Compare against the typed prefix (cursor-position cut), + -- not the full editbox text. The autocomplete suffix is + -- selected past the cursor and would make full text != pending. + local full = eb:GetText() or "" + local cursor = eb:GetCursorPosition() or #full + local typedPrefix = strtrim(full:sub(1, cursor)) + if typedPrefix == pending then + UI:OnSearchTextChanged(typedPrefix, true) + end + end + end) +end + +function UI:RequestAchievementSearch(query) + if not query or #query < 2 then return nil end + local cached = achSearchCache[query] + if cached then return cached end + local setSearch = _G["SetAchievementSearchString"] + if not setSearch then return nil end + EnsureAchievementSearchListener() + achSearchPending = query + pcall(setSearch, query) + return nil +end + +function UI:PrewarmAchievementSearch() + if achSearchPrewarmed then return end + achSearchPrewarmed = true + local loadUI = _G["AchievementFrame_LoadUI"] + if loadUI then pcall(loadUI) end + EnsureAchievementSearchListener() + local setSearch = _G["SetAchievementSearchString"] + if not setSearch then return end + -- Trigger the one-time index build off the player's typing path. + -- A nonsensical query that won't match anything keeps the search + -- box visually empty if the user opens AchievementFrame later. + pcall(setSearch, "\1") + if Utils and Utils.SafeAfter then + Utils.SafeAfter(0.5, function() pcall(setSearch, "") end) + end +end + local SCRATCH = { visible = {}, isLastChild = {}, @@ -3908,9 +4422,11 @@ local function ActivateSettingResult(data) elseif stype == "slider" then RefocusSearchEditBox() elseif stype == "dropdown" then - if not UI:CycleSettingDropdown(data) then - UI:OpenSettingNoClose(data) - end + -- Dropdown row click opens the Settings panel for that variable. + -- Inline cycling has its own controls (paddle arrows, center + -- popup) right on the row, so the row click itself shouldn't + -- duplicate that behavior. + UI:OpenSettingNoClose(data) else UI:OpenSettingNoClose(data) end @@ -3929,6 +4445,135 @@ local function FlatNameLess(ra, rb) return (ra.data.name or "") < (rb.data.name or "") end +-- Custom popup for inline setting dropdowns. Replaces MenuUtil.CreateContextMenu +-- because MenuUtil's option buttons can have a click target that's narrower than +-- the visible label for very long strings, which silently swallows selection. +-- This popup auto-sizes to the longest label so every row's clickable area +-- matches its visible text exactly. +local inlineDropdownPopup +local inlineDropdownRows = {} +local function GetInlineDropdownPopup() + if inlineDropdownPopup then return inlineDropdownPopup end + local p = CreateFrame("Frame", "EasyFindInlineDropdownPopup", UIParent, "BackdropTemplate") + -- Match the bar's FULLSCREEN_DIALOG so the popup renders ABOVE the + -- bar instead of behind it. Other popups (filter dropdown sub-menus, + -- spec/class flyouts) already use TOOLTIP which sits above this. + p:SetFrameStrata("FULLSCREEN_DIALOG") + p:SetFrameLevel(200) + p:Hide() + p:EnableMouse(true) + p:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = false, edgeSize = 12, + insets = { left = 4, right = 4, top = 4, bottom = 4 }, + }) + p:SetBackdropColor(0.05, 0.05, 0.05, 0.95) + p:SetBackdropBorderColor(0.6, 0.6, 0.6, 1) + p:SetScript("OnEvent", function(self, event) + if event ~= "GLOBAL_MOUSE_DOWN" then return end + if self:IsMouseOver() then return end + if self.owner and self.owner:IsMouseOver() then return end + self:Hide() + end) + p:SetScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + EasyFind._inlineDropdownMenuOpen = true + end) + p:SetScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + EasyFind._inlineDropdownMenuOpen = false + end) + inlineDropdownPopup = p + return p +end + +local function GetInlineDropdownRow(popup, index) + local row = inlineDropdownRows[index] + if row then return row end + row = CreateFrame("Button", nil, popup) + row:SetHeight(20) + local radio = row:CreateTexture(nil, "ARTWORK") + radio:SetSize(14, 14) + radio:SetTexture("Interface\\AddOns\\EasyFind\\radio-off") + radio:SetPoint("LEFT", 6, 0) + row.radio = radio + local lbl = row:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + lbl:SetPoint("LEFT", radio, "RIGHT", 6, 0) + lbl:SetPoint("RIGHT", row, "RIGHT", -6, 0) + lbl:SetJustifyH("LEFT") + lbl:SetWordWrap(false) + lbl:SetMaxLines(1) + row.lbl = lbl + local hl = row:CreateTexture(nil, "HIGHLIGHT") + hl:SetAllPoints() + hl:SetColorTexture(1, 1, 1, 0.1) + inlineDropdownRows[index] = row + return row +end + +local function ShowInlineSettingDropdown(owner, opts, getCurrent, onSelect) + local popup = GetInlineDropdownPopup() + popup.owner = owner + -- Hide any prior rows in the pool. + for i = 1, #inlineDropdownRows do + inlineDropdownRows[i]:Hide() + inlineDropdownRows[i]:SetScript("OnClick", nil) + end + -- Measure longest label so the popup auto-sizes. + local maxTextW = 0 + local probe = popup._probeFS + if not probe then + probe = popup:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + probe:Hide() + popup._probeFS = probe + end + for i = 1, #opts do + local label = opts[i].label or tostring(opts[i].value) + probe:SetText(label) + local w = (probe.GetUnboundedStringWidth and probe:GetUnboundedStringWidth()) + or (probe.GetStringWidth and probe:GetStringWidth()) + or 0 + if w > maxTextW then maxTextW = w end + end + local PAD_LR = 6 + 14 + 6 + 6 -- left pad + radio + gap + right pad + local PAD_TOP = 8 + local PAD_BOT = 8 + local ROW_H = 20 + local popupW = math.max(140, math.ceil(maxTextW) + PAD_LR + 12) + local popupH = PAD_TOP + (#opts * ROW_H) + PAD_BOT + popup:SetSize(popupW, popupH) + popup:ClearAllPoints() + popup:SetPoint("TOPRIGHT", owner, "BOTTOMRIGHT", 0, -2) + -- Build rows. + local cur = getCurrent and getCurrent() or nil + for i = 1, #opts do + local opt = opts[i] + local row = GetInlineDropdownRow(popup, i) + row:ClearAllPoints() + row:SetPoint("TOPLEFT", popup, "TOPLEFT", 6, -PAD_TOP - (i - 1) * ROW_H) + row:SetPoint("RIGHT", popup, "RIGHT", -6, 0) + row.lbl:SetText(opt.label or tostring(opt.value)) + local checked = cur == opt.value or tostring(cur) == tostring(opt.value) + row.radio:SetTexture(checked and "Interface\\AddOns\\EasyFind\\radio-on" or "Interface\\AddOns\\EasyFind\\radio-off") + local optValue = opt.value + row:SetScript("OnClick", function() + if onSelect then onSelect(optValue) end + EasyFind._popupGraceUntil = GetTime() + 0.2 + popup:Hide() + end) + row:Show() + end + popup:Show() + popup:Raise() +end + +UI.HideInlineSettingDropdown = function() + if inlineDropdownPopup and inlineDropdownPopup:IsShown() then + inlineDropdownPopup:Hide() + end +end + -- Expand a container node: inject its database children into cachedHierarchical. function UI:CreateResultButton(index) local scrollChild = resultsFrame.scrollChild @@ -4358,6 +5003,30 @@ function UI:CreateResultButton(index) kb1:SetScript("OnClick", MakeBindingClickHandler(1)) kb2:SetScript("OnClick", MakeBindingClickHandler(2)) + -- Hovering a kb button overrides the row's action-hint subtext with a + -- slot-specific hint ("primary" vs "alternate") so the user knows + -- which key the next press will bind. OnLeave hands back to the row. + local function MakeKbHoverHandler(slotLabel) + return function(self) + if not resultRow.pathSubtext or not resultRow.pathSubtext:IsShown() then return end + resultRow.pathSubtext:SetText("Click then press a key to bind " .. slotLabel) + resultRow.pathSubtext:SetTextColor(0.85, 0.78, 0.55, 1.0) + end + end + local function KbLeaveHandler(self) + if not resultRow.pathSubtext or not resultRow.pathSubtext:IsShown() then return end + if resultRow:IsMouseOver() then + ApplyActionHint(resultRow) + else + resultRow.pathSubtext:SetText(GetFlatSubtext(resultRow.data)) + resultRow.pathSubtext:SetTextColor(0.55, 0.55, 0.55, 1.0) + end + end + kb1:HookScript("OnEnter", MakeKbHoverHandler("primary")) + kb1:HookScript("OnLeave", KbLeaveHandler) + kb2:HookScript("OnEnter", MakeKbHoverHandler("alternate")) + kb2:HookScript("OnLeave", KbLeaveHandler) + -- Inline dropdown widget for settings whose options enumerate. Matches -- the in-game SettingsDropdownWithSteppers control: -- prev/next: common-dropdown-c-button-hover-2 (25x25 paddle body) @@ -4423,15 +5092,46 @@ function UI:CreateResultButton(index) ddTxt:SetPoint("RIGHT", ddArrow, "LEFT", -4, 0) ddTxt:SetJustifyH("CENTER") ddTxt:SetWordWrap(false) + ddTxt:SetNonSpaceWrap(false) + ddTxt:SetMaxLines(1) end resultRow.settingDropdownLabel = ddCenter - - -- Open the native Blizzard menu on click. Reads opts/current value + -- Width-bounded truncation with ellipses. Anchor-clipped FontStrings + -- silently chop with no marker, so we measure and append "..." when + -- the value would overflow the chevron-padded button. + resultRow.SetSettingDropdownText = function(self, value) + local btn = self.settingDropdownLabel + if not btn then return end + value = value or "" + btn:SetText(value) + local fs = btn:GetFontString() + if not fs then return end + local btnW = btn:GetWidth() or 0 + -- Reserve room for: 8px left pad + chevron at -8 from right (12 wide, + -- so 20 from right edge) + 8px gap before the chevron = 36 total. + local maxW = btnW - 38 + if maxW <= 0 or #value == 0 then return end + local function getW() + return (fs.GetUnboundedStringWidth and fs:GetUnboundedStringWidth()) + or (fs.GetStringWidth and fs:GetStringWidth()) + or 0 + end + if getW() <= maxW then return end + for cut = #value - 1, 1, -1 do + btn:SetText(value:sub(1, cut) .. "...") + if getW() <= maxW then return end + end + end + + -- Open our custom dropdown popup on click. Reads opts/current value -- from whatever data the row has *now*, since rows are pooled and the - -- same physical button serves different settings across renders. + -- same physical button serves different settings across renders. We + -- avoid MenuUtil here because its option click target can be narrower + -- than the visible label for very long strings, silently swallowing + -- selection on the longest entry. ddCenter:SetScript("OnClick", function(self) local rowData = resultRow.data - if not rowData or not rowData.settingVariable or not MenuUtil then return end + if not rowData or not rowData.settingVariable then return end local opts = rowData.settingOptions if not opts and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetOptionsForVariable then opts = ns.BlizzOptionsSearch.GetOptionsForVariable(rowData.settingVariable) @@ -4439,18 +5139,9 @@ function UI:CreateResultButton(index) end if not opts or #opts == 0 then return end local var = rowData.settingVariable - MenuUtil.CreateContextMenu(self, function(_, rootDescription) - for i = 1, #opts do - local opt = opts[i] - local optValue = opt.value - rootDescription:CreateRadio(opt.label or tostring(optValue), function() - local cur = ReadSettingVariable(var) - return cur == optValue or tostring(cur) == tostring(optValue) - end, function() - UI:SetSettingDropdownValue(rowData, optValue) - end) - end - end) + ShowInlineSettingDropdown(self, opts, + function() return ReadSettingVariable(var) end, + function(value) UI:SetSettingDropdownValue(rowData, value) end) end) ddPrev:SetScript("OnClick", function() @@ -4531,6 +5222,12 @@ function UI:CreateResultButton(index) text:SetPoint("LEFT", icon, "RIGHT", 4, 0) text:SetPoint("RIGHT", amountText, "LEFT", -4, 0) text:SetJustifyH("LEFT") + -- Single-line, no wrap. The render path uses SetClippedText below + -- to append "..." when the name is too wide for the available + -- horizontal space, matching how the dropdown widget truncates. + text:SetWordWrap(false) + text:SetNonSpaceWrap(false) + text:SetMaxLines(1) resultRow.text = text -- Path subtext (flat-headerless mode only). Anchored under the name in the @@ -4773,6 +5470,43 @@ function UI:CreateResultButton(index) local onAddAlias = canAlias and function() UI:PromptForAlias(pinData) end or nil + local extra + if pinData.achievementID and pinData.category == "Achievement" then + local achID = pinData.achievementID + local isTracked = UI:IsAchievementTracked(achID) + extra = { + isTracked = isTracked, + onTrack = function() UI:ToggleAchievementTracked(achID) end, + } + elseif pinData.category == "Currency" and pinData.currencyID then + local cid = pinData.currencyID + extra = { + isOnBackpack = UI:IsCurrencyOnBackpack(cid), + onToggleBackpack = function() UI:ToggleCurrencyBackpack(cid) end, + } + if UI:IsCurrencyTransferable(cid) then + extra.onTransfer = function() UI:RouteCurrencyTransfer(pinData) end + end + elseif pinData.transmogSetID then + local sid = pinData.transmogSetID + extra = { + isFavorite = UI:IsTransmogSetFavorite(sid), + onToggleFavorite = function() UI:ToggleTransmogSetFavorite(sid) end, + } + elseif pinData.petID then + local pid = pinData.petID + local cageable = UI:IsPetCageable(pid) + extra = { + onSummon = function() UI:SummonPet(pid) end, + onRename = function() UI:RenamePet(pid) end, + isFavorite = UI:IsPetFavorite(pid), + onToggleFavorite = function() UI:TogglePetFavorite(pid) end, + onCageOrRelease = function() + if cageable then UI:CagePet(pid) else UI:ReleasePet(pid) end + end, + isCageable = cageable, + } + end ShowPinPopup(self, isPinned, function() if isPinned then UnpinUIItem(pinData) @@ -4786,7 +5520,7 @@ function UI:CreateResultButton(index) else UI:OnSearchTextChanged(text, true) end - end, onGuide, onAddAlias) + end, onGuide, onAddAlias, extra) return end @@ -4853,12 +5587,42 @@ function UI:CreateResultButton(index) resultRow:SetScript("OnEnter", function(self) -- Hover-based action hint (mirrors keyboard selection hint). ApplyActionHint(self) + -- Macro rows: resolve the #showtooltip / first cast/use line to a + -- spell or item via the macro APIs and surface that tooltip. Falls + -- back to displaying the macro body when neither resolves. + if self.data and self.data.macroIndex and self.data.category == "Macro" then + local idx = self.data.macroIndex + local spellID + if GetMacroSpell then + local _, _, sid = GetMacroSpell(idx) + spellID = sid + end + local itemName, itemLink + if not spellID and GetMacroItem then + itemName, itemLink = GetMacroItem(idx) + end + AnchorTooltipAtCursor(GameTooltip, self) + if spellID and GameTooltip.SetSpellByID then + GameTooltip:SetSpellByID(spellID) + elseif itemLink and GameTooltip.SetHyperlink then + GameTooltip:SetHyperlink(itemLink) + elseif itemName and GameTooltip.SetItemByID and select(2, GetItemInfo(itemName)) then + GameTooltip:SetHyperlink(select(2, GetItemInfo(itemName))) + else + GameTooltip:SetText(self.data.name or "Macro", 1, 1, 1) + if self.data.macroBody and self.data.macroBody ~= "" then + GameTooltip:AddLine(self.data.macroBody, 0.7, 0.7, 0.7, true) + end + end + GameTooltip:Show() + return + end -- Talent / Ability rows: show the spell tooltip (talents share the -- spell tooltip surface). Mirrors the icon-OnEnter path so the row -- itself produces a tooltip even when the cursor is on the name. if self.data and self.data.spellID and (self.data.category == "Talent" or self.data.category == "Ability") then - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + AnchorTooltipAtCursor(GameTooltip, self) if GameTooltip.SetSpellByID then GameTooltip:SetSpellByID(self.data.spellID) else @@ -4867,10 +5631,28 @@ function UI:CreateResultButton(index) GameTooltip:Show() return end + -- Currency row: show the currency tooltip (icon + description + + -- amount). Routed early so it doesn't fall through to the + -- generic icon-tooltip block, which only checks mount / toy / + -- pet / etc. fields and would otherwise miss currencies. + if self.data and self.data.category == "Currency" and self.data.currencyID then + local cid = self.data.currencyID + AnchorTooltipAtCursor(GameTooltip, self) + if GameTooltip.SetCurrencyByID then + GameTooltip:SetCurrencyByID(cid) + elseif C_CurrencyInfo and C_CurrencyInfo.GetCurrencyLink then + local lok, link = pcall(C_CurrencyInfo.GetCurrencyLink, cid) + if lok and link and GameTooltip.SetHyperlink then + GameTooltip:SetHyperlink(link) + end + end + GameTooltip:Show() + return + end -- Keybinding row: show the action name plus current bindings. if self.data and self.data.settingType == "keybind" and self.data.bindingAction then local action = self.data.bindingAction - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + AnchorTooltipAtCursor(GameTooltip, self) GameTooltip:SetText(self.data.name or action, 1, 1, 1) local k1, k2 = GetBindingKey(action) GameTooltip:AddLine(" ") @@ -4891,7 +5673,7 @@ function UI:CreateResultButton(index) if ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetTooltipForVariable then tipText = ns.BlizzOptionsSearch.GetTooltipForVariable(var, self.data.name) end - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + AnchorTooltipAtCursor(GameTooltip, self) GameTooltip:SetText(self.data.name or var, 1, 1, 1) if tipText then GameTooltip:AddLine(tipText, 1, 0.82, 0, true) @@ -4940,15 +5722,38 @@ function UI:CreateResultButton(index) ns.MapSearch:PreviewUIResult(self.data) end elseif self.data and self.icon and self.icon:IsShown() then + -- Per-flyout tooltip suppression. Collections covers the + -- mount / toy / pet / outfit / heirloom / appearance set + -- group; loot (the gear flyout's "Hide tooltips") covers + -- itemized gear results AND any bag item that's actual + -- gear — the entry being a "Bag" by category is incidental + -- to where it lives, the user-facing class of thing is + -- "gear" either way. + local ht = EasyFind.db.hideTooltips + local ic = self.icon + if ht and ht.collections and (ic.mountID or ic.toyItemID or ic.petID + or ic.outfitID or ic.heirloomItemID or ic.transmogSetID) then + return + end + if ht and ht.loot then + if ic.lootItemID then return end + if ic.bagItemID and self.data and self.data.equipLoc then + local slot = self.data.equipLoc + if slot ~= "" and slot ~= "INVTYPE_NON_EQUIP" + and slot ~= "INVTYPE_AMMO" and slot ~= "INVTYPE_QUIVER" then + return + end + end + end -- Mount tooltip (show on icon hover) if self.icon.mountID and self.icon.spellID then - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + AnchorTooltipAtCursor(GameTooltip, self) GameTooltip:SetMountBySpellID(self.icon.spellID) GameTooltip:Show() -- Toy tooltip with live cooldown refresh elseif self.icon.toyItemID then local toyItemID = self.icon.toyItemID - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + AnchorTooltipAtCursor(GameTooltip, self) GameTooltip:SetToyByItemID(toyItemID) GameTooltip:Show() self.toyTooltipTicker = C_Timer.NewTicker(1, function() @@ -4962,16 +5767,16 @@ function UI:CreateResultButton(index) local link = C_PetJournal and C_PetJournal.GetBattlePetLink and C_PetJournal.GetBattlePetLink(self.icon.petID) if link and BattlePetToolTip_ShowLink then - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + AnchorTooltipAtCursor(GameTooltip, self) BattlePetToolTip_ShowLink(link) elseif link then - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + AnchorTooltipAtCursor(GameTooltip, self) GameTooltip:SetHyperlink(link) GameTooltip:Show() end -- Outfit tooltip elseif self.icon.outfitID then - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + AnchorTooltipAtCursor(GameTooltip, self) 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) @@ -4999,7 +5804,7 @@ function UI:CreateResultButton(index) GameTooltip:Show() -- Loot item tooltip elseif self.icon.lootItemID then - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + AnchorGearTooltip(GameTooltip, self) local itemLink = self.data and ns.Database and ns.Database:GetLootItemLink(self.data) if itemLink then GameTooltip:SetHyperlink(itemLink) @@ -5009,22 +5814,32 @@ function UI:CreateResultButton(index) GameTooltip:Show() -- Heirloom tooltip elseif self.icon.heirloomItemID then - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + AnchorGearTooltip(GameTooltip, self) GameTooltip:SetItemByID(self.icon.heirloomItemID) GameTooltip:Show() -- Ability tooltip (must come after mount, since mount entries -- carry both mountID and spellID and use the mount tooltip). elseif self.icon.spellID then - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + AnchorTooltipAtCursor(GameTooltip, self) if GameTooltip.SetSpellByID then GameTooltip:SetSpellByID(self.icon.spellID) else GameTooltip:SetHyperlink("spell:" .. self.icon.spellID) end GameTooltip:Show() - -- Bag item tooltip + -- Bag item tooltip. Real gear (helm/chest/weapon/etc.) gets + -- the panel-edge buffer because of the compare frame; bag + -- consumables / containers are normal-sized so they follow + -- the cursor like everything else. elseif self.icon.bagItemID then - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + local slot = self.data and self.data.equipLoc + local isGear = slot and slot ~= "" and slot ~= "INVTYPE_NON_EQUIP" + and slot ~= "INVTYPE_AMMO" and slot ~= "INVTYPE_QUIVER" + if isGear then + AnchorGearTooltip(GameTooltip, self) + else + AnchorTooltipAtCursor(GameTooltip, self) + end local link = self.data and self.data.bagItemLink if link then GameTooltip:SetHyperlink(link) @@ -5300,7 +6115,9 @@ function UI:OnSearchTextChanged(text, force) or filters.currencies == false or filters.reputations == false or filters.bags == false or filters.macros == false or filters.options == false - or filters.gameOptions == false or filters.addonOptions == false) then + or filters.gameOptions == false or filters.addonOptions == false + or filters.titles == false or filters.gearSets == false + or filters.talents == false) then wipe(SCRATCH.filteredResults) local filtered = SCRATCH.filteredResults local fi = 0 @@ -5324,6 +6141,16 @@ function UI:OnSearchTextChanged(text, force) results = filtered end + -- Currency filter mode: kept in DB so it can drive bidirectional + -- sync with the in-game CurrencyFrame's filter dropdown later, but + -- we deliberately don't prune our own search results here. The + -- in-game tab shows every currency the character has discovered + -- (zero-quantity warband-transferable ones included), and an + -- earlier per-cache `isAccountTransferable` check was hiding some + -- of those because the flag's truthiness varied across builds. + -- Showing everything keeps search at least as inclusive as the + -- in-game tab regardless of what mode is selected. + -- Map Search: search static locations and dungeon entrances, merge into results local mapResults if filters and filters.map ~= false and ns.MapSearch and ns.MapSearch.SearchForUI then @@ -5338,6 +6165,44 @@ function UI:OnSearchTextChanged(text, force) end if #combined > 1 then tsort(combined, FlatNameLess) end + -- Hard cap on visible results. The scoring step already ranks by + -- relevance; everything past the cap is noise the user has to scroll + -- through. Pinned items aren't in this set (they only show on empty + -- query), so the cap is a clean top-N over the actual search match + -- list. 15 matches the original uiMaxResults default. + local TOP_N = 15 + if #combined > TOP_N then + for ri = #combined, TOP_N + 1, -1 do combined[ri] = nil end + end + + -- Inline achievement results: drive Blizzard's indexed achievement + -- search and surface its results directly in our dropdown. First + -- call for a given query kicks off the (already-built) index lookup + -- and returns nothing; ACHIEVEMENT_SEARCH_UPDATED fires next frame + -- and we re-render with the cached results. Score each one through + -- ScoreName so they interleave naturally with mount / toy / setting + -- hits ranked off the same query, instead of clumping at a fixed + -- band. + if text ~= "" and (not filters or filters.achievements ~= false) then + local achHits = self:RequestAchievementSearch(text) + if achHits and ns.Database and ns.Database.ScoreName then + local lowerQ = slower(text) + local qLen = #lowerQ + -- Blizzard's index returns matches across name + description + -- + criteria; we only want name matches here (the user has + -- the UI Elements filter for navigating to achievement + -- categories by their subtext). Drop anything ScoreName + -- can't rank against the name itself. + for ai = 1, #achHits do + local entry = achHits[ai] + local score = ns.Database:ScoreName(entry.nameLower, lowerQ, qLen) + if score and score > 0 then + combined[#combined + 1] = { data = entry, score = score } + end + end + end + end + local n = 0 for ri = 1, #combined do local d = combined[ri] and combined[ri].data @@ -5354,6 +6219,7 @@ function UI:OnSearchTextChanged(text, force) e.isMatch = true e.isFlat = true e.flatCatKey = nil + e.isPinned = IsUIItemPinned(d) and true or false e.data = d end end @@ -5468,7 +6334,10 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if same then for hi = 1, n do local e = hierarchical[hi] - if last[hi * 2 - 1] ~= e.data or last[hi * 2] ~= (e.depth or 0) then + local stride = (hi - 1) * 3 + if last[stride + 1] ~= e.data + or last[stride + 2] ~= (e.depth or 0) + or last[stride + 3] ~= (e.isPinned and 1 or 0) then same = false break end @@ -5490,10 +6359,12 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) last.maxResultsH = maxResultsH for hi = 1, n do local e = hierarchical[hi] - last[hi * 2 - 1] = e.data - last[hi * 2] = e.depth or 0 + local stride = (hi - 1) * 3 + last[stride + 1] = e.data + last[stride + 2] = e.depth or 0 + last[stride + 3] = e.isPinned and 1 or 0 end - for i = n * 2 + 1, #last do last[i] = nil end + for i = n * 3 + 1, #last do last[i] = nil end end ClearResultTooltips() @@ -5606,9 +6477,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local maxVisibleHeight = EasyFind.db.uiResultsHeight or 280 local willScroll = visibleN * rowH > maxVisibleHeight local scrollInset = 0 - if willScroll and resultsFrame.scrollBar then - scrollInset = resultsFrame.scrollBar:GetWidth() - end wipe(SCRATCH.isLastChild) local isLastChild = SCRATCH.isLastChild @@ -5686,6 +6554,25 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end resultRow.data = data + -- Reset every icon.* tooltip-identifier the OnEnter handler + -- looks at. Without this, rows recycled from a previous + -- render leak their old category's tooltip — e.g. a row + -- that was "Felfire Hawk" (mount) becoming "Bronze Bullion" + -- (currency) keeps icon.mountID set, so OnEnter shows the + -- mount tooltip instead. Only the mount/toy/.../bag branch + -- below resets these per-field; categories like currency, + -- reputation, achievement, settings never enter that branch + -- and used to inherit stale state. + if resultRow.icon then + resultRow.icon.mountID = nil + resultRow.icon.toyItemID = nil + resultRow.icon.petID = nil + resultRow.icon.spellID = nil + resultRow.icon.outfitID = nil + resultRow.icon.heirloomItemID = nil + resultRow.icon.bagItemID = nil + resultRow.icon.lootItemID = nil + end -- Secure action attributes. Cache the (type, value) we last -- applied to this row so we only re-issue SetAttribute when -- the row's data actually changed. SetAttribute on a secure @@ -5812,9 +6699,9 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.text:ClearAllPoints() resultRow.text:SetPoint("LEFT", resultRow, "LEFT", 2, 0) resultRow.text:SetPoint("RIGHT", resultRow.pinToggle, "LEFT", -4, 0) - resultRow.text:SetText(entry.name) SetScaledFont(resultRow.text, theme.pathFont) resultRow.text:SetTextColor(0.7, 0.7, 0.7, 1.0) + SetClippedText(resultRow.text, entry.name) elseif entry.isSectionHeader then -- Lightweight inline section label: centered text -- between two faint horizontal rules. Used for @@ -6002,13 +6889,13 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.text:ClearAllPoints() resultRow.text:SetPoint("BOTTOMLEFT", leftAnchor, "RIGHT", 6, stackHalfGap) resultRow.text:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) - resultRow.text:SetText(entry.name) SetScaledFont(resultRow.text, theme.pathFont) if isUnearnedCurrency then resultRow.text:SetTextColor(0.5, 0.5, 0.5, 1.0) else resultRow.text:SetTextColor(1.0, 1.0, 1.0, 1.0) end + SetClippedText(resultRow.text, entry.name) resultRow.pathSubtext:ClearAllPoints() resultRow.pathSubtext:SetPoint("TOPLEFT", resultRow.text, "BOTTOMLEFT", 0, -stackGap) @@ -6024,7 +6911,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.text:ClearAllPoints() resultRow.text:SetPoint("LEFT", resultRow.icon, "RIGHT", 4, 0) resultRow.text:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) - resultRow.text:SetText(entry.name) if resultRow.pathSubtext then resultRow.pathSubtext:Hide() @@ -6049,6 +6935,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) SetScaledFont(resultRow.text, theme.leafFont) resultRow.text:SetTextColor(unpack(theme.leafColor)) end + SetClippedText(resultRow.text, entry.name) end else if resultRow.pathSubtext then resultRow.pathSubtext:Hide() end @@ -6217,6 +7104,16 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) else resultRow.icon:SetVertexColor(1, 1, 1, 1) end + -- Talents: desaturate the per-talent icon if not in + -- the player's current allocation. Allocated talents + -- (chosen choice option, or non-zero rank on regular + -- nodes) render full color. + elseif data.category == "Talent" then + if data.talentIsAllocated then + resultRow.icon:SetVertexColor(1, 1, 1, 1) + else + resultRow.icon:SetVertexColor(0.4, 0.4, 0.4, 1) + end else resultRow.icon:SetVertexColor(1, 1, 1, 1) end @@ -6613,8 +7510,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local kb2 = resultRow.settingKeybind2 local function refresh() local k1, k2 = GetBindingKey(action) - kb1:SetText(k1 or "Not Bound") - kb2:SetText(k2 or "Not Bound") + kb1:SetText(AbbrevBinding(k1)) + kb2:SetText(AbbrevBinding(k2)) end kb1._bindingAction = action kb1._refresh = refresh @@ -6733,7 +7630,11 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if optList and #optList > 0 then -- Inline dropdown widget: paddle arrows + center -- button styled like the in-game Settings dropdown. - resultRow.settingDropdownLabel:SetText(val or "") + if resultRow.SetSettingDropdownText then + resultRow:SetSettingDropdownText(val or "") + else + resultRow.settingDropdownLabel:SetText(val or "") + end resultRow.settingDropdownGroup:Show() resultRow.amountText:Hide() resultRow.text:SetPoint("RIGHT", resultRow.settingDropdownGroup, "LEFT", -4, 0) @@ -6768,9 +7669,15 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if entry.isFlat and resultRow.icon and resultRow.icon:IsShown() then local d = entry.data local rightSideIcon = d and (d.mountID or d.toyItemID or d.petID - or d.outfitID or d.transmogSetID or d.category == "Currency" + or d.outfitID or d.heirloomItemID or d.transmogSetID + or d.category == "Currency" or (d.itemID and d.category == "Loot") or (d.spellID and d.category == "Talent") + or (d.spellID and d.category == "Ability") + or (d.encounterID and d.category == "Boss") + or (d.macroIndex and d.category == "Macro") + or (d.bagID and d.category == "Bag") + or (d.achievementID and d.category == "Achievement") or d.mapSearchResult) if rightSideIcon then local rightSize = entryRowH - 20 @@ -6794,9 +7701,15 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local catShown = resultRow.flatCatIcon and resultRow.flatCatIcon:IsShown() local d = data local mainIconOnRight = d and (d.mountID or d.toyItemID or d.petID - or d.outfitID or d.transmogSetID or d.category == "Currency" + or d.outfitID or d.heirloomItemID or d.transmogSetID + or d.category == "Currency" or (d.itemID and d.category == "Loot") or (d.spellID and d.category == "Talent") + or (d.spellID and d.category == "Ability") + or (d.encounterID and d.category == "Boss") + or (d.macroIndex and d.category == "Macro") + or (d.bagID and d.category == "Bag") + or (d.achievementID and d.category == "Achievement") or d.mapSearchResult) local leftAnchor @@ -6901,8 +7814,11 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- Off-spec abilities: desaturate the icon and dim the text -- to match the spellbook's greyed-out treatment for spells - -- that belong to a non-active spec line (offSpecID > 0). - if IsSpellbookOnlyAbility(data) then + -- that belong to a non-active spec line. Active passives + -- (current-spec, not castable but the player has them) stay + -- full-color -- they're still "yours". Click for both still + -- routes to the spellbook page since neither can be cast. + if data.isOffSpec then if resultRow.icon then resultRow.icon:SetVertexColor(0.4, 0.4, 0.4, 1.0) end @@ -6973,17 +7889,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local hasScroll = totalContentHeight > maxVisibleHeight local visibleHeight = hasScroll and maxVisibleHeight or totalContentHeight - -- If scrollbar appeared but we didn't reserve space for it (e.g. stacked rep rows - -- pushed content past maxVisibleHeight), retroactively narrow all visible rows - -- so they don't bleed into the scrollbar. - if hasScroll and scrollInset == 0 and resultsFrame.scrollBar then - local scrollBarW = resultsFrame.scrollBar:GetWidth() - scrollInset = scrollBarW - for i = 1, count do - resultButtons[i]:SetWidth(resultButtons[i]:GetWidth() - scrollBarW) - end - end - -- Size the results frame and scroll child resultsFrame:SetHeight(padT + padB + visibleHeight) resultsFrame.scrollChild:SetWidth(resultsFrame:GetWidth() - scrollInset) @@ -7002,10 +7907,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if resultsFrame.scrollBar then resultsFrame.scrollBar:SetShown(hasScroll) if hasScroll then - local scrollCenterX = resultsFrame:GetWidth() * 0.96 - resultsFrame.scrollBar:ClearAllPoints() - resultsFrame.scrollBar:SetPoint("CENTER", resultsFrame, "TOPLEFT", scrollCenterX, -resultsFrame:GetHeight() / 2) - resultsFrame.scrollBar:UpdateBarHeight() resultsFrame.scrollBar:UpdateThumb(totalContentHeight, visibleHeight) end end @@ -7501,7 +8402,7 @@ function UI:UpdateSelectionHighlight(skipRefocus) local rd = selRow and selRow.data local secureRow = rd and (rd.outfitID or rd.toyItemID or (rd.spellID and not IsSpellbookOnlyAbility(rd)) - or rd.mountID or rd.macroIndex + or rd.mountID or rd.macroIndex or rd.slashCommand or (rd.itemID and rd.category == "Bag")) if secureRow then local btnName = selRow:GetName() @@ -8234,12 +9135,12 @@ function UI:OpenToyInToyBox(data) step(1) end --- Open PlayerSpellsFrame to the Talents tab (2) and highlight the --- talent node matching data.talentNodeID. Mirrors OpenAbilityInSpellbook's --- shape: prefer PlayerSpellsUtil.TogglePlayerSpellsFrame(tabID) for an --- atomic open-to-tab; fall back to clicking the micro button + tab. --- The talent step's own Highlight branch (in Highlight.lua) finds the --- node button by GetNodeID() and highlights it once the tree is up. +-- Open PlayerSpellsFrame to the Talents tab and drive Blizzard's own +-- search box at PlayerSpellsFrame.TalentsFrame.SearchBox with the +-- talent name. The game's native search highlights matching nodes +-- with the spyglass icon -- no need for our own highlight pass. +-- Cleanest path: matches the visual the player already recognizes +-- and works for hero / sub-tree talents without us walking parents. function UI:OpenTalentInTalentsTab(data) local highlight = ns.Highlight local TALENTS_TAB = 2 @@ -8268,12 +9169,78 @@ function UI:OpenTalentInTalentsTab(data) end return end - -- Frame open and on Talents tab: hand off the node-highlight step - -- to the Highlight system. data.steps[3] is the talentNodeID step. - if highlight and highlight.StartGuideAtStep then - data.noCourseCorrect = true - highlight:StartGuideAtStep(data, 3) + -- Frame open and on Talents tab: light up the matching talent + -- button's SearchIcon directly. Each talent button is parented + -- to TalentsFrame.ButtonsParent (or a hero/sub-tree container) + -- and frame-named after the talent itself, so the cleanest path + -- is: walk children, match by GetName(), Show() the SearchIcon. + local talentsFrame = frame.TalentsFrame + local targetLower = (data.name or ""):lower() + + local function nameOf(btn) + if not btn or not btn.GetName then return nil end + local n = btn:GetName() + return n and n:lower() or nil end + + -- Recursive search: choice nodes nest the actual option button + -- one (or more) levels below ButtonsParent's direct child, so a + -- fixed 2-level walk misses them. Cap depth so we don't spin on + -- weird parent loops. + local function searchTree(frame, depth) + if not frame or depth > 5 then return nil end + if frame.SearchIcon and nameOf(frame) == targetLower then + return frame + end + if frame.GetChildren then + local kids = { frame:GetChildren() } + for i = 1, #kids do + local found = searchTree(kids[i], depth + 1) + if found then return found end + end + end + return nil + end + + local function findMatchingButton() + if not talentsFrame then return nil end + local containers = { + talentsFrame.ButtonsParent, + talentsFrame.HeroTalentsContainer, + talentsFrame.SubTreeContainer, + } + for _, parent in ipairs(containers) do + local found = searchTree(parent, 0) + if found then return found end + end + return nil + end + + local tries = 0 + local function showSpyglass() + tries = tries + 1 + local btn = findMatchingButton() + if btn and btn.SearchIcon and btn.SearchIcon.Show then + -- Bump strata above the talent button's own ARTWORK / OVERLAY + -- siblings so the spyglass isn't occluded by the talent + -- icon's connectors and glow textures. + if btn.SearchIcon.SetFrameStrata then + btn.SearchIcon:SetFrameStrata("HIGH") + end + if btn.SearchIcon.SetFrameLevel and btn.GetFrameLevel then + btn.SearchIcon:SetFrameLevel(btn:GetFrameLevel() + 10) + end + btn.SearchIcon:Show() + if ns.Highlight and ns.Highlight.RegisterTalentSearchIcon then + ns.Highlight:RegisterTalentSearchIcon(btn, targetLower, nameOf) + end + return + end + if tries < 20 then + C_Timer.After(0.05, showSpyglass) + end + end + C_Timer.After(0, showSpyglass) end ensureFrameOnTab(1) @@ -8366,16 +9333,24 @@ function UI:OpenAbilityInSpellbook(data) if targetElement then local elementBtn = FindVisibleButtonForElement(paged, targetElement) if elementBtn and highlight then - highlight:HighlightFrame(elementBtn, nil, stillRepresentsTarget) - HideHighlightOnHover(elementBtn) + if highlight.HighlightSpellbookSpell then + highlight:HighlightSpellbookSpell(elementBtn, stillRepresentsTarget) + else + highlight:HighlightFrame(elementBtn, nil, stillRepresentsTarget) + HideHighlightOnHover(elementBtn) + end return end end local btn = FindSpellbookButton(paged, data, false) if btn and highlight then - highlight:HighlightFrame(btn, nil, stillRepresentsTarget) - HideHighlightOnHover(btn) + if highlight.HighlightSpellbookSpell then + highlight:HighlightSpellbookSpell(btn, stillRepresentsTarget) + else + highlight:HighlightFrame(btn, nil, stillRepresentsTarget) + HideHighlightOnHover(btn) + end return end @@ -8422,6 +9397,7 @@ function UI:SelectResult(data, forceGuide) if data.slashCommand 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 @@ -8617,10 +9593,23 @@ function UI:SelectResult(data, forceGuide) -- open the bag(s) containing them and highlight the slot. if data.itemID and data.category == "Bag" then if useFast then + -- Skip bag-open for anything the secure click will already act + -- on: explicit Use spells, equippable gear, AND broad item + -- types that "use" via right-click without a Use:tooltip line + -- (Consumable / Container / Quest). Without the type fallback, + -- right-click-openable containers like lockboxes still hit + -- the bag-open path, so the bag visibly pops AND the + -- container opens -- the user only wants the latter. local hasUseEffect = (C_Item and C_Item.GetItemSpell and C_Item.GetItemSpell(data.itemID)) or (GetItemSpell and GetItemSpell(data.itemID)) local isEquippable = IsEquippableItem and IsEquippableItem(data.itemID) - if hasUseEffect or isEquippable then + local itemType + if not hasUseEffect and not isEquippable and GetItemInfo then + itemType = select(6, GetItemInfo(data.itemID)) + end + if hasUseEffect or isEquippable + or itemType == "Consumable" or itemType == "Container" + or itemType == "Quest" then return end local openBag = (C_Container and C_Container.OpenBag) or OpenBag @@ -8967,6 +9956,10 @@ function UI:DirectOpen(data) self:ClickAchievementCategory(categoryToClick) end + if step.achievementID then + self:OpenAchievementByID(step.achievementID) + end + -- EJ tier + dungeon/raid tab (boss navigation when EJ is already -- open). The EJMicroButton fast path handles the cold-open case; -- this branch handles re-opens on a different tier. @@ -9334,6 +10327,345 @@ function UI:ClickAchievementCategory(categoryName) return false end +-- Achievement watch/tracking. Modern WoW (Midnight) routes achievement +-- tracking through C_ContentTracking with Enum.ContentTrackingType +-- .Achievement. Older clients exposed top-level +-- IsTrackedAchievement / AddTrackedAchievement / +-- RemoveTrackedAchievement. We try the modern API first then fall back. +local function GetAchievementContentType() + if Enum and Enum.ContentTrackingType + and Enum.ContentTrackingType.Achievement ~= nil then + return Enum.ContentTrackingType.Achievement + end + return nil +end + +function UI:IsAchievementTracked(achievementID) + if not achievementID then return false end + local ct = GetAchievementContentType() + if ct ~= nil and C_ContentTracking and C_ContentTracking.IsTracking then + local ok, tracked = pcall(C_ContentTracking.IsTracking, ct, achievementID) + if ok then return tracked and true or false end + end + local fn = _G["IsTrackedAchievement"] + if fn then + local ok, tracked = pcall(fn, achievementID) + if ok then return tracked and true or false end + end + return false +end + +function UI:ToggleAchievementTracked(achievementID) + if not achievementID then return end + local tracked = self:IsAchievementTracked(achievementID) + local ct = GetAchievementContentType() + if ct ~= nil and C_ContentTracking and C_ContentTracking.StartTracking then + if tracked then + -- StopTracking REQUIRES a third arg (Enum.ContentTrackingStopType); + -- omitting it causes the call to silently no-op. .User is the + -- "user clicked to stop tracking" reason. + local stopType = (Enum and Enum.ContentTrackingStopType + and Enum.ContentTrackingStopType.User) or 0 + pcall(C_ContentTracking.StopTracking, ct, achievementID, stopType) + else + pcall(C_ContentTracking.StartTracking, ct, achievementID) + end + return + end + if tracked then + local stop = _G["RemoveTrackedAchievement"] + if stop then pcall(stop, achievementID) end + else + local start = _G["AddTrackedAchievement"] + if start then pcall(start, achievementID) end + end +end + +-- Pet (battle pet) right-click actions. petID here is a Blizzard pet +-- GUID string returned by GetPetInfoByIndex / similar, NOT a numeric +-- speciesID. All wrappers no-op gracefully when the pet APIs aren't +-- available. +function UI:SummonPet(petID) + if petID and C_PetJournal and C_PetJournal.SummonPetByGUID then + pcall(C_PetJournal.SummonPetByGUID, petID) + end +end + +function UI:IsPetFavorite(petID) + if not petID or not C_PetJournal then return false end + if C_PetJournal.GetPetInfoByPetID then + local ok, _, _, _, _, _, _, _, _, _, _, _, _, _, isFav = pcall(C_PetJournal.GetPetInfoByPetID, petID) + if ok and isFav then return true end + end + return false +end + +function UI:TogglePetFavorite(petID) + if not petID or not C_PetJournal or not C_PetJournal.SetFavorite then return end + local fav = self:IsPetFavorite(petID) + pcall(C_PetJournal.SetFavorite, petID, (not fav) and 1 or 0) +end + +-- Returns true when the pet is cage-eligible (tradeable). Blizzard's +-- context menu shows "Put In Cage" for these and "Release" for the +-- rest; we mirror that distinction so the user gets the same affordance. +function UI:IsPetCageable(petID) + if not petID or not C_PetJournal then return false end + if C_PetJournal.PetIsTradable then + local ok, val = pcall(C_PetJournal.PetIsTradable, petID) + if ok then return val and true or false end + end + return false +end + +function UI:CagePet(petID) + if not petID or not C_PetJournal or not C_PetJournal.CagePetByID then return end + pcall(C_PetJournal.CagePetByID, petID) +end + +function UI:ReleasePet(petID) + if not petID or not C_PetJournal or not C_PetJournal.ReleasePetByID then return end + StaticPopup_Show("EASYFIND_PET_RELEASE_CONFIRM", nil, nil, petID) +end + +StaticPopupDialogs["EASYFIND_PET_RELEASE_CONFIRM"] = { + text = "Are you sure you want to permanently release this pet? This cannot be undone.", + button1 = ACCEPT or "OK", + button2 = CANCEL or "Cancel", + timeout = 0, + whileDead = true, + hideOnEscape = true, + OnAccept = function(_, petID) + if petID and C_PetJournal and C_PetJournal.ReleasePetByID then + pcall(C_PetJournal.ReleasePetByID, petID) + end + end, +} + +function UI:RenamePet(petID) + if not petID then return end + StaticPopup_Show("EASYFIND_PET_RENAME", nil, nil, petID) +end + +StaticPopupDialogs["EASYFIND_PET_RENAME"] = { + text = "New name for this pet:", + button1 = ACCEPT or "OK", + button2 = CANCEL or "Cancel", + hasEditBox = true, + maxLetters = 16, + timeout = 0, + whileDead = true, + hideOnEscape = true, + enterClicksFirstButton = true, + OnShow = function(self, petID) + local eb = self.editBox or self.EditBox + if not eb then return end + local existing = "" + if petID and C_PetJournal and C_PetJournal.GetPetInfoByPetID then + local ok, _, customName, _, _, _, _, _, name = pcall(C_PetJournal.GetPetInfoByPetID, petID) + if ok then existing = customName or name or "" end + end + eb:SetText(existing) + eb:HighlightText() + eb:SetFocus() + end, + OnAccept = function(self, petID) + local eb = self.editBox or self.EditBox + local txt = eb and eb:GetText() or "" + if petID and C_PetJournal and C_PetJournal.SetCustomName then + pcall(C_PetJournal.SetCustomName, petID, txt) + end + end, +} + +-- Transmog (appearance) set favorite toggle. The favorite flag lives on +-- the BASE set (not the per-class / per-difficulty variants), so we +-- always resolve to the base ID before reading or writing. Without this +-- step, SetIsFavorite is a silent no-op when called with a variant ID. +local function ResolveTransmogBaseSetID(setID) + if not setID or not C_TransmogSets then return setID end + if C_TransmogSets.GetBaseSetID then + local ok, baseID = pcall(C_TransmogSets.GetBaseSetID, setID) + if ok and baseID and baseID ~= 0 then return baseID end + end + return setID +end + +function UI:IsTransmogSetFavorite(setID) + if not setID or not C_TransmogSets then return false end + local baseID = ResolveTransmogBaseSetID(setID) + if C_TransmogSets.GetIsFavorite then + local ok, fav = pcall(C_TransmogSets.GetIsFavorite, baseID) + if ok and fav then return true end + end + if C_TransmogSets.GetSetInfo then + local ok, info = pcall(C_TransmogSets.GetSetInfo, baseID) + if ok and type(info) == "table" and info.favoriteSetID then return true end + if ok and type(info) == "table" and info.favorite then return true end + end + return false +end + +function UI:ToggleTransmogSetFavorite(setID) + if not setID or not C_TransmogSets then return end + local baseID = ResolveTransmogBaseSetID(setID) + local fav = self:IsTransmogSetFavorite(baseID) + if C_TransmogSets.SetIsFavorite then + pcall(C_TransmogSets.SetIsFavorite, baseID, not fav) + elseif C_TransmogSets.MarkSetFavorite then + pcall(C_TransmogSets.MarkSetFavorite, baseID, not fav) + end +end + +-- Backpack-tracker toggle for currencies. Mirrors what the in-game +-- "Show on backpack" checkbox in the Currency tab does. +function UI:IsCurrencyOnBackpack(currencyID) + if not currencyID or currencyID == 0 or not C_CurrencyInfo then return false end + -- The enumeration list is authoritative. The CurrencyInfo struct's + -- `isShowInBackpack` flag in modern builds indicates *capability* + -- (the currency is allowed to be tracked), not current state — so + -- using it always read as on, the toggle always tried to add, and + -- removal silently no-op'd. + local getInfo = C_CurrencyInfo.GetBackpackCurrencyInfo + if getInfo then + local cap = (_G["MAX_WATCHED_TOKENS"]) or 3 + for i = 1, cap do + local bok, bi = pcall(getInfo, i) + if not bok or type(bi) ~= "table" then break end + local id = bi.currencyTypesID or bi.currencyID + -- Require a non-zero ID match. Some clients return a + -- placeholder table for unused tracker slots with id=0 / + -- name="" / nil quantity instead of returning nil; without + -- this guard, comparing against currencyID still works but + -- we'd false-positive if the caller ever passed 0 or nil + -- through (defended above) — keep the explicit check so a + -- future mistake at a call site can't bite. + if id and id ~= 0 and id == currencyID then return true end + end + return false + end + -- Fallback for builds that don't expose the enumeration: trust the + -- inline flag from GetCurrencyInfo. + local ok, info = pcall(C_CurrencyInfo.GetCurrencyInfo, currencyID) + if not ok or type(info) ~= "table" then return false end + return info.isShowInBackpack and true or false +end + +function UI:ToggleCurrencyBackpack(currencyID) + if not currencyID or not C_CurrencyInfo then return end + local on = self:IsCurrencyOnBackpack(currencyID) + local target = not on + + -- Backpack tracker caps at 3. When the user tries to ADD a fourth, + -- raise the same red UIErrorsFrame message Blizzard's default UI + -- shows instead of silently dropping the call. + if target and C_CurrencyInfo.GetBackpackCurrencyInfo then + local cap = (_G["MAX_WATCHED_TOKENS"]) or 3 + local count = 0 + for i = 1, cap + 1 do + local bok, bi = pcall(C_CurrencyInfo.GetBackpackCurrencyInfo, i) + if not bok or type(bi) ~= "table" then break end + count = count + 1 + end + if count >= cap then + local msg = (_G["TOKEN_BACKPACK_FULL_MESSAGE"]) + or string.format("You may only watch %d currencies at a time", cap) + local errFrame = _G["UIErrorsFrame"] + if errFrame and errFrame.AddMessage then + errFrame:AddMessage(msg, 1.0, 0.1, 0.1, 1.0) + end + return + end + end + + -- SetCurrencyBackpackByID takes a currency ID directly. The older + -- SetCurrencyBackpack takes a *list index* (which is why it + -- silently no-op'd / hit the wrong currency when called with an + -- ID). Prefer the by-ID variant when available. + if C_CurrencyInfo.SetCurrencyBackpackByID then + pcall(C_CurrencyInfo.SetCurrencyBackpackByID, currencyID, target) + elseif C_CurrencyInfo.SetCurrencyBackpack then + pcall(C_CurrencyInfo.SetCurrencyBackpack, currencyID, target) + end + + -- Force the visible backpack token strip to refresh now. The + -- SetCurrency* call updates state but doesn't always notify the + -- ContainerFrame's token row when the bag is already open, so the + -- user sees stale icons until they close and reopen. Call every + -- update entry-point we know about; whichever exists in this + -- client wins, the rest no-op. + local candidates = { + _G["BackpackTokenFrame"], + _G["BackpackTokenFrame_Update"], + } + for _, c in ipairs(candidates) do + if type(c) == "function" then + pcall(c) + elseif type(c) == "table" then + if c.Update then pcall(c.Update, c) end + if c.UpdateTokens then pcall(c.UpdateTokens, c) end + end + end + -- Container frames host the token row in modern bag UI. Iterate + -- the standard ContainerFrame1..N looking for an Update method. + for i = 1, 13 do + local cf = _G["ContainerFrame" .. i] + if cf and cf.Update then pcall(cf.Update, cf) end + local tokenFrame = cf and cf.tokenFrame + if tokenFrame and tokenFrame.Update then + pcall(tokenFrame.Update, tokenFrame) + end + end +end + +function UI:IsCurrencyTransferable(currencyID) + return ns.Database and ns.Database.IsCurrencyAccountTransferable + and ns.Database:IsCurrencyAccountTransferable(currencyID) or false +end + +-- Open the Currency tab to the given currency, then click the +-- TokenFramePopup's transfer toggle. The first half mirrors what a +-- left-click on the row would do; we then chain a deferred click on +-- the transfer button once the popup is laid out. +function UI:RouteCurrencyTransfer(pinData) + if not pinData then return end + self:SelectResult(pinData) + if not Utils or not Utils.SafeAfter then return end + local function clickTransfer() + local btn = _G["TokenFramePopup"] and _G["TokenFramePopup"].CurrencyTransferToggleButton + if btn and btn:IsShown() and btn.Click then + btn:Click() + end + end + Utils.SafeAfter(0.1, clickTransfer) + Utils.SafeAfter(0.25, clickTransfer) +end + +-- Open the AchievementFrame to a specific achievement. Tries Blizzard's +-- modern OpenAchievementFrameToAchievement first; falls back to the +-- legacy AchievementFrame_SelectAchievement; finally just shows the +-- frame so the user can find it manually. +function UI:OpenAchievementByID(achievementID) + if not achievementID then return end + if _G["AchievementFrame_LoadUI"] then + pcall(_G["AchievementFrame_LoadUI"]) + end + local frame = _G["AchievementFrame"] + if frame and not frame:IsShown() and ShowUIPanel then + ShowUIPanel(frame) + end + local opener = _G["OpenAchievementFrameToAchievement"] + if opener then + pcall(opener, achievementID) + return + end + local selector = _G["AchievementFrame_SelectAchievement"] + if selector then + pcall(selector, achievementID) + end +end + + -- Helper function to click a side tab (PvE Group Finder tabs) -- Helper to extract text from various button types function UI:GetButtonText(frame) @@ -9661,6 +10993,16 @@ function UI:RefreshResults() toggleFocused = savedToggle self:UpdateSelectionHighlight() end + -- Re-apply the hover action hint. Re-render rewrote pathSubtext + -- back to GetFlatSubtext, so the row the cursor is still over + -- would otherwise revert to the unhovered subtext after a click. + for i = 1, #resultButtons do + local row = resultButtons[i] + if row and row:IsShown() and row:IsMouseOver() then + ApplyActionHint(row) + break + end + end end end @@ -10245,7 +11587,7 @@ function UI:ShowFirstTimeSetup() end) end) btn:HookScript("OnEnter", function(self) - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + AnchorTooltipAtCursor(GameTooltip, self) GameTooltip:AddLine("Recommended: " .. recommended, 1, 1, 1, true) GameTooltip:AddLine("Click to bind. Right-click to clear.", 0.7, 0.7, 0.7, true) GameTooltip:Show() From 06728d7e28f0ce12320df79f59811bfc4aee3135 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 8 May 2026 09:01:20 -0400 Subject: [PATCH 062/103] Filters: drive Blizzard's TokenFrame, ReputationFrame, SpellBook Extend the filter flyouts to drive Blizzard's own filter dropdowns, not just our search results. - Currencies flyout now also flips TokenFrame's All / Warband filter via ApplyTokenFrameFilter (defensive multi-API: SetFilter, SetFilterMode, then field assignment). - Reputations row gains a flyout matching the in-game dropdown: All / Warband / radio plus a Show Legacy Reputations checkbox. Drives ReputationFrame.SetFilter and SetShowLegacy with the same defensive fallbacks. - Abilities row gains a Hide Passives checkbox flyout. Drives PlayerSpellsFrame.SpellBookFrame.SetHidePassives and also drops isPassive ability rows from our search results. - HookBlizzardFilters runs on ADDON_LOADED so each frame gets an OnShow hook the first time it loads, replaying our DB state when the panel appears. DB defaults added: reputationFilterMode, showLegacyReputations, abilityHidePassives. Renderer for flyoutRadio extended with an optional checkboxes array and onChange callback. radio.options may now be empty (checkbox-only flyouts), and a thin separator divides radio rows from checkboxes when both are present. --- Core.lua | 9 +++ UI.lua | 222 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 217 insertions(+), 14 deletions(-) diff --git a/Core.lua b/Core.lua index 65efd48..718f72b 100644 --- a/Core.lua +++ b/Core.lua @@ -128,6 +128,15 @@ local DB_DEFAULTS = { -- dropdown). "all" = show every currency the character has; -- "warband" = only surface warband-transferable currencies. currencyFilterMode = "all", + -- Reputation filter mode (mirrors ReputationFrame's dropdown). + -- "all" / "warband" / "char". Persisted independently from + -- showLegacyReputations because Blizzard treats them as separate + -- filter axes. + reputationFilterMode = "all", + showLegacyReputations = false, + -- Spellbook ability filter: when true, hides passive abilities in + -- both EasyFind's results and Blizzard's spellbook page. + abilityHidePassives = false, appearanceSetClass = nil, -- nil = player class, "all" = all, {classID=N} = specific appearanceSetCollected = true, -- Show collected sets appearanceSetNotCollected = true, -- Show uncollected sets diff --git a/UI.lua b/UI.lua index 853cb64..0884969 100644 --- a/UI.lua +++ b/UI.lua @@ -680,6 +680,14 @@ function UI:Initialize() self:CreateResultsFrame() self:RegisterCombatEvents() + -- Hook Blizzard's TokenFrame / ReputationFrame / SpellBookFrame so + -- our filter flyout choices apply when the player opens those + -- panels. Frames are LoadOnDemand: hook each as it appears. + local blizzHook = CreateFrame("Frame") + blizzHook:RegisterEvent("ADDON_LOADED") + blizzHook:SetScript("OnEvent", function() UI:HookBlizzardFilters() end) + UI:HookBlizzardFilters() + if EasyFind.db.autoHide then searchFrame:Hide() elseif EasyFind.db.visible ~= false then @@ -1974,7 +1982,13 @@ local UI_FILTER_OPTIONS = { -- Abilities: boss-skull icon from the Encounter Journal boss tab -- spritesheet (texture 522972). { key = "abilities", label = "Abilities", iconTex = 522972, - iconCoords = { 0.904, 0.996, 0.707, 0.748 } }, + iconCoords = { 0.904, 0.996, 0.707, 0.748 }, + flyoutRadio = { + checkboxes = { + { dbKey = "abilityHidePassives", label = "Hide Passives", + onChange = function(v) if UI.ApplySpellBookHidePassives then UI:ApplySpellBookHidePassives(v) end end }, + }, + } }, { key = "achievements", label = "Achievements", iconAtlas = "UI-HUD-MicroMenu-Achievements-Up" }, { key = "bags", label = "Bags", iconAtlas = "bag-main" }, -- Bosses: EJ overview tab icon from texture 522972. @@ -1998,6 +2012,7 @@ local UI_FILTER_OPTIONS = { { value = "all", label = "This Character Only" }, { value = "warband", label = "All Warband Transferable" }, }, + onChange = function(v) if UI.ApplyTokenFrameFilter then UI:ApplyTokenFrameFilter(v) end end, } }, -- Gear: treasure-chest icon from the Encounter Journal loot tab -- spritesheet (texture 522972) for visual consistency with the @@ -2023,7 +2038,20 @@ local UI_FILTER_OPTIONS = { { key = "addonOptions", label = "AddOn Options", iconAtlas = "QuestLog-icon-setting", iconColor = { 1.0, 0.78, 0.35 } }, } }, { key = "reputations", label = "Reputations", iconTex = 1121272, - iconCoords = { 0.3783, 0.4072, 0.9066, 0.9350 } }, + iconCoords = { 0.3783, 0.4072, 0.9066, 0.9350 }, + flyoutRadio = { + dbKey = "reputationFilterMode", + options = { + { value = "all", label = "All" }, + { value = "warband", label = "Warband" }, + { value = "char", label = (UnitName and UnitName("player")) or "This Character" }, + }, + onChange = function(v) if UI.ApplyReputationFilter then UI:ApplyReputationFilter(v) end end, + checkboxes = { + { dbKey = "showLegacyReputations", label = "Show Legacy Reputations", + onChange = function(v) if UI.ApplyReputationShowLegacy then UI:ApplyReputationShowLegacy(v) end end }, + }, + } }, -- Talents: leaf icon from the talents atlas spritesheet (4556093), -- visually consistent with the in-game talent tree. { key = "talents", label = "Talents", iconAtlas = "UI-HUD-MicroMenu-SpellbookAbilities-Up" }, @@ -2897,15 +2925,19 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) dropdown:HookScript("OnHide", function() popup:Hide() end) end - -- Radio flyout (e.g. Currencies > "All Warband Transferable" / - -- "This Character Only"). Mirrors the checkbox flyout above - -- but writes a single dbKey to the chosen option's value, and - -- sub-rows render as radio bullets that uncheck siblings. + -- Radio + checkbox flyout. radio.options renders radio rows that + -- write radio.dbKey; radio.checkboxes renders independent toggles + -- below. Either section may be omitted (e.g. Abilities has only a + -- "Hide Passives" checkbox; Currencies has only the radio set). if opt.flyoutRadio and not opt.flyoutSubFilters then local radio = opt.flyoutRadio local SUB_POPUP_WIDTH = 200 local SUB_ROW_H = 22 local SUB_PAD = 6 + local options = radio.options or {} + local checkboxes = radio.checkboxes or {} + local hasSeparator = #options > 0 and #checkboxes > 0 + local SEPARATOR_H = hasSeparator and 8 or 0 local popup = CreateFrame("Frame", nil, UIParent, "BackdropTemplate") popup:SetFrameStrata("TOOLTIP") @@ -2930,7 +2962,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end) local radioRows = {} - for ri, optionDef in ipairs(radio.options) do + for ri, optionDef in ipairs(options) do local rRow = CreateFrame("Button", nil, popup) rRow:SetSize(SUB_POPUP_WIDTH - SUB_PAD * 2, SUB_ROW_H) rRow:SetPoint("TOPLEFT", popup, "TOPLEFT", @@ -2962,6 +2994,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) for _, otherRow in ipairs(radioRows) do otherRow.tick:SetShown(otherRow.value == self.value) end + if radio.onChange then radio.onChange(self.value) end if searchEditBox:GetText() ~= "" then UI:OnSearchTextChanged(searchEditBox:GetText()) end @@ -2970,12 +3003,71 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) radioRows[ri] = rRow end - popup:SetSize(SUB_POPUP_WIDTH, SUB_PAD * 2 + #radio.options * SUB_ROW_H) + local checkboxRows = {} + local cbStartY = SUB_PAD + #options * SUB_ROW_H + SEPARATOR_H + for ci, cbDef in ipairs(checkboxes) do + local cRow = CreateFrame("Button", nil, popup) + cRow:SetSize(SUB_POPUP_WIDTH - SUB_PAD * 2, SUB_ROW_H) + cRow:SetPoint("TOPLEFT", popup, "TOPLEFT", + SUB_PAD, -(cbStartY + (ci - 1) * SUB_ROW_H)) + + local box = cRow:CreateTexture(nil, "ARTWORK") + box:SetAtlas("common-dropdown-ticksquare") + box:SetSize(12, 12) + box:SetPoint("LEFT", 5, 0) + + local tick = cRow:CreateTexture(nil, "OVERLAY") + tick:SetAtlas("common-dropdown-icon-checkmark-yellow") + tick:SetSize(14, 14) + tick:SetPoint("LEFT", 4, 0) + tick:Hide() + + local lbl = cRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + lbl:SetPoint("LEFT", box, "RIGHT", 6, 0) + lbl:SetText(cbDef.label) + + local hl = cRow:CreateTexture(nil, "HIGHLIGHT") + hl:SetAllPoints() + hl:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + + cRow.tick = tick + cRow.dbKey = cbDef.dbKey + cRow.onChange = cbDef.onChange + cRow:SetScript("OnClick", function(self) + local cur = EasyFind.db[self.dbKey] + local next = not cur + EasyFind.db[self.dbKey] = next + self.tick:SetShown(next) + if self.onChange then self.onChange(next) end + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end) + checkboxRows[ci] = cRow + end + + popup:SetSize(SUB_POPUP_WIDTH, + SUB_PAD * 2 + #options * SUB_ROW_H + SEPARATOR_H + #checkboxes * SUB_ROW_H) + + if hasSeparator then + local sep = popup:CreateTexture(nil, "ARTWORK") + sep:SetColorTexture(1, 1, 1, 0.12) + sep:SetHeight(1) + sep:SetPoint("LEFT", popup, "LEFT", SUB_PAD, 0) + sep:SetPoint("RIGHT", popup, "RIGHT", -SUB_PAD, 0) + sep:SetPoint("TOP", popup, "TOP", 0, + -(SUB_PAD + #options * SUB_ROW_H + SEPARATOR_H * 0.5)) + end local function SyncRadio() - local cur = EasyFind.db[radio.dbKey] - for _, rRow in ipairs(radioRows) do - rRow.tick:SetShown(rRow.value == cur) + if radio.dbKey then + local cur = EasyFind.db[radio.dbKey] + for _, rRow in ipairs(radioRows) do + rRow.tick:SetShown(rRow.value == cur) + end + end + for _, cRow in ipairs(checkboxRows) do + cRow.tick:SetShown(EasyFind.db[cRow.dbKey] and true or false) end end row.SyncFlyoutSubChecks = SyncRadio @@ -6109,6 +6201,9 @@ function UI:OnSearchTextChanged(text, force) -- (ui / abilities / achievements / currencies / reputations / bags -- / options) is unchecked. Options is a parent toggle: when off, -- both gameOptions and addonOptions buckets are treated as off. + -- abilityHidePassives also drops isPassive ability rows here so + -- the filter applies regardless of which bucket is on. + local hidePassives = EasyFind.db.abilityHidePassives if filters and (filters.ui == false or filters.abilities == false or filters.bosses == false or filters.achievements == false @@ -6117,7 +6212,8 @@ function UI:OnSearchTextChanged(text, force) or filters.options == false or filters.gameOptions == false or filters.addonOptions == false or filters.titles == false or filters.gearSets == false - or filters.talents == false) then + or filters.talents == false + or hidePassives) then wipe(SCRATCH.filteredResults) local filtered = SCRATCH.filteredResults local fi = 0 @@ -6127,11 +6223,13 @@ function UI:OnSearchTextChanged(text, force) fi = fi + 1 filtered[fi] = r else - local bucket = GetUIBucket(r.data) + local d = r.data + local bucket = GetUIBucket(d) local bucketOff = bucket and filters[bucket] == false local parentOff = optionsOff and (bucket == "gameOptions" or bucket == "addonOptions") - if not bucket or (not bucketOff and not parentOff) then + local passiveOff = hidePassives and d and d.category == "Ability" and d.isPassive + if not passiveOff and (not bucket or (not bucketOff and not parentOff)) then fi = fi + 1 filtered[fi] = r end @@ -10551,6 +10649,102 @@ function UI:IsCurrencyOnBackpack(currencyID) return info.isShowInBackpack and true or false end +-- Try to drive Blizzard's TokenFrame filter (the dropdown that toggles +-- between "Show All" and "Show Warband Transferable"). The exact API has +-- moved around across Dragonflight / War Within / Midnight, so try +-- known field/method names in order and refresh whichever is present. +function UI:ApplyTokenFrameFilter(mode) + if not mode then return end + local warband = (mode == "warband") + if TokenFrame then + if TokenFrame.SetFilter then + pcall(TokenFrame.SetFilter, TokenFrame, mode) + elseif TokenFrame.SetFilterMode then + pcall(TokenFrame.SetFilterMode, TokenFrame, mode) + else + TokenFrame.filter = mode + TokenFrame.showWarband = warband + end + if TokenFrame.Update then + pcall(TokenFrame.Update, TokenFrame) + end + end + if BackpackTokenFrame and BackpackTokenFrame.Update then + pcall(BackpackTokenFrame.Update, BackpackTokenFrame) + end +end + +-- Reputation filter (All / Warband / This Character). Same defensive +-- multi-API pattern -- Blizzard's reputation revamp shifted the storage +-- location across builds. +function UI:ApplyReputationFilter(mode) + if not mode then return end + if not ReputationFrame then return end + if ReputationFrame.SetFilter then + pcall(ReputationFrame.SetFilter, ReputationFrame, mode) + elseif ReputationFrame.SetFilterMode then + pcall(ReputationFrame.SetFilterMode, ReputationFrame, mode) + else + ReputationFrame.filter = mode + end + if ReputationFrame.Update then + pcall(ReputationFrame.Update, ReputationFrame) + end +end + +function UI:ApplyReputationShowLegacy(show) + if not ReputationFrame then return end + if ReputationFrame.SetShowLegacy then + pcall(ReputationFrame.SetShowLegacy, ReputationFrame, show and true or false) + else + ReputationFrame.showLegacy = show and true or false + end + if ReputationFrame.Update then + pcall(ReputationFrame.Update, ReputationFrame) + end +end + +-- Spellbook "Hide Passives" checkbox. Lives on PlayerSpellsFrame's +-- spellbook page in modern WoW; SpellBookFrame is the older path. +function UI:ApplySpellBookHidePassives(hide) + hide = hide and true or false + local frame = (PlayerSpellsFrame and PlayerSpellsFrame.SpellBookFrame) or SpellBookFrame + if not frame then return end + if frame.SetHidePassives then + pcall(frame.SetHidePassives, frame, hide) + else + frame.hidePassives = hide + end + if frame.Update then + pcall(frame.Update, frame) + elseif frame.UpdateDisplayedSpells then + pcall(frame.UpdateDisplayedSpells, frame) + end +end + +-- Re-apply all driven filters when the relevant Blizzard frame opens. +-- The frames are LoadOnDemand, so we hook OnShow lazily once the frame +-- becomes available. +function UI:HookBlizzardFilters() + local function hookOnce(frame, applyFn, dbKey) + if not frame or frame._easyFindFilterHooked then return end + frame._easyFindFilterHooked = true + frame:HookScript("OnShow", function() + local v = EasyFind.db and EasyFind.db[dbKey] + if v ~= nil then applyFn(UI, v) end + end) + end + if TokenFrame then hookOnce(TokenFrame, UI.ApplyTokenFrameFilter, "currencyFilterMode") end + if ReputationFrame then + hookOnce(ReputationFrame, function(self) + self:ApplyReputationFilter(EasyFind.db.reputationFilterMode) + self:ApplyReputationShowLegacy(EasyFind.db.showLegacyReputations) + end, "reputationFilterMode") + end + local sbf = (PlayerSpellsFrame and PlayerSpellsFrame.SpellBookFrame) or SpellBookFrame + if sbf then hookOnce(sbf, UI.ApplySpellBookHidePassives, "abilityHidePassives") end +end + function UI:ToggleCurrencyBackpack(currencyID) if not currencyID or not C_CurrencyInfo then return end local on = self:IsCurrencyOnBackpack(currencyID) From 3fda43bc10afb91562494e2de073b8bc8415fa4c Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 8 May 2026 09:13:01 -0400 Subject: [PATCH 063/103] Filters: drive Blizzard via dropdown click + checkbox walk Best-effort method/field dispatch wasn't reliable across builds. Switch to walking the Blizzard frame tree: find the actual checkbox or dropdown option button by visible label and click it via its native OnClick so all of Blizzard's downstream bookkeeping fires. - Hide Passives: walk the spellbook frame for a CheckButton labelled "hide passives", cache the reference, and toggle via OnClick. - TokenFrame / ReputationFrame filters: open the filter dropdown and click the option whose text matches the picked value (All / Warband / character name / Show Legacy Reputations). - Method/field set is kept as last-resort fallback. Defensive caching of found frames means repeated toggles don't re-walk the tree unless the cached reference goes stale. --- UI.lua | 247 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 201 insertions(+), 46 deletions(-) diff --git a/UI.lua b/UI.lua index 0884969..56fcb05 100644 --- a/UI.lua +++ b/UI.lua @@ -10649,77 +10649,232 @@ function UI:IsCurrencyOnBackpack(currencyID) return info.isShowInBackpack and true or false end --- Try to drive Blizzard's TokenFrame filter (the dropdown that toggles --- between "Show All" and "Show Warband Transferable"). The exact API has --- moved around across Dragonflight / War Within / Midnight, so try --- known field/method names in order and refresh whichever is present. -function UI:ApplyTokenFrameFilter(mode) - if not mode then return end - local warband = (mode == "warband") - if TokenFrame then - if TokenFrame.SetFilter then - pcall(TokenFrame.SetFilter, TokenFrame, mode) - elseif TokenFrame.SetFilterMode then - pcall(TokenFrame.SetFilterMode, TokenFrame, mode) +-- Walk a frame's descendants depth-first looking for a CheckButton whose +-- visible label contains the given (lowercase) substring. Used to find +-- Blizzard's Hide Passives checkbox without hard-coding the dynamic hex +-- frame path that changes each session. +local function FindCheckButtonByText(frame, needle) + if not frame or not needle then return nil end + local function getLabelText(btn) + if btn.text and btn.text.GetText then return btn.text:GetText() end + if btn.Text and btn.Text.GetText then return btn.Text:GetText() end + if btn.Label and btn.Label.GetText then return btn.Label:GetText() end + if btn.GetFontString then + local fs = btn:GetFontString() + if fs and fs.GetText then return fs:GetText() end + end + if btn.GetRegions then + local regions = { btn:GetRegions() } + for i = 1, #regions do + local r = regions[i] + if r and r.GetObjectType and r:GetObjectType() == "FontString" and r.GetText then + local t = r:GetText() + if t then return t end + end + end + end + return nil + end + local function walk(f, depth) + if not f or depth > 8 then return nil end + local ot = f.GetObjectType and f:GetObjectType() or nil + if ot == "CheckButton" then + local t = getLabelText(f) + if t and slower(t):find(needle, 1, true) then return f end + end + if f.GetChildren then + local kids = { f:GetChildren() } + for i = 1, #kids do + local found = walk(kids[i], depth + 1) + if found then return found end + end + end + return nil + end + return walk(frame, 0) +end + +-- Find a dropdown frame whose selected text matches one of `wantedTexts`. +-- Modern Blizzard dropdowns expose SetSelectionByValue / Pick or +-- inherit from DropdownButton. We don't try to drive the dropdown's +-- internal value (Blizzard renamed the storage too many times); instead +-- we find a child option button whose text matches and dispatch its +-- OnClick like the user clicked it. +local function ClickMatchingDropdownOption(frame, wantedTexts, depth) + if not frame or (depth or 0) > 10 then return false end + local ot = frame.GetObjectType and frame:GetObjectType() or nil + if ot == "Button" or ot == "CheckButton" then + local label + if frame.text and frame.text.GetText then label = frame.text:GetText() + elseif frame.Text and frame.Text.GetText then label = frame.Text:GetText() else - TokenFrame.filter = mode - TokenFrame.showWarband = warband + if frame.GetRegions then + local regions = { frame:GetRegions() } + for i = 1, #regions do + local r = regions[i] + if r and r.GetObjectType and r:GetObjectType() == "FontString" then + label = r:GetText() + if label and label ~= "" then break end + end + end + end end - if TokenFrame.Update then - pcall(TokenFrame.Update, TokenFrame) + if label then + local lower = slower(label) + for _, want in ipairs(wantedTexts) do + if lower == slower(want) then + local oc = frame:GetScript("OnClick") + if oc then + local ok = pcall(oc, frame, "LeftButton", true) + if ok then return true end + end + if frame.Click then pcall(frame.Click, frame, "LeftButton") end + return true + end + end end end - if BackpackTokenFrame and BackpackTokenFrame.Update then - pcall(BackpackTokenFrame.Update, BackpackTokenFrame) + if frame.GetChildren then + local kids = { frame:GetChildren() } + for i = 1, #kids do + if ClickMatchingDropdownOption(kids[i], wantedTexts, (depth or 0) + 1) then return true end + end end + return false end --- Reputation filter (All / Warband / This Character). Same defensive --- multi-API pattern -- Blizzard's reputation revamp shifted the storage --- location across builds. +-- Trigger a click on a Blizzard dropdown so its menu pops up, then +-- traverse the open menu (which lives under UIParent / DropDownList) +-- to find and click the matching option. Restores the menu's hidden +-- state at the end so we don't leave a dangling visible popup. +local function DriveDropdown(dropdownFrame, wantedTexts) + if not dropdownFrame then return false end + -- Modern WowDropdownMenu pattern + if dropdownFrame.SetSelectionText then + pcall(dropdownFrame.SetSelectionText, dropdownFrame, wantedTexts[1]) + end + -- Find the trigger button and click it to open the menu + local trigger = dropdownFrame.Button or dropdownFrame.MenuTrigger or dropdownFrame + if trigger and trigger.GetScript then + local oc = trigger:GetScript("OnMouseDown") or trigger:GetScript("OnClick") + if oc then pcall(oc, trigger, "LeftButton") end + end + -- Walk all currently visible UIParent children for an open menu and + -- click the matching item. The menu may live in DropDownList1 or + -- newer MenuFrame with dynamic name. + local function findMenu() + if DropDownList1 and DropDownList1:IsShown() then return DropDownList1 end + local kids = { UIParent:GetChildren() } + for i = 1, #kids do + local k = kids[i] + if k and k:IsShown() and k.GetObjectType and k:GetObjectType() == "Frame" then + local strata = k:GetFrameStrata() + if strata == "FULLSCREEN_DIALOG" or strata == "TOOLTIP" then + -- Heuristic: menus are tall narrow popups + if k:GetWidth() < 300 and k:GetHeight() < 400 then + return k + end + end + end + end + return nil + end + local menu = findMenu() + if not menu then return false end + local clicked = ClickMatchingDropdownOption(menu, wantedTexts, 0) + if menu.Hide then menu:Hide() end + return clicked +end + +-- Currency filter (TokenFrame / BackpackTokenFrame). Modern dropdown +-- API path is the priority; field-fallback is kept for older builds. +function UI:ApplyTokenFrameFilter(mode) + if not mode then return end + if not TokenFrame then return end + local labels = (mode == "warband") + and { "Warband Transferable", "Show Warband Transferable", "Warband" } + or { "All", "Show All", "This Character", "This Character Only" } + local fd = TokenFrame.filterDropdown or TokenFrame.FilterDropdown + or TokenFrame.filterDropDown or TokenFrame.dropdown + if fd and DriveDropdown(fd, labels) then + if TokenFrame.Update then pcall(TokenFrame.Update, TokenFrame) end + return + end + -- Fallback: defensive method/field set + for _, m in ipairs({ "SetFilter", "SetFilterMode" }) do + local fn = TokenFrame[m] + if fn then pcall(fn, TokenFrame, mode) end + end + if TokenFrame.Update then pcall(TokenFrame.Update, TokenFrame) end +end + +-- Reputation filter (All / Warband / Char). Same dropdown-drive pattern. function UI:ApplyReputationFilter(mode) if not mode then return end if not ReputationFrame then return end - if ReputationFrame.SetFilter then - pcall(ReputationFrame.SetFilter, ReputationFrame, mode) - elseif ReputationFrame.SetFilterMode then - pcall(ReputationFrame.SetFilterMode, ReputationFrame, mode) + local labels + if mode == "warband" then + labels = { "Warband" } + elseif mode == "char" then + local n = UnitName and UnitName("player") + labels = n and { n } or { "Character" } else - ReputationFrame.filter = mode + labels = { "All" } end - if ReputationFrame.Update then - pcall(ReputationFrame.Update, ReputationFrame) + local fd = ReputationFrame.filterDropdown or ReputationFrame.FilterDropdown + or ReputationFrame.filterDropDown + if fd and DriveDropdown(fd, labels) then + if ReputationFrame.Update then pcall(ReputationFrame.Update, ReputationFrame) end + return end + for _, m in ipairs({ "SetFilter", "SetFilterMode" }) do + local fn = ReputationFrame[m] + if fn then pcall(fn, ReputationFrame, mode) end + end + if ReputationFrame.Update then pcall(ReputationFrame.Update, ReputationFrame) end end +-- Show Legacy Reputations -- a checkbox in the same filter dropdown. +-- The dropdown's checkboxes are toggled by clicking the matching item; +-- driving the menu open + click is the most reliable cross-build path. function UI:ApplyReputationShowLegacy(show) if not ReputationFrame then return end - if ReputationFrame.SetShowLegacy then - pcall(ReputationFrame.SetShowLegacy, ReputationFrame, show and true or false) - else - ReputationFrame.showLegacy = show and true or false - end - if ReputationFrame.Update then - pcall(ReputationFrame.Update, ReputationFrame) + local fd = ReputationFrame.filterDropdown or ReputationFrame.FilterDropdown + or ReputationFrame.filterDropDown + if fd then + -- Snapshot current state so we only click when our state diverges. + local applied = DriveDropdown(fd, { "Show Legacy Reputations" }) + if applied and ReputationFrame.Update then + pcall(ReputationFrame.Update, ReputationFrame) + end end end --- Spellbook "Hide Passives" checkbox. Lives on PlayerSpellsFrame's --- spellbook page in modern WoW; SpellBookFrame is the older path. +-- Hide Passives. The actual checkbox is somewhere under the spellbook +-- frame; rather than guess the path (Blizzard moves it across builds), +-- walk descendants to find a CheckButton labeled "Hide Passives" and +-- toggle it via its native OnClick so all of Blizzard's downstream +-- bookkeeping fires. function UI:ApplySpellBookHidePassives(hide) hide = hide and true or false local frame = (PlayerSpellsFrame and PlayerSpellsFrame.SpellBookFrame) or SpellBookFrame if not frame then return end - if frame.SetHidePassives then - pcall(frame.SetHidePassives, frame, hide) - else - frame.hidePassives = hide - end - if frame.Update then - pcall(frame.Update, frame) - elseif frame.UpdateDisplayedSpells then - pcall(frame.UpdateDisplayedSpells, frame) + local cb = frame._easyFindHidePassivesCB + if not cb or not cb.GetChecked or not cb:IsShown() then + cb = FindCheckButtonByText(frame, "hide passives") + frame._easyFindHidePassivesCB = cb + end + if not cb then + -- Last resort: defensive set + refresh + if frame.SetHidePassives then pcall(frame.SetHidePassives, frame, hide) end + if frame.UpdateDisplayedSpells then pcall(frame.UpdateDisplayedSpells, frame) end + return end + if cb:GetChecked() == hide then return end + cb:SetChecked(hide) + local oc = cb:GetScript("OnClick") + if oc then pcall(oc, cb, "LeftButton", true) + elseif cb.Click then pcall(cb.Click, cb, "LeftButton") end end -- Re-apply all driven filters when the relevant Blizzard frame opens. From ffe86365ecc4399b18bce828ee94fbf5f5377a1b Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 8 May 2026 21:40:45 -0400 Subject: [PATCH 064/103] Highlight: gate hover-dismiss; cancel guide instead of hiding Hover-dismiss without canHoverDismiss() raced the 0.1s step ticker and strobed: HighlightFrame would re-show on the next tick (resetting highlightShownAt), the watcher would hide again, and so on. Use :Cancel() to stop the ticker entirely once the player has the cursor on the target. --- Highlight.lua | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Highlight.lua b/Highlight.lua index cbdf314..70d544a 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -107,11 +107,12 @@ function Highlight:CreateHighlightFrame() Highlight:HideHighlight() return end - -- Hover-to-dismiss: once the player moves the cursor onto the - -- highlighted target, they've found it -- clear the visual so - -- the talent panel / spellbook / etc. isn't cluttered. - if target and target.IsMouseOver and target:IsMouseOver() then - Highlight:HideHighlight() + -- Hover-to-dismiss: cursor is on the target, so the player + -- found it. Cancel (not HideHighlight) stops the step ticker; + -- otherwise the next tick's HighlightFrame re-shows and resets + -- highlightShownAt, this watcher hides again, and we strobe. + if canHoverDismiss() and target and target.IsMouseOver and target:IsMouseOver() then + Highlight:Cancel() end end) end From eb26f565f8b4607b1eca69eca7e1789e6efafe0d Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 8 May 2026 21:40:58 -0400 Subject: [PATCH 065/103] Search: pre-load bosses 5s post-login Boss search was lazy-loaded only on a 'boss' / 'icc' keyword hit, so typing an individual encounter name (e.g. 'Professor Putricide') got no results until something else triggered the load. Schedule the async loader 5s after PLAYER_LOGIN so encounters are searchable on the first keystroke. The async path is time-sliced so the ~1000- encounter scan doesn't stutter. --- Core.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Core.lua b/Core.lua index 718f72b..bfaee55 100644 --- a/Core.lua +++ b/Core.lua @@ -533,6 +533,18 @@ local function OnPlayerLogin() EasyFind:EnsureDynamicLoaded() end + -- Background-load heavy async providers (bosses) a few seconds after + -- login so individual encounter names ("Professor Putricide") match + -- on the first search instead of requiring a "boss" / "icc" keyword + -- to trigger the lazy load. The async loader is time-sliced so the + -- ~1000 encounter scan doesn't stutter. + SafeAfter(5.0, function() + if EasyFind.db.enableUISearch == false then return end + if ns.Database and ns.Database.LoadHeavyDynamicSearchData then + ns.Database:LoadHeavyDynamicSearchData(function() end) + end + end) + -- Pre-warm Blizzard's achievement search index off the user's typing -- path. The index build is the lag source we used to hit on the -- first achievement-related search; doing it once in the background From cd249dc7be196c3f4418829c129132266a2055b8 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 8 May 2026 21:41:30 -0400 Subject: [PATCH 066/103] Filters: drive Blizzard via real APIs; bidirectional sync; UX polish Sync our filter flyouts with Blizzard's own dropdowns through the actual setters their UI calls internally: - Currency: C_CurrencyInfo.SetCurrencyFilter with Enum.CurrencyFilterType.DiscoveredOnly / DiscoveredAndAllAccountTransferable. Mirrors TokenFrame's SetFilterTypeSelected (clears selectedToken/selectedID, hides TokenFramePopup) before flipping. - Reputation: C_Reputation.SetReputationSortType with Enum.ReputationSortType.None / Account / Character. - Show Legacy Reputations: C_Reputation.SetLegacyReputationsShown. - Hide Passives: SetCVar('spellBookHidePassives') + SpellBookFrame:UpdateDisplayedSpells(true, false). After each apply, call dropdown:GenerateMenu() so the trigger label re-evaluates its IsSelected callback and updates the displayed selection text live (was stuck on the previous value until the panel was reopened). Bidirectional sync: hooksecurefunc on the same C_*Info setters and a CVAR_UPDATE listener for spellBookHidePassives, so toggling Blizzard's dropdown updates our DB. Popup re-reads from DB on every show, so opening our flyout reflects out-of-band changes. Currency option labels use Blizzard's CURRENCY_FILTER_TYPE_TRANSFERABLE and CURRENCY_FILTER_TYPE_CHARACTER:format(UnitName('player')) globals so they read ' Only' and 'All Warband Transferable', matching the in-game dropdown verbatim. Order swapped so Warband is first (matches Blizzard's CurrencyFilterFilterTypeOrder). Hide Passives also drops isPassive ability rows from our search results so the filter is consistent across both UIs. Gear Sets moved out of the Collections sub-flyout to its own top-level filter row (gear sets aren't a collection); the bucket filter no longer ties gearSetsOff to collectionsOff. Flyout overlap fix: introduced a single activeFlyoutPopup tracker inside CreateUIFilterDropdown. Any flyout (sub-filters, radio, gear options) hides whatever was previously active on its own Show, so sweeping rapidly between filter rows can never paint two popups at once, even rows whose popup wasn't tracked in dropdown.flyoutPopups. ClearActiveFlyout on each popup's OnHide keeps the tracker in sync. Wheel handling on the results scrollFrame moved into CreateMinimalScrollBar (eased smooth scroll); removed the duplicated linear handler from UI:CreateResultsFrame. --- UI.lua | 377 ++++++++++++++++++++------------------------------------- 1 file changed, 130 insertions(+), 247 deletions(-) diff --git a/UI.lua b/UI.lua index 56fcb05..8027a24 100644 --- a/UI.lua +++ b/UI.lua @@ -679,14 +679,7 @@ function UI:Initialize() self:CreateSearchFrame() self:CreateResultsFrame() self:RegisterCombatEvents() - - -- Hook Blizzard's TokenFrame / ReputationFrame / SpellBookFrame so - -- our filter flyout choices apply when the player opens those - -- panels. Frames are LoadOnDemand: hook each as it appears. - local blizzHook = CreateFrame("Frame") - blizzHook:RegisterEvent("ADDON_LOADED") - blizzHook:SetScript("OnEvent", function() UI:HookBlizzardFilters() end) - UI:HookBlizzardFilters() + self:HookBlizzardFilterChanges() if EasyFind.db.autoHide then searchFrame:Hide() @@ -1998,19 +1991,21 @@ local UI_FILTER_OPTIONS = { { key = "collections", label = "Collections", iconAtlas = "UI-HUD-MicroMenu-Collections-Up", flyoutSubFilters = { { key = "appearanceSets", label = "Appearance Sets", iconTex = "Interface\\Icons\\INV_Helmet_03", hasOptions = true }, - { key = "gearSets", label = "Gear Sets", iconAtlas = "equipmentmanager-spec-border" }, { key = "heirlooms", label = "Heirlooms", iconTex = 133877 }, { key = "mounts", label = "Mounts", iconTex = 132261 }, { key = "outfits", label = "Outfits", iconTex = 132649 }, { key = "pets", label = "Pets", iconTex = 631719 }, { key = "toys", label = "Toys", iconTex = 454046 }, } }, + { key = "gearSets", label = "Gear Sets", iconAtlas = "equipmentmanager-spec-border" }, { key = "currencies", label = "Currencies", iconTex = 136452, flyoutRadio = { dbKey = "currencyFilterMode", options = { - { value = "all", label = "This Character Only" }, - { value = "warband", label = "All Warband Transferable" }, + { value = "warband", label = CURRENCY_FILTER_TYPE_TRANSFERABLE or "All Warband Transferable" }, + { value = "all", label = (CURRENCY_FILTER_TYPE_CHARACTER and UnitName and UnitName("player") + and CURRENCY_FILTER_TYPE_CHARACTER:format(UnitName("player"))) + or ((UnitName and UnitName("player") or "This Character") .. " Only") }, }, onChange = function(v) if UI.ApplyTokenFrameFilter then UI:ApplyTokenFrameFilter(v) end end, } }, @@ -2396,6 +2391,22 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local PADDING_BOTTOM = 8 local CHECK_SIZE = 16 + -- Single source of truth for which side-flyout (sub-filters / radio / + -- gear options) is currently visible. Any popup that opens hides + -- whatever was active so sweeping between rows can never leave a + -- previous flyout lingering, even rows whose popup wasn't tracked + -- in dropdown.flyoutPopups. + local activeFlyoutPopup + local function SetActiveFlyout(popup) + if activeFlyoutPopup and activeFlyoutPopup ~= popup and activeFlyoutPopup:IsShown() then + activeFlyoutPopup:Hide() + end + activeFlyoutPopup = popup + end + local function ClearActiveFlyout(popup) + if activeFlyoutPopup == popup then activeFlyoutPopup = nil end + end + local dropdown = CreateFrame("Frame", "EasyFindUIFilterDropdown", UIParent, "BackdropTemplate") dropdown:SetFrameStrata("FULLSCREEN_DIALOG") dropdown:SetFrameLevel(9999) @@ -2678,6 +2689,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end) popup:HookScript("OnHide", function(self) self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + ClearActiveFlyout(self) end) popup:HookScript("OnEvent", function(self, event) if event ~= "GLOBAL_MOUSE_DOWN" then return end @@ -2878,15 +2890,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local hideTimer local function ShowPopup() if hideTimer then hideTimer:Cancel(); hideTimer = nil end - -- Slam any sibling flyout shut on entry so quickly - -- moving between adjacent flyout rows can't paint two - -- popups on top of each other (the 0.15s grace timer - -- would otherwise leave the previous one hanging). - for _, sibling in ipairs(dropdown.flyoutPopups or {}) do - if sibling ~= popup and sibling:IsShown() then - sibling:Hide() - end - end + SetActiveFlyout(popup) SyncSubChecks() popup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) PositionPopup() @@ -2954,6 +2958,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end) popup:HookScript("OnHide", function(self) self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + ClearActiveFlyout(self) end) popup:HookScript("OnEvent", function(self, event) if event ~= "GLOBAL_MOUSE_DOWN" then return end @@ -3079,9 +3084,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local hideTimer local function ShowPopup() if hideTimer then hideTimer:Cancel(); hideTimer = nil end - for _, sibling in ipairs(dropdown.flyoutPopups or {}) do - if sibling ~= popup and sibling:IsShown() then sibling:Hide() end - end + SetActiveFlyout(popup) SyncRadio() popup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) PositionPopup() @@ -3826,6 +3829,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end local function ShowGear() if gearHideTimer then gearHideTimer:Cancel(); gearHideTimer = nil end + SetActiveFlyout(gearOptionsPopup) if row.UpdateDiffButtons then row.UpdateDiffButtons() end UpdateSpecLabel() for _, sr in ipairs(lootSubRows) do @@ -3854,6 +3858,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local sp = _G["EasyFindSpecPopup"] if sp then sp:Hide() end classFlyout:Hide() + ClearActiveFlyout(self) end) -- Outside-click: nested diff/spec/class popups act as guards -- so clicks inside them don't dismiss the gear options. @@ -6149,7 +6154,7 @@ function UI:OnSearchTextChanged(text, force) local abilitiesOff = filters.abilities == false local bossesOff = filters.bosses == false local titlesOff = filters.titles == false - local gearSetsOff = collectionsOff or filters.gearSets == false + local gearSetsOff = filters.gearSets == false if mountsOff or toysOff or petsOff or outfitsOff or lootOff or appsetsOff or bagsOff or macrosOff or gameOptOff or addonOptOff or abilitiesOff or bossesOff or heirloomsOff or titlesOff or gearSetsOff then @@ -10649,255 +10654,133 @@ function UI:IsCurrencyOnBackpack(currencyID) return info.isShowInBackpack and true or false end --- Walk a frame's descendants depth-first looking for a CheckButton whose --- visible label contains the given (lowercase) substring. Used to find --- Blizzard's Hide Passives checkbox without hard-coding the dynamic hex --- frame path that changes each session. -local function FindCheckButtonByText(frame, needle) - if not frame or not needle then return nil end - local function getLabelText(btn) - if btn.text and btn.text.GetText then return btn.text:GetText() end - if btn.Text and btn.Text.GetText then return btn.Text:GetText() end - if btn.Label and btn.Label.GetText then return btn.Label:GetText() end - if btn.GetFontString then - local fs = btn:GetFontString() - if fs and fs.GetText then return fs:GetText() end - end - if btn.GetRegions then - local regions = { btn:GetRegions() } - for i = 1, #regions do - local r = regions[i] - if r and r.GetObjectType and r:GetObjectType() == "FontString" and r.GetText then - local t = r:GetText() - if t then return t end - end - end - end - return nil - end - local function walk(f, depth) - if not f or depth > 8 then return nil end - local ot = f.GetObjectType and f:GetObjectType() or nil - if ot == "CheckButton" then - local t = getLabelText(f) - if t and slower(t):find(needle, 1, true) then return f end - end - if f.GetChildren then - local kids = { f:GetChildren() } - for i = 1, #kids do - local found = walk(kids[i], depth + 1) - if found then return found end - end - end - return nil - end - return walk(frame, 0) -end - --- Find a dropdown frame whose selected text matches one of `wantedTexts`. --- Modern Blizzard dropdowns expose SetSelectionByValue / Pick or --- inherit from DropdownButton. We don't try to drive the dropdown's --- internal value (Blizzard renamed the storage too many times); instead --- we find a child option button whose text matches and dispatch its --- OnClick like the user clicked it. -local function ClickMatchingDropdownOption(frame, wantedTexts, depth) - if not frame or (depth or 0) > 10 then return false end - local ot = frame.GetObjectType and frame:GetObjectType() or nil - if ot == "Button" or ot == "CheckButton" then - local label - if frame.text and frame.text.GetText then label = frame.text:GetText() - elseif frame.Text and frame.Text.GetText then label = frame.Text:GetText() - else - if frame.GetRegions then - local regions = { frame:GetRegions() } - for i = 1, #regions do - local r = regions[i] - if r and r.GetObjectType and r:GetObjectType() == "FontString" then - label = r:GetText() - if label and label ~= "" then break end - end - end - end - end - if label then - local lower = slower(label) - for _, want in ipairs(wantedTexts) do - if lower == slower(want) then - local oc = frame:GetScript("OnClick") - if oc then - local ok = pcall(oc, frame, "LeftButton", true) - if ok then return true end - end - if frame.Click then pcall(frame.Click, frame, "LeftButton") end - return true - end - end - end - end - if frame.GetChildren then - local kids = { frame:GetChildren() } - for i = 1, #kids do - if ClickMatchingDropdownOption(kids[i], wantedTexts, (depth or 0) + 1) then return true end - end - end - return false -end - --- Trigger a click on a Blizzard dropdown so its menu pops up, then --- traverse the open menu (which lives under UIParent / DropDownList) --- to find and click the matching option. Restores the menu's hidden --- state at the end so we don't leave a dangling visible popup. -local function DriveDropdown(dropdownFrame, wantedTexts) - if not dropdownFrame then return false end - -- Modern WowDropdownMenu pattern - if dropdownFrame.SetSelectionText then - pcall(dropdownFrame.SetSelectionText, dropdownFrame, wantedTexts[1]) - end - -- Find the trigger button and click it to open the menu - local trigger = dropdownFrame.Button or dropdownFrame.MenuTrigger or dropdownFrame - if trigger and trigger.GetScript then - local oc = trigger:GetScript("OnMouseDown") or trigger:GetScript("OnClick") - if oc then pcall(oc, trigger, "LeftButton") end - end - -- Walk all currently visible UIParent children for an open menu and - -- click the matching item. The menu may live in DropDownList1 or - -- newer MenuFrame with dynamic name. - local function findMenu() - if DropDownList1 and DropDownList1:IsShown() then return DropDownList1 end - local kids = { UIParent:GetChildren() } - for i = 1, #kids do - local k = kids[i] - if k and k:IsShown() and k.GetObjectType and k:GetObjectType() == "Frame" then - local strata = k:GetFrameStrata() - if strata == "FULLSCREEN_DIALOG" or strata == "TOOLTIP" then - -- Heuristic: menus are tall narrow popups - if k:GetWidth() < 300 and k:GetHeight() < 400 then - return k - end - end - end - end - return nil - end - local menu = findMenu() - if not menu then return false end - local clicked = ClickMatchingDropdownOption(menu, wantedTexts, 0) - if menu.Hide then menu:Hide() end - return clicked +-- Force a dropdown's trigger label to re-read its IsSelected callback. +-- Modern WowDropdownMenu builds the displayed selection text inside +-- SetupMenu's generator; calling GenerateMenu rebuilds and re-evaluates. +local function RefreshDropdownLabel(dropdown) + if not dropdown then return end + if dropdown.GenerateMenu then pcall(dropdown.GenerateMenu, dropdown) + elseif dropdown.RefreshMenu then pcall(dropdown.RefreshMenu, dropdown) + elseif dropdown.SignalUpdate then pcall(dropdown.SignalUpdate, dropdown) end end --- Currency filter (TokenFrame / BackpackTokenFrame). Modern dropdown --- API path is the priority; field-fallback is kept for older builds. +-- Currency filter. API: C_CurrencyInfo.SetCurrencyFilter(filterType). +-- DiscoveredAndAllAccountTransferable = "warband"; DiscoveredOnly = "all". function UI:ApplyTokenFrameFilter(mode) - if not mode then return end - if not TokenFrame then return end - local labels = (mode == "warband") - and { "Warband Transferable", "Show Warband Transferable", "Warband" } - or { "All", "Show All", "This Character", "This Character Only" } - local fd = TokenFrame.filterDropdown or TokenFrame.FilterDropdown - or TokenFrame.filterDropDown or TokenFrame.dropdown - if fd and DriveDropdown(fd, labels) then + if not mode or not C_CurrencyInfo or not C_CurrencyInfo.SetCurrencyFilter then return end + if not Enum or not Enum.CurrencyFilterType then return end + local target = (mode == "warband") + and Enum.CurrencyFilterType.DiscoveredAndAllAccountTransferable + or Enum.CurrencyFilterType.DiscoveredOnly + local current = C_CurrencyInfo.GetCurrencyFilter and C_CurrencyInfo.GetCurrencyFilter() + if current == target then return end + -- Mirror Blizzard's SetFilterTypeSelected: clear in-flight selection + -- so the popup doesn't try to render a row that no longer exists. + if TokenFrame then + TokenFrame.selectedToken = nil + TokenFrame.selectedID = nil + end + if TokenFramePopup and TokenFramePopup.Hide then TokenFramePopup:Hide() end + pcall(C_CurrencyInfo.SetCurrencyFilter, target) + if TokenFrame and TokenFrame:IsShown() then if TokenFrame.Update then pcall(TokenFrame.Update, TokenFrame) end - return - end - -- Fallback: defensive method/field set - for _, m in ipairs({ "SetFilter", "SetFilterMode" }) do - local fn = TokenFrame[m] - if fn then pcall(fn, TokenFrame, mode) end + RefreshDropdownLabel(TokenFrame.filterDropdown) end - if TokenFrame.Update then pcall(TokenFrame.Update, TokenFrame) end end --- Reputation filter (All / Warband / Char). Same dropdown-drive pattern. +-- Reputation sort type. API: C_Reputation.SetReputationSortType(sortType). +-- None = "all"; Account = "warband"; Character = "char". function UI:ApplyReputationFilter(mode) - if not mode then return end - if not ReputationFrame then return end - local labels + if not mode or not C_Reputation or not C_Reputation.SetReputationSortType then return end + if not Enum or not Enum.ReputationSortType then return end + local sortType if mode == "warband" then - labels = { "Warband" } + sortType = Enum.ReputationSortType.Account elseif mode == "char" then - local n = UnitName and UnitName("player") - labels = n and { n } or { "Character" } + sortType = Enum.ReputationSortType.Character else - labels = { "All" } + sortType = Enum.ReputationSortType.None end - local fd = ReputationFrame.filterDropdown or ReputationFrame.FilterDropdown - or ReputationFrame.filterDropDown - if fd and DriveDropdown(fd, labels) then + local current = C_Reputation.GetReputationSortType and C_Reputation.GetReputationSortType() + if current == sortType then return end + pcall(C_Reputation.SetReputationSortType, sortType) + if ReputationFrame and ReputationFrame:IsShown() then if ReputationFrame.Update then pcall(ReputationFrame.Update, ReputationFrame) end - return - end - for _, m in ipairs({ "SetFilter", "SetFilterMode" }) do - local fn = ReputationFrame[m] - if fn then pcall(fn, ReputationFrame, mode) end + RefreshDropdownLabel(ReputationFrame.filterDropdown) end - if ReputationFrame.Update then pcall(ReputationFrame.Update, ReputationFrame) end end --- Show Legacy Reputations -- a checkbox in the same filter dropdown. --- The dropdown's checkboxes are toggled by clicking the matching item; --- driving the menu open + click is the most reliable cross-build path. +-- Show Legacy Reputations. API: C_Reputation.SetLegacyReputationsShown(bool). function UI:ApplyReputationShowLegacy(show) - if not ReputationFrame then return end - local fd = ReputationFrame.filterDropdown or ReputationFrame.FilterDropdown - or ReputationFrame.filterDropDown - if fd then - -- Snapshot current state so we only click when our state diverges. - local applied = DriveDropdown(fd, { "Show Legacy Reputations" }) - if applied and ReputationFrame.Update then - pcall(ReputationFrame.Update, ReputationFrame) - end + if not C_Reputation or not C_Reputation.SetLegacyReputationsShown then return end + show = show and true or false + local current = C_Reputation.AreLegacyReputationsShown and C_Reputation.AreLegacyReputationsShown() + if current == show then return end + pcall(C_Reputation.SetLegacyReputationsShown, show) + if ReputationFrame and ReputationFrame:IsShown() then + if ReputationFrame.Update then pcall(ReputationFrame.Update, ReputationFrame) end + RefreshDropdownLabel(ReputationFrame.filterDropdown) end end --- Hide Passives. The actual checkbox is somewhere under the spellbook --- frame; rather than guess the path (Blizzard moves it across builds), --- walk descendants to find a CheckButton labeled "Hide Passives" and --- toggle it via its native OnClick so all of Blizzard's downstream --- bookkeeping fires. +-- Hide Passives. CVar-backed: spellBookHidePassives ("0" / "1"). +-- Mirrors SpellBookFrameMixin:SetupSettingsDropdown's SetSelected logic. function UI:ApplySpellBookHidePassives(hide) hide = hide and true or false - local frame = (PlayerSpellsFrame and PlayerSpellsFrame.SpellBookFrame) or SpellBookFrame - if not frame then return end - local cb = frame._easyFindHidePassivesCB - if not cb or not cb.GetChecked or not cb:IsShown() then - cb = FindCheckButtonByText(frame, "hide passives") - frame._easyFindHidePassivesCB = cb - end - if not cb then - -- Last resort: defensive set + refresh - if frame.SetHidePassives then pcall(frame.SetHidePassives, frame, hide) end - if frame.UpdateDisplayedSpells then pcall(frame.UpdateDisplayedSpells, frame) end - return + if GetCVarBool and GetCVarBool("spellBookHidePassives") == hide then return end + if SetCVar then pcall(SetCVar, "spellBookHidePassives", hide and "1" or "0") end + local frame = PlayerSpellsFrame and PlayerSpellsFrame.SpellBookFrame + if frame and frame:IsShown() and frame.UpdateDisplayedSpells then + pcall(frame.UpdateDisplayedSpells, frame, true, false) + end +end + +-- Bidirectional sync from Blizzard back to our DB. Hook the same +-- C_*Info setters Blizzard's own dropdowns call so toggling the +-- in-game UI updates our flyout state. The popup syncs from DB on +-- next show, so we don't need to refresh anything live. +function UI:HookBlizzardFilterChanges() + if C_CurrencyInfo and C_CurrencyInfo.SetCurrencyFilter and Enum and Enum.CurrencyFilterType then + hooksecurefunc(C_CurrencyInfo, "SetCurrencyFilter", function(filterType) + local mode = (filterType == Enum.CurrencyFilterType.DiscoveredAndAllAccountTransferable) + and "warband" or "all" + if EasyFind.db and EasyFind.db.currencyFilterMode ~= mode then + EasyFind.db.currencyFilterMode = mode + end + end) end - if cb:GetChecked() == hide then return end - cb:SetChecked(hide) - local oc = cb:GetScript("OnClick") - if oc then pcall(oc, cb, "LeftButton", true) - elseif cb.Click then pcall(cb.Click, cb, "LeftButton") end -end - --- Re-apply all driven filters when the relevant Blizzard frame opens. --- The frames are LoadOnDemand, so we hook OnShow lazily once the frame --- becomes available. -function UI:HookBlizzardFilters() - local function hookOnce(frame, applyFn, dbKey) - if not frame or frame._easyFindFilterHooked then return end - frame._easyFindFilterHooked = true - frame:HookScript("OnShow", function() - local v = EasyFind.db and EasyFind.db[dbKey] - if v ~= nil then applyFn(UI, v) end + if C_Reputation and C_Reputation.SetReputationSortType and Enum and Enum.ReputationSortType then + hooksecurefunc(C_Reputation, "SetReputationSortType", function(sortType) + local mode + if sortType == Enum.ReputationSortType.Account then mode = "warband" + elseif sortType == Enum.ReputationSortType.Character then mode = "char" + else mode = "all" end + if EasyFind.db and EasyFind.db.reputationFilterMode ~= mode then + EasyFind.db.reputationFilterMode = mode + end end) end - if TokenFrame then hookOnce(TokenFrame, UI.ApplyTokenFrameFilter, "currencyFilterMode") end - if ReputationFrame then - hookOnce(ReputationFrame, function(self) - self:ApplyReputationFilter(EasyFind.db.reputationFilterMode) - self:ApplyReputationShowLegacy(EasyFind.db.showLegacyReputations) - end, "reputationFilterMode") + if C_Reputation and C_Reputation.SetLegacyReputationsShown then + hooksecurefunc(C_Reputation, "SetLegacyReputationsShown", function(show) + show = show and true or false + if EasyFind.db and EasyFind.db.showLegacyReputations ~= show then + EasyFind.db.showLegacyReputations = show + end + end) end - local sbf = (PlayerSpellsFrame and PlayerSpellsFrame.SpellBookFrame) or SpellBookFrame - if sbf then hookOnce(sbf, UI.ApplySpellBookHidePassives, "abilityHidePassives") end + -- Hide Passives is CVar-backed; CVAR_UPDATE fires on any change. + local cvarFrame = CreateFrame("Frame") + cvarFrame:RegisterEvent("CVAR_UPDATE") + cvarFrame:SetScript("OnEvent", function(_, _, name, value) + if not name then return end + local n = slower(name) + if n == "spellbookhidepassives" then + local hide = value == "1" or value == 1 or value == true + if EasyFind.db and EasyFind.db.abilityHidePassives ~= hide then + EasyFind.db.abilityHidePassives = hide + end + end + end) end function UI:ToggleCurrencyBackpack(currencyID) From 7cbfdc058d22a6f5db7b5887bbb7e466901caa76 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 00:56:14 -0400 Subject: [PATCH 067/103] Settings: live scroll/highlight, slider formatter, per-row Apply Migrate to modern category enumeration (SettingsPanel.GetAllCategories); the legacy Settings.GetCategoryList was removed in Midnight 12.0 so the formatter and option lookups were silently bailing on the preflight. Navigation: - HighlightFoundElement draws the standard yellow border around the visible row after scrolling so the player lands on a clearly-marked setting instead of having to scan the panel. - ScrollToBindingAction handles keybind rows: finds the SettingsKeybindingSection by header (or by bindingsCategories membership), expands it via the section frame's Button:Click() if collapsed, then walks the section's Controls for a binding frame whose initializer.data.bindingIndex matches the action and highlights it. step.bindingHeader/bindingAction propagated into the keybind entry so HandleStep has them. Slider display: - GetFormatterForVariable lazy-resolves the live initializer's options.formatters table for curated SETTINGS_DATA sliders that ship without one (Mouse Look Speed raw 180 -> displayed "5.5", etc.). - PickFormatter prefers the "Top" label (value above the thumb). Per-row Apply/Reset infrastructure: - NotePendingApply, HasPendingChange, ApplyVariable, RevertVariable expose the staged-pendingValue state for settings flagged with CommitFlag.Apply (graphics, resolution). - ApplyVariable uses SetValue(value, immediate=true) which bypasses the pendingValue/Commit/Revert pipeline and the 8-second auto-revert popup that was wiping changes when the SettingsPanel wasn't shown. - PROXY_DEPENDENT_DEFAULTS handles PROXY_ANTIALIASING specifically: the parent's SetValue closure only zeros the OTHER mode's CVar and assumes the chosen mode's quality CVar is non-zero (Blizzard's UI has a secondary dropdown for that). We default the dependent CVar to a baseline so applying the parent alone actually enables AA. - PROXY_DEPENDENTS commits any settings the parent's closure staged via dep:SetValue (without immediate), since those Apply-flagged dependents would otherwise stay staged after the parent applies. --- BlizzOptionsSearch.lua | 526 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 480 insertions(+), 46 deletions(-) diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index cfade63..db54922 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -14,6 +14,25 @@ local slower = Utils.slower local SafeAfter = Utils.SafeAfter local pcall = pcall +-- Modern WoW exposes the categories via SettingsPanel:GetAllCategories. +-- The legacy Settings.GetCategoryList was removed in Midnight (12.0), +-- so callers that walked it returned nil and silently failed. +local function GetSettingsCategoryList() + if SettingsPanel and SettingsPanel.GetAllCategories then + local ok, list = pcall(SettingsPanel.GetAllCategories, SettingsPanel) + if ok and type(list) == "table" then return list end + end + if Settings and Settings.GetCategoryList then + local ok, list = pcall(Settings.GetCategoryList) + if ok and type(list) == "table" then return list end + end + return nil +end +local function HasSettingsCategoryAccess() + return (SettingsPanel and SettingsPanel.GetAllCategories) ~= nil + or (Settings and Settings.GetCategoryList) ~= nil +end + -- Curated list of individual settings for direct search. -- Format: { display name, CVar/variable, category name, type code, [min, max, step] } -- type: c=checkbox, d=dropdown, s=slider @@ -542,8 +561,7 @@ local function GetOptionsForVariable(variable) optionsByVariable[variable] = hardcoded return hardcoded end - if not (SettingsPanel and SettingsPanel.GetLayout - and Settings and Settings.GetCategoryList) then + if not (SettingsPanel and SettingsPanel.GetLayout and HasSettingsCategoryAccess()) then return nil end local found @@ -577,7 +595,7 @@ local function GetOptionsForVariable(variable) end end end - local list = Settings.GetCategoryList() + local list = GetSettingsCategoryList() if type(list) == "table" then for _, cat in ipairs(list) do scan(cat) end end @@ -586,6 +604,289 @@ local function GetOptionsForVariable(variable) end BlizzOptionsSearch.GetOptionsForVariable = GetOptionsForVariable +-- Pull the slider's display formatter from the live registry so curated +-- SETTINGS_DATA entries (Mouse Look Speed, Camera Distance, etc.) show +-- the same value Blizzard's panel does instead of the raw CVar number +-- (e.g., PROXY_MOUSE_LOOK_SPEED raw 180 -> displayed "5.5"). +local formatterByVariable = {} +local function PickFormatter(formatters) + if type(formatters) ~= "table" then return nil end + -- MinimalSliderWithSteppersMixin.Label = MakeEnum("Left","Right","Top","Min","Max") + -- Top (2) mirrors the live value above the thumb best. + local f = formatters[2] or formatters[1] or formatters[0] + or formatters.Top or formatters.Right + if type(f) == "function" then return f end + for _, fn in pairs(formatters) do + if type(fn) == "function" then return fn end + end + return nil +end +local function GetFormatterForVariable(variable) + if not variable then return nil end + local cached = formatterByVariable[variable] + if cached ~= nil then + return cached ~= false and cached or nil + end + if not (SettingsPanel and SettingsPanel.GetLayout and HasSettingsCategoryAccess()) then + return nil + end + local found + local function scan(cat) + if found or not cat then return end + local lok, layout = pcall(SettingsPanel.GetLayout, SettingsPanel, cat) + if lok and layout and layout.GetInitializers then + local iok, inits = pcall(layout.GetInitializers, layout) + if iok and inits then + for _, init in ipairs(inits) do + local setting + if init.GetSetting then + local sok, s = pcall(init.GetSetting, init) + if sok then setting = s end + end + if not setting and init.data then setting = init.data.setting end + if setting and setting.GetVariable then + local vok, v = pcall(setting.GetVariable, setting) + if vok and v == variable then + local d = init.data + local opts = (type(d) == "table") and d.options or nil + if type(opts) == "function" then + local ook, o = pcall(opts, setting) + if not ook then ook, o = pcall(opts) end + if ook then opts = o end + end + if type(opts) == "table" then + found = PickFormatter(opts.formatters) + end + return + end + end + end + end + end + if cat.GetSubcategories then + local sok, subs = pcall(cat.GetSubcategories, cat) + if sok and type(subs) == "table" then + for _, sub in ipairs(subs) do scan(sub) end + end + end + end + local list = GetSettingsCategoryList() + if type(list) == "table" then + for _, cat in ipairs(list) do scan(cat) end + end + formatterByVariable[variable] = found or false + return found +end +BlizzOptionsSearch.GetFormatterForVariable = GetFormatterForVariable + +-- Pending-apply tracking. Settings with CommitFlag.Apply (graphics +-- options, resolution, etc.) stage their value into setting.pendingValue +-- instead of writing through, so we mirror Blizzard's bottom-bar UX: +-- accumulate pending changes and let the user Apply / Revert as a batch. +local pendingApplySettings = {} +local pendingChangeCallbacks = {} + +local function PendingCount() + -- Prune entries whose pendingValue cleared out from under us (user + -- opened Blizzard's panel and Applied / Cancelled there directly). + local n = 0 + for setting in pairs(pendingApplySettings) do + if setting.pendingValue ~= nil then + n = n + 1 + else + pendingApplySettings[setting] = nil + end + end + return n +end + +local function FirePendingChanged() + local n = PendingCount() + for i = 1, #pendingChangeCallbacks do + pcall(pendingChangeCallbacks[i], n) + end +end + +function BlizzOptionsSearch:RegisterPendingChangedCallback(fn) + if type(fn) ~= "function" then return end + pendingChangeCallbacks[#pendingChangeCallbacks + 1] = fn +end + +function BlizzOptionsSearch:GetPendingApplyCount() + return PendingCount() +end + +-- Called by the inline editor right after Setting:SetValue. If the +-- setting has the Apply commit flag, the new value is staged in +-- setting.pendingValue and needs the user to commit; otherwise the +-- value is already live and we drop the entry. +function BlizzOptionsSearch:NotePendingApply(variable) + if not variable or not Settings or not Settings.GetSetting then return end + local sok, settObj = pcall(Settings.GetSetting, variable) + if not sok or not settObj then return end + local hasApply + if settObj.HasCommitFlag and Settings.CommitFlag and Settings.CommitFlag.Apply then + local hok, has = pcall(settObj.HasCommitFlag, settObj, Settings.CommitFlag.Apply) + hasApply = hok and has + end + if hasApply and settObj.pendingValue ~= nil then + pendingApplySettings[settObj] = settObj + FirePendingChanged() + elseif pendingApplySettings[settObj] then + -- Setting reverted to original via repeat-edit: drop it. + pendingApplySettings[settObj] = nil + FirePendingChanged() + end +end + +-- Apply all pending changes at once. Mirrors SettingsPanel:CommitSettings +-- so secondary effects (gx restart, window update, save bindings) fire +-- once for the batch instead of per-setting. +function BlizzOptionsSearch:ApplyPendingChanges() + if not next(pendingApplySettings) then return end + if not Settings or not Settings.CommitFlag then return end + + local list = {} + for setting in pairs(pendingApplySettings) do + if setting.LockPendingValue then pcall(setting.LockPendingValue, setting) end + list[#list + 1] = setting + end + table.sort(list, function(a, b) + local oa = a.GetCommitOrder and a:GetCommitOrder() or 0 + local ob = b.GetCommitOrder and b:GetCommitOrder() or 0 + return oa < ob + end) + local saveBindings, gxRestart, windowUpdate = false, false, false + for i = 1, #list do + local s = list[i] + if s.HasCommitFlag then + saveBindings = saveBindings or (pcall(s.HasCommitFlag, s, Settings.CommitFlag.SaveBindings) and s:HasCommitFlag(Settings.CommitFlag.SaveBindings)) + gxRestart = gxRestart or (pcall(s.HasCommitFlag, s, Settings.CommitFlag.GxRestart) and s:HasCommitFlag(Settings.CommitFlag.GxRestart)) + windowUpdate= windowUpdate or (pcall(s.HasCommitFlag, s, Settings.CommitFlag.UpdateWindow) and s:HasCommitFlag(Settings.CommitFlag.UpdateWindow)) + end + if s.Commit then pcall(s.Commit, s) end + end + if gxRestart and RestartGx then pcall(RestartGx) end + if windowUpdate and UpdateWindow then pcall(UpdateWindow) end + if saveBindings and SaveBindings and GetCurrentBindingSet then + pcall(SaveBindings, GetCurrentBindingSet()) + end + wipe(pendingApplySettings) + FirePendingChanged() +end + +function BlizzOptionsSearch:RevertPendingChanges() + if not next(pendingApplySettings) then return end + for setting in pairs(pendingApplySettings) do + if setting.Revert then pcall(setting.Revert, setting) end + end + wipe(pendingApplySettings) + FirePendingChanged() +end + +-- True when a single variable has a staged-but-not-applied change. +function BlizzOptionsSearch:HasPendingChange(variable) + if not variable or not Settings or not Settings.GetSetting then return false end + local sok, settObj = pcall(Settings.GetSetting, variable) + if not sok or not settObj then return false end + return settObj.pendingValue ~= nil +end + +local function FlagsFor(setting) + local saveBindings, gxRestart, windowUpdate + if setting.HasCommitFlag and Settings and Settings.CommitFlag then + local function has(flag) + local ok, v = pcall(setting.HasCommitFlag, setting, flag) + return ok and v + end + saveBindings = has(Settings.CommitFlag.SaveBindings) + gxRestart = has(Settings.CommitFlag.GxRestart) + windowUpdate = has(Settings.CommitFlag.UpdateWindow) + end + return saveBindings, gxRestart, windowUpdate +end + +-- Apply / revert one specific setting (per-row Apply/Reset buttons). +-- PROXY_ANTIALIASING is a "view" setting whose own SetValue closure +-- only zeros the OTHER mode's CVar (fxaa or msaa); it assumes the +-- chosen mode's CVar is already non-zero (Blizzard's UI relies on a +-- secondary "quality" dropdown the user fills in). For our inline +-- editor without that dropdown, default the dependent CVar to a +-- baseline so applying the parent actually turns AA on. +local PROXY_DEPENDENT_DEFAULTS = { + PROXY_ANTIALIASING = function(value) + if value == 1 and GetCVar and tonumber(GetCVar("ffxAntiAliasingMode")) == 0 then + if SetCVar then pcall(SetCVar, "ffxAntiAliasingMode", "1") end + elseif value == 2 and GetCVar and tonumber(GetCVar("MSAAQuality")) == 0 then + if SetCVar then pcall(SetCVar, "MSAAQuality", "2") end + elseif value == 3 then + if GetCVar and tonumber(GetCVar("ffxAntiAliasingMode")) == 0 + and SetCVar then pcall(SetCVar, "ffxAntiAliasingMode", "1") end + if GetCVar and tonumber(GetCVar("MSAAQuality")) == 0 + and SetCVar then pcall(SetCVar, "MSAAQuality", "2") end + end + end, +} + +-- Settings whose own SetValue closure stages dependent Apply-flagged +-- settings (e.g., PROXY_ANTIALIASING's closure calls +-- aaSettings.fxaa:SetValue / aaSettings.msaa:SetValue). When we Apply +-- the parent, those dependents stay staged unless we also commit them. +local PROXY_DEPENDENTS = { + PROXY_ANTIALIASING = { "PROXY_FXAA", "PROXY_MSAA", "PROXY_MSAA_ALPHA" }, +} + +local function CommitStagedDependents(parentVar) + local deps = PROXY_DEPENDENTS[parentVar] + if not deps or not Settings or not Settings.GetSetting then return end + for i = 1, #deps do + local depVar = deps[i] + local ok, depObj = pcall(Settings.GetSetting, depVar) + if ok and depObj and depObj.pendingValue ~= nil and depObj.SetValue then + pcall(depObj.SetValue, depObj, depObj.pendingValue, true) + pendingApplySettings[depObj] = nil + end + end +end + +function BlizzOptionsSearch:ApplyVariable(variable) + if not variable or not Settings or not Settings.GetSetting then return end + local sok, settObj = pcall(Settings.GetSetting, variable) + if not sok or not settObj or settObj.pendingValue == nil then return end + local pending = settObj.pendingValue + local depsFn = PROXY_DEPENDENT_DEFAULTS[variable] + if depsFn then pcall(depsFn, pending) end + if settObj.SetValue then + pcall(settObj.SetValue, settObj, pending, true) + end + -- Parent's SetValue closure may have staged dependents (e.g., + -- AA_NONE clears both fxaa and msaa via dep:SetValue, which stages + -- when the dep itself is Apply-flagged). Commit those too. + CommitStagedDependents(variable) + pendingApplySettings[settObj] = nil + FirePendingChanged() +end + +function BlizzOptionsSearch:RevertVariable(variable) + if not variable or not Settings or not Settings.GetSetting then return end + local sok, settObj = pcall(Settings.GetSetting, variable) + if not sok or not settObj or settObj.pendingValue == nil then return end + if settObj.Revert then pcall(settObj.Revert, settObj) end + pendingApplySettings[settObj] = nil + -- Discard any dependents the parent staged. + local deps = PROXY_DEPENDENTS[variable] + if deps then + for i = 1, #deps do + local _, depObj = pcall(Settings.GetSetting, deps[i]) + if depObj and depObj.pendingValue ~= nil and depObj.Revert then + pcall(depObj.Revert, depObj) + pendingApplySettings[depObj] = nil + end + end + end + FirePendingChanged() +end + -- Walk one category and its subcategories, recording id by name and -- (where possible) by variable. SettingsPanel exposes the layout per -- category; the layout has GetInitializers which return setting rows. @@ -683,12 +984,10 @@ end -- party addon's setting registration, weird initializer shape) can't -- abort the entire crawl and starve SETTINGS_DATA of category ids. local function ResolveCategoryIDs() - if Settings and Settings.GetCategoryList then - local lok, list = pcall(Settings.GetCategoryList) - if lok and type(list) == "table" then - for _, cat in ipairs(list) do - pcall(CrawlCategory, cat) - end + local list = GetSettingsCategoryList() + if type(list) == "table" then + for _, cat in ipairs(list) do + pcall(CrawlCategory, cat) end end if SettingsPanel and SettingsPanel.GetAllCategories then @@ -748,49 +1047,160 @@ local function OpenSettingsByName(name) end BlizzOptionsSearch.OpenSettingsByName = OpenSettingsByName --- Scroll the SettingsPanel's setting list to the row matching the --- given variable name. Called after the category opens. -local function ScrollToSettingVariable(variable) - if not SettingsPanel then return false end - local scrollBox = SettingsPanel.Container +local function GetSettingsScrollBox() + if not SettingsPanel then return nil end + return SettingsPanel.Container and SettingsPanel.Container.SettingsList and SettingsPanel.Container.SettingsList.ScrollBox +end + +-- Highlight the visible row for an elementData in the settings list, so +-- the user lands on a clearly-marked row instead of having to scan the +-- whole panel for the thing they searched. +local function HighlightFoundElement(scrollBox, elementData) + if not scrollBox or not elementData then return end + if not ns.Highlight or not ns.Highlight.HighlightFrame then return end + SafeAfter(0.05, function() + local frame = scrollBox.FindFrame and scrollBox:FindFrame(elementData) + if frame then ns.Highlight:HighlightFrame(frame) end + end) +end + +local function FindSettingElement(dp, variable) + if not dp then return nil end + local function matches(elementData) + local inner = elementData and (elementData.data or elementData) + local setting = inner and inner.setting + if setting and setting.GetVariable then + return setting:GetVariable() == variable + end + return false + end + if dp.FindElementDataByPredicate then + local found = dp:FindElementDataByPredicate(matches) + if found then return found end + end + if dp.GetSize and dp.Find then + for si = 1, dp:GetSize() do + local ed = dp:Find(si) + if matches(ed) then return ed end + end + end + return nil +end + +-- Scroll to and highlight the row for a setting variable. +local function ScrollToSettingVariable(variable) + local scrollBox = GetSettingsScrollBox() + if not scrollBox then return false end + local dp = scrollBox.GetDataProvider and scrollBox:GetDataProvider() + local found = FindSettingElement(dp, variable) + if not found then return false end + local alignCenter = ScrollBoxConstants and ScrollBoxConstants.AlignCenter + scrollBox:ScrollToElementData(found, alignCenter) + HighlightFoundElement(scrollBox, found) + return true +end +BlizzOptionsSearch.ScrollToSettingVariable = ScrollToSettingVariable + +-- Look up a binding's index from its action name (e.g., ACTIONBUTTON1). +local function GetBindingIndexForAction(action) + if not action or not GetNumBindings or not GetBinding then return nil end + for i = 1, GetNumBindings() do + local a = GetBinding(i) + if a == action then return i end + end + return nil +end + +-- Walk a SettingsKeybindingSection's child binding frames for one whose +-- bindingIndex matches. KeyBindingFrameBindingTemplateMixin:Init stashes +-- the initializer on self.initializer, so binding index lives at +-- frame.initializer.data.bindingIndex. +local function FindBindingFrameInSection(sectionFrame, bindingIndex) + if not sectionFrame or not sectionFrame.Controls then return nil end + for _, frame in ipairs(sectionFrame.Controls) do + local idx + if frame.initializer and frame.initializer.data then + idx = frame.initializer.data.bindingIndex + end + if not idx and frame.data and frame.data.bindingIndex then + idx = frame.data.bindingIndex + end + if idx == bindingIndex then return frame end + end + return nil +end + +-- Scroll to a keybind: find the section by header name, expand it if +-- collapsed, then highlight the binding row inside. +local function ScrollToBindingAction(action, headerName) + if not action then return false end + local scrollBox = GetSettingsScrollBox() if not scrollBox then return false end local dp = scrollBox.GetDataProvider and scrollBox:GetDataProvider() if not dp then return false end - local found - if dp.FindElementDataByPredicate then - found = dp:FindElementDataByPredicate(function(elementData) - local inner = elementData and (elementData.data or elementData) - local setting = inner and inner.setting - if setting and setting.GetVariable then - return setting:GetVariable() == variable + local bindingIdx = GetBindingIndexForAction(action) + + -- Locate the section element. Match by header name first; fall back + -- to any section whose bindingsCategories contains our index. + local headerLower = headerName and slower(headerName) or nil + local function matchSection(elementData) + local inner = elementData and (elementData.data or elementData) + if not inner then return false end + if headerLower and inner.name and slower(inner.name) == headerLower then + return true + end + if bindingIdx and inner.bindingsCategories then + for _, entry in ipairs(inner.bindingsCategories) do + if type(entry) == "table" and entry[1] == bindingIdx then return true end end - return false - end) + end + return false end - if not found and dp.GetSize and dp.Find then - local sz = dp:GetSize() - for si = 1, sz do - local sdata = dp:Find(si) - local inner = sdata and (sdata.data or sdata) - local setting = inner and inner.setting - if setting and setting.GetVariable - and setting:GetVariable() == variable then - found = sdata - break - end + + local section + if dp.FindElementDataByPredicate then + section = dp:FindElementDataByPredicate(matchSection) + end + if not section and dp.GetSize and dp.Find then + for si = 1, dp:GetSize() do + local ed = dp:Find(si) + if matchSection(ed) then section = ed; break end end end - if found then - local alignCenter = ScrollBoxConstants and ScrollBoxConstants.AlignCenter - scrollBox:ScrollToElementData(found, alignCenter) - return true + if not section then return false end + + local sectionData = section.data or section + local alignBegin = ScrollBoxConstants and ScrollBoxConstants.AlignBegin + scrollBox:ScrollToElementData(section, alignBegin) + + -- Expand if collapsed, wait for the section's child frames to lay + -- out, then highlight the actual binding row. The section frame may + -- be recycled across passes, so re-fetch it inside the deferred + -- callback rather than capturing the first reference. + local function expandThenHighlight() + local sectionFrame = scrollBox.FindFrame and scrollBox:FindFrame(section) + if not sectionFrame then return end + if sectionData and not sectionData.expanded and sectionFrame.Button then + sectionFrame.Button:Click() + end + SafeAfter(0.15, function() + local sf = scrollBox.FindFrame and scrollBox:FindFrame(section) + if not sf or not bindingIdx then return end + local bindingFrame = FindBindingFrameInSection(sf, bindingIdx) + if bindingFrame and bindingFrame:IsShown() + and ns.Highlight and ns.Highlight.HighlightFrame then + ns.Highlight:HighlightFrame(bindingFrame) + end + end) end - return false + SafeAfter(0.05, expandThenHighlight) + + return true end -BlizzOptionsSearch.ScrollToSettingVariable = ScrollToSettingVariable +BlizzOptionsSearch.ScrollToBindingAction = ScrollToBindingAction -- Collect name/path entries. SETTINGS_DATA always produces entries -- regardless of whether the live Settings tree is available yet (the @@ -860,10 +1270,9 @@ local function CollectEntries() end -- Top-level + subcategory entries from the live registry. Optional: - -- if Settings.GetCategoryList isn't ready yet, the curated entries - -- above are still present. - if not Settings or not Settings.GetCategoryList then return entries end - local list = Settings.GetCategoryList() + -- if the categories aren't ready yet, the curated entries above are + -- still present. + local list = GetSettingsCategoryList() if type(list) ~= "table" then return entries end local function addCategoryEntry(cat, parentName) @@ -948,7 +1357,11 @@ local function CollectKeybindings() settingType = "keybind", bindingAction = action, steps = { - { settingsCategory = "Keybindings" }, + { + settingsCategory = "Keybindings", + bindingAction = action, + bindingHeader = currentHeader, + }, }, }) end @@ -993,7 +1406,7 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) -- Slider initializers expose a SliderOptions table on -- init.data.options; checkbox initializers have a boolean -- variable type; everything else falls into "dropdown". - local resolvedType, sMin, sMax, sStep + local resolvedType, sMin, sMax, sStep, sFmt local d = init.data local opts = (type(d) == "table") and d.options or nil if type(opts) == "function" then @@ -1008,6 +1421,22 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) sMin = opts.minValue sMax = opts.maxValue sStep = opts.steps or opts.stepSize or 1 + -- Slider display value goes through Blizzard's + -- formatter (uiScale 0.64 -> "64%", etc.). Labels + -- are keyed by MinimalSliderWithSteppersMixin.Label + -- (Left=0, Right=1, Top=2, Min=3, Max=4); Top + -- mirrors the live value above the thumb best. + if type(opts.formatters) == "table" then + sFmt = opts.formatters[2] or opts.formatters[1] + or opts.formatters[0] or opts.formatters.Top + or opts.formatters.Right + if type(sFmt) ~= "function" then + sFmt = nil + for _, fn in pairs(opts.formatters) do + if type(fn) == "function" then sFmt = fn; break end + end + end + end elseif setting.GetVariableType then local tok, vtype = pcall(setting.GetVariableType, setting) if tok and vtype == "boolean" then @@ -1045,6 +1474,7 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) settingMin = sMin, settingMax = sMax, settingStep = sStep, + settingFormatter = sFmt, settingOptions = settingOptions, steps = { { @@ -1221,6 +1651,10 @@ function BlizzOptionsSearch:HandleStep(step) if step.settingVariable then SafeAfter(0, function() ScrollToSettingVariable(step.settingVariable) end) SafeAfter(0.1, function() ScrollToSettingVariable(step.settingVariable) end) + elseif step.bindingAction then + local header = step.bindingHeader + SafeAfter(0, function() ScrollToBindingAction(step.bindingAction, header) end) + SafeAfter(0.15, function() ScrollToBindingAction(step.bindingAction, header) end) end return catID ~= nil From 1c344c2960e1b4a8e92fffbf4ad82176f549ce19 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 00:56:43 -0400 Subject: [PATCH 068/103] UI: settings row click routing, inline Apply ext, slider polish Click routing: - ActivateSettingResult: checkboxes still toggle inline; sliders / keybinds / dropdowns / unknown open the in-game Settings panel (previously the keybind row no-op'd). Ctrl+click on a checkbox row also opens the panel. - Drop the keybind early-return; inline kb1/kb2 buttons consume their own clicks, so the row click reaching the handler means the user clicked the label area and wants to navigate. - GetActionHint subtext now reads "Select to open settings menu" for every non-checkbox setting; checkbox subtext gains the "Ctrl+click to open settings menu" pipe-suffix. - Keybind buttons now own a real GameTooltip explaining "Click then press a key combination. Right-click to clear" instead of mutating the row's pathSubtext. WriteSettingVariable hardening: - Coerce the value to the setting's declared variable type (number/string/boolean) before SetValue: passing "1" to a number setting was a silent no-op that looked like a flicker. - Verify by reading GetValue back; if it doesn't match what we wrote, fall through to the CVar path. - After a successful Setting:SetValue, NotePendingApply tells BlizzOptionsSearch about the pendingValue stage so the per-row apply ext appears. Slider: - Display value goes through Blizzard's SliderOptions.formatters[Top] function (lazy-resolved from the live registry for curated rows), accepting both string and number returns. - OnValueChanged also calls NotePendingApply for Apply-flagged settings so the apply ext can light up. - HookScript("OnMouseUp") triggers a single RefreshResults on drag-release so the ext appears once per drag instead of per-tick. Per-row Apply/Reset extension: - New settingApplyExt frame on each result row, anchored just below the row's BOTTOMLEFT so the row's own contents (icon, name, inline editors) don't shift -- only the y-cursor advances to make room. - Thin yellow separator + centered Reset / Apply buttons. Apply uses SetValue(value, true); Reset uses Setting:Revert. Both refresh results so the row reflects the new state. - Shown when HasPendingChange returns true (CommitFlag.Apply settings only); hidden otherwise. --- UI.lua | 285 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 224 insertions(+), 61 deletions(-) diff --git a/UI.lua b/UI.lua index 8027a24..b3a4b73 100644 --- a/UI.lua +++ b/UI.lua @@ -401,14 +401,11 @@ local function GetActionHint(data) if data.encounterID and data.category == "Boss" then return "Select to open Encounter Journal" end - if data.settingType == "dropdown" and data.settingVariable then - return "Select to open setting" - end if data.settingType == "checkbox" and data.settingVariable then - return "Select to toggle" + return "Select to toggle | Ctrl+click to open settings menu" end - if data.settingType == "keybind" and data.bindingAction then - return "Hover a slot then click to rebind" + if data.settingVariable or data.bindingAction then + return "Select to open settings menu" end return nil end @@ -4493,7 +4490,49 @@ local function WriteSettingVariable(variable, value) if Settings and Settings.GetSetting then local sok, settObj = pcall(Settings.GetSetting, variable) if sok and settObj and settObj.SetValue then - if pcall(settObj.SetValue, settObj, value) then return true end + -- Coerce to the setting's declared variable type before + -- writing. Type mismatches (e.g. passing "1" to a number + -- setting) make Setting:SetValue silently no-op, which + -- looks like a flicker on our row: pcall succeeds but + -- the underlying value never changes. + local writeValue = value + if settObj.GetVariableType then + local tok, vtype = pcall(settObj.GetVariableType, settObj) + if tok and type(vtype) == "string" then + if vtype == "number" then + writeValue = tonumber(value) or value + elseif vtype == "string" then + writeValue = tostring(value) + elseif vtype == "boolean" then + if type(value) == "boolean" then + writeValue = value + elseif value == "1" or value == 1 or value == "true" then + writeValue = true + elseif value == "0" or value == 0 or value == "false" then + writeValue = false + end + end + end + end + if pcall(settObj.SetValue, settObj, writeValue) then + -- Settings flagged with CommitFlag.Apply stage to + -- pendingValue (graphics, resolution, etc.) and need + -- the user to commit. Tell BlizzOptionsSearch so the + -- floating Apply/Revert bar can surface the change. + if ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.NotePendingApply then + ns.BlizzOptionsSearch:NotePendingApply(variable) + end + -- Verify: SetValue can succeed on the call but reject + -- the value internally. Read back to confirm it took. + if settObj.GetValue then + local rok, raw = pcall(settObj.GetValue, settObj) + if rok and (raw == writeValue or tostring(raw) == tostring(writeValue)) then + return true + end + else + return true + end + end end end -- CVar fallback for raw CVars not registered with the Settings panel. @@ -4511,20 +4550,19 @@ local function WriteSettingVariable(variable, value) return false end -local function ActivateSettingResult(data) +local function ActivateSettingResult(data, ctrlHeld) if not data or not data.settingVariable then return false end local stype = data.settingType - if stype == "checkbox" then + if stype == "checkbox" and not ctrlHeld then + -- Plain click toggles inline. Ctrl+click falls through to open + -- the in-game Settings panel for the same variable. UI:ToggleSettingCheckbox(data) - elseif stype == "slider" then - RefocusSearchEditBox() - elseif stype == "dropdown" then - -- Dropdown row click opens the Settings panel for that variable. - -- Inline cycling has its own controls (paddle arrows, center - -- popup) right on the row, so the row click itself shouldn't - -- duplicate that behavior. - UI:OpenSettingNoClose(data) else + -- Slider / keybind / dropdown / unknown: open the Settings + -- panel for that variable. Inline editors (slider drag, kb1/kb2 + -- capture, dropdown paddles) sit on top of the row and consume + -- their own clicks, so the row click reaching us means the user + -- clicked the label area and wants to navigate to the setting. UI:OpenSettingNoClose(data) end return true @@ -4837,7 +4875,15 @@ function UI:CreateResultButton(index) if Settings and Settings.GetSetting then local sok, settObj = pcall(Settings.GetSetting, variable) if sok and settObj and settObj.SetValue then - if pcall(settObj.SetValue, settObj, newVal) then return end + if pcall(settObj.SetValue, settObj, newVal) then + -- Slider drag goes through here, not WriteSettingVariable, + -- so trigger the same Apply-flag tracking so the per-row + -- apply ext appears. + if ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.NotePendingApply then + ns.BlizzOptionsSearch:NotePendingApply(variable) + end + return + end end end if SetCVar then @@ -4909,16 +4955,40 @@ function UI:CreateResultButton(index) if self._updating then return end applySettingValue(self._settingVar, newVal) local valText = resultRow.settingSliderValue - if valText then - if newVal == mfloor(newVal) then - valText:SetText(tostring(mfloor(newVal))) - else - valText:SetText(sformat("%.2f", newVal)) - end - end + if not valText then return end + local fmt = self._settingFormatter + if not fmt and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetFormatterForVariable then + fmt = ns.BlizzOptionsSearch.GetFormatterForVariable(self._settingVar) + if fmt then self._settingFormatter = fmt end + end + local displayVal + if fmt then + local fok, formatted = pcall(fmt, newVal) + if fok and formatted ~= nil then + local ft = type(formatted) + if ft == "string" and formatted ~= "" then + displayVal = formatted + elseif ft == "number" then + displayVal = (formatted == mfloor(formatted)) + and tostring(mfloor(formatted)) + or sformat("%.2f", formatted) + end + end + end + if not displayVal then + displayVal = (newVal == mfloor(newVal)) + and tostring(mfloor(newVal)) + or sformat("%.2f", newVal) + end + valText:SetText(displayVal) end) resultRow.settingSlider = settingSlider + -- Refresh once on drag-release so the per-row apply ext appears for + -- Apply-flagged sliders. OnValueChanged fires per-tick during drag, + -- which would be too expensive to refresh on; OnMouseUp fires once. + settingSlider:HookScript("OnMouseUp", function() UI:RefreshResults() end) + stepBack:SetScript("OnClick", function() local slider = resultRow.settingSlider if not slider:IsShown() then return end @@ -5101,23 +5171,20 @@ function UI:CreateResultButton(index) kb2:SetScript("OnClick", MakeBindingClickHandler(2)) -- Hovering a kb button overrides the row's action-hint subtext with a - -- slot-specific hint ("primary" vs "alternate") so the user knows - -- which key the next press will bind. OnLeave hands back to the row. + -- Per-slot GameTooltip explaining the rebind workflow. Lives on the + -- kb buttons themselves so the row's subtext stays focused on what + -- clicking the row does ("Select to open settings menu"). local function MakeKbHoverHandler(slotLabel) return function(self) - if not resultRow.pathSubtext or not resultRow.pathSubtext:IsShown() then return end - resultRow.pathSubtext:SetText("Click then press a key to bind " .. slotLabel) - resultRow.pathSubtext:SetTextColor(0.85, 0.78, 0.55, 1.0) + GameTooltip:SetOwner(self, "ANCHOR_TOP") + GameTooltip:SetText("Bind " .. slotLabel .. " key", 1, 1, 1) + GameTooltip:AddLine("Click then press a key combination.", 0.85, 0.78, 0.55, true) + GameTooltip:AddLine("Right-click to clear.", 0.7, 0.7, 0.7, true) + GameTooltip:Show() end end - local function KbLeaveHandler(self) - if not resultRow.pathSubtext or not resultRow.pathSubtext:IsShown() then return end - if resultRow:IsMouseOver() then - ApplyActionHint(resultRow) - else - resultRow.pathSubtext:SetText(GetFlatSubtext(resultRow.data)) - resultRow.pathSubtext:SetTextColor(0.55, 0.55, 0.55, 1.0) - end + local function KbLeaveHandler() + GameTooltip:Hide() end kb1:HookScript("OnEnter", MakeKbHoverHandler("primary")) kb1:HookScript("OnLeave", KbLeaveHandler) @@ -5258,6 +5325,49 @@ function UI:CreateResultButton(index) settingSliderValue:SetShadowOffset(1, -1) resultRow.settingSliderValue = settingSliderValue + -- Per-row Apply / Reset section. Settings flagged with + -- CommitFlag.Apply (graphics, resolution, etc.) stage their value + -- to setting.pendingValue instead of writing through; the row grows + -- to expose the buttons inline so the change can be committed + -- without leaving the search. + local APPLY_EXT_H = 22 + local applyExt = CreateFrame("Frame", nil, resultRow) + applyExt:SetHeight(APPLY_EXT_H) + applyExt:SetPoint("TOPLEFT", resultRow, "BOTTOMLEFT", 6, -2) + applyExt:SetPoint("TOPRIGHT", resultRow, "BOTTOMRIGHT", -6, -2) + applyExt:Hide() + + local applyExtSep = applyExt:CreateTexture(nil, "ARTWORK") + applyExtSep:SetColorTexture(0.85, 0.78, 0.55, 0.55) + applyExtSep:SetHeight(1) + applyExtSep:SetPoint("TOPLEFT", applyExt, "TOPLEFT", 0, 0) + applyExtSep:SetPoint("TOPRIGHT", applyExt, "TOPRIGHT", 0, 0) + + local resetBtn = CreateFrame("Button", nil, applyExt, "UIPanelButtonTemplate") + resetBtn:SetSize(58, 18) + resetBtn:SetText("Reset") + resetBtn:SetPoint("RIGHT", applyExt, "CENTER", -2, -2) + local applyBtn = CreateFrame("Button", nil, applyExt, "UIPanelButtonTemplate") + applyBtn:SetSize(58, 18) + applyBtn:SetText("Apply") + applyBtn:SetPoint("LEFT", applyExt, "CENTER", 2, -2) + applyBtn:SetScript("OnClick", function() + local v = resultRow.data and resultRow.data.settingVariable + if v and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.ApplyVariable then + ns.BlizzOptionsSearch:ApplyVariable(v) + UI:RefreshResults() + end + end) + resetBtn:SetScript("OnClick", function() + local v = resultRow.data and resultRow.data.settingVariable + if v and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.RevertVariable then + ns.BlizzOptionsSearch:RevertVariable(v) + UI:RefreshResults() + end + end) + resultRow.settingApplyExt = applyExt + resultRow.settingApplyExtH = APPLY_EXT_H + -- Right-aligned reputation standing bar -- Structure: repBar (dark bg + border) → repClip (clips fill) → repFillFrame (colored, same shape) -- repBar → repTextOverlay (text on top of everything) @@ -5626,24 +5736,13 @@ function UI:CreateResultButton(index) return end - -- Setting click: keep the search panel open so the user can - -- adjust multiple settings, retest, retoggle, etc. without - -- having to reopen the search. - -- Checkbox: toggle inline (no need to open the panel at all). - -- Slider: the inline slider widget IS the editor; row click - -- is a no-op so dragging the slider doesn't also fire a - -- row click that opens the panel. - -- Dropdown / other: open the Settings panel for editing but - -- leave the search results visible underneath. - if self.data and self.data.settingType == "keybind" and self.data.bindingAction then - -- Keybind row: the inline kb1/kb2 buttons own the click and - -- manage their own keyboard capture (via blockFocus on the - -- editbox). A bare row click does nothing here; refocusing - -- the editbox would clear blockFocus mid-capture and hand - -- the next keystroke to the search bar instead of the bind. - return - end - if ActivateSettingResult(self.data) then return end + -- Setting click. Checkbox: toggle inline (Ctrl+click opens the + -- panel). Everything else (slider / keybind / dropdown): open + -- the panel so the user lands on the setting they searched for. + -- Inline editors (slider drag, kb1/kb2 capture, dropdown + -- paddles) sit on top of the row and consume their own clicks, + -- so reaching this handler means the user clicked the label. + if ActivateSettingResult(self.data, IsControlKeyDown()) then return end if self.isPinHeader then return @@ -5789,7 +5888,26 @@ function UI:CreateResultButton(index) local n = tonumber(cur) if n then GameTooltip:AddLine(" ") - local valStr = (n == mfloor(n)) and tostring(mfloor(n)) or sformat("%.2f", n) + if not self.data.settingFormatter + and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetFormatterForVariable then + local fmt = ns.BlizzOptionsSearch.GetFormatterForVariable(var) + if fmt then self.data.settingFormatter = fmt end + end + local valStr + if self.data.settingFormatter then + local fok, f = pcall(self.data.settingFormatter, n) + if fok and f ~= nil then + local ft = type(f) + if ft == "string" and f ~= "" then + valStr = f + elseif ft == "number" then + valStr = (f == mfloor(f)) and tostring(mfloor(f)) or sformat("%.2f", f) + end + end + end + if not valStr then + valStr = (n == mfloor(n)) and tostring(mfloor(n)) or sformat("%.2f", n) + end GameTooltip:AddLine(sformat("Current: %s (%s - %s)", valStr, tostring(self.data.settingMin), @@ -7674,6 +7792,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if sMax <= sMin then sMax = sMin + 1 end local slider = resultRow.settingSlider slider._settingVar = data.settingVariable + slider._settingFormatter = data.settingFormatter slider._updating = true slider:SetMinMaxValues(sMin, sMax) slider:SetValueStep(stepVal) @@ -7681,11 +7800,35 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) slider._updating = false resultRow.settingSliderGroup:Show() + -- Curated SETTINGS_DATA rows don't ship with a + -- formatter; pull from the live registry on demand + -- so the inline value matches Blizzard's panel + -- (Mouse Look Speed raw 180 -> displayed "5.5"). + if not data.settingFormatter + and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetFormatterForVariable then + local fmt = ns.BlizzOptionsSearch.GetFormatterForVariable(data.settingVariable) + if fmt then data.settingFormatter = fmt end + end local displayVal - if numVal == mfloor(numVal) then - displayVal = tostring(mfloor(numVal)) - else - displayVal = sformat("%.2f", numVal) + if data.settingFormatter then + local fok, formatted = pcall(data.settingFormatter, numVal) + if fok and formatted ~= nil then + local ft = type(formatted) + if ft == "string" and formatted ~= "" then + displayVal = formatted + elseif ft == "number" then + displayVal = (formatted == mfloor(formatted)) + and tostring(mfloor(formatted)) + or sformat("%.2f", formatted) + end + end + end + if not displayVal then + if numVal == mfloor(numVal) then + displayVal = tostring(mfloor(numVal)) + else + displayVal = sformat("%.2f", numVal) + end end resultRow.settingSliderValue:SetText(displayVal) @@ -7883,6 +8026,21 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end end + -- Per-row Apply / Reset extension for settings that staged + -- a pendingValue (CommitFlag.Apply -- graphics, resolution). + -- The extension sits BELOW the row as a separate visual + -- element so the row's own contents (icon, name, inline + -- editors) don't shift; only the y-cursor below advances to + -- make room. + local hasPendingApply = false + if data and data.settingVariable and ns.BlizzOptionsSearch + and ns.BlizzOptionsSearch.HasPendingChange then + hasPendingApply = ns.BlizzOptionsSearch:HasPendingChange(data.settingVariable) + end + if resultRow.settingApplyExt then + resultRow.settingApplyExt:SetShown(hasPendingApply) + 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() @@ -7933,6 +8091,11 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end end + -- Reserve y-cursor space for the apply extension (sits below + -- the row at -2 offset). Row's own bounds are unchanged. + if hasPendingApply and resultRow.settingApplyExtH then + actualH = actualH + resultRow.settingApplyExtH + 2 + end yOffset = yOffset + actualH resultRow:Show() elseif resultRow then From a495702e2f2f0a0767d8c28d2d78b8338bd99771 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 01:15:42 -0400 Subject: [PATCH 069/103] Settings: timed-confirmation popup for Revertable settings Settings flagged Revertable (monitor, resolution, display mode) need the GAME_SETTINGS_TIMED_CONFIRMATION popup so the user has an escape hatch when a change leaves the screen unusable. Our direct SetValue(immediate=true) path skipped Blizzard's CommitSettings pipeline that fires the popup. Detect the Revertable flag and route those through SettingsPanel:CommitSettings(false) instead, pushing the parent (and any staged dependents from PROXY_DEPENDENTS) into SettingsPanel.modified first so Blizzard's pipeline picks them up. Lift StaticPopup1 to TOOLTIP strata when the popup fires (resultsFrame is FULLSCREEN_DIALOG which would otherwise cover the DIALOG-strata popup). Restore on OnHide via a one-shot hook so other StaticPopup1 reuses aren't affected. --- BlizzOptionsSearch.lua | 61 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index db54922..220dbd0 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -849,6 +849,14 @@ local function CommitStagedDependents(parentVar) end end +local function HasFlag(settObj, flag) + if not settObj.HasCommitFlag or not Settings or not Settings.CommitFlag or not flag then + return false + end + local ok, has = pcall(settObj.HasCommitFlag, settObj, flag) + return ok and has +end + function BlizzOptionsSearch:ApplyVariable(variable) if not variable or not Settings or not Settings.GetSetting then return end local sok, settObj = pcall(Settings.GetSetting, variable) @@ -856,6 +864,59 @@ function BlizzOptionsSearch:ApplyVariable(variable) local pending = settObj.pendingValue local depsFn = PROXY_DEPENDENT_DEFAULTS[variable] if depsFn then pcall(depsFn, pending) end + + -- Settings flagged Revertable (monitor, resolution, display mode) + -- need Blizzard's CommitSettings pipeline so the + -- GAME_SETTINGS_TIMED_CONFIRMATION popup ("Accept new options? + -- Reverting in 8 seconds.") fires. The popup is the user's only + -- escape hatch when a change leaves the screen unusable. Our + -- direct SetValue(immediate=true) path skips that, so route + -- through Blizzard for these specifically. Push parent + any + -- staged dependents into SettingsPanel.modified first. + local revertable = HasFlag(settObj, Settings.CommitFlag.Revertable) + if revertable and SettingsPanel and SettingsPanel.CommitSettings + and SettingsPanel.modified then + SettingsPanel.modified[settObj] = settObj + local deps = PROXY_DEPENDENTS[variable] + if deps then + for i = 1, #deps do + local _, depObj = pcall(Settings.GetSetting, deps[i]) + if depObj and depObj.pendingValue ~= nil then + SettingsPanel.modified[depObj] = depObj + end + end + end + pcall(SettingsPanel.CommitSettings, SettingsPanel, false) + -- Lift the timed-confirmation popup above our search panel + -- (resultsFrame is FULLSCREEN_DIALOG; StaticPopup1 defaults to + -- DIALOG which renders behind us). Hook OnHide once to restore + -- so other StaticPopup1 uses aren't affected. + local popup = _G["StaticPopup1"] + if popup and popup:IsShown() and not popup._easyFindStrataLifted then + popup._easyFindStrataLifted = true + popup._easyFindOriginalStrata = popup:GetFrameStrata() + popup:SetFrameStrata("TOOLTIP") + popup:HookScript("OnHide", function(self) + if self._easyFindStrataLifted then + if self._easyFindOriginalStrata then + self:SetFrameStrata(self._easyFindOriginalStrata) + end + self._easyFindStrataLifted = nil + self._easyFindOriginalStrata = nil + end + end) + end + pendingApplySettings[settObj] = nil + if deps then + for i = 1, #deps do + local _, depObj = pcall(Settings.GetSetting, deps[i]) + if depObj then pendingApplySettings[depObj] = nil end + end + end + FirePendingChanged() + return + end + if settObj.SetValue then pcall(settObj.SetValue, settObj, pending, true) end From 26674e9b89ccf6e6f6d248bef1392fcd7f73f70f Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 02:30:55 -0400 Subject: [PATCH 070/103] Settings: combined cb+slider rows, broader walk, unapplied popup Settings panel parity work: Game category walker - WalkCategorySettings now also handles game (non-AddOn) categories via a new CollectGameSettings pass, so Use UI Scale and other graphics-tab settings the curated SETTINGS_DATA list doesn't cover show up in search. Skips variables already in the curated set so there are no duplicates. - inspectInit detects CreateSettingsCheckboxSliderInitializer (init.data.cbSetting + sliderSetting) and emits a single combined entry instead of two separate ones, mirroring Blizzard's row. - Slider step is now (max - min) / opts.steps. Settings.CreateSliderOptions stores opts.steps as the COUNT of steps across the range, not the per-tick delta; treating it as a delta gave Use UI Scale a step of 50 and made the paddle arrows jump to extremes. checkboxSlider rendering - New settingType = "checkboxSlider" with cbVariable + sliderVariable fields. The renderer shows the checkbox state on the right plus the slider group anchored to its left, so both fit inline. Click the row to toggle the checkbox; drag the slider to edit. The slider branch resets the slider group's anchor to its default to avoid carrying over the checkboxSlider position. - ActivateSettingResult and GetActionHint route checkboxSlider through the same toggle-on-click / Ctrl+click-opens-panel logic as plain checkboxes. - Per-row apply ext now also checks data.sliderVariable for pending state, and Apply/Reset buttons operate on both the cb and slider variables when the row is a combined widget. Unapplied-changes confirmation - New EASYFIND_UNAPPLIED_SETTINGS static popup mirrors Blizzard's GAME_SETTINGS_CONFIRM_DISCARD: Exit / Apply and Exit / Cancel. RequestHideResults gates the user-close paths (click-outside-to-close, ESC) on this popup when there are pending Apply-flagged changes; internal HideResults callers (no-results refresh, etc.) skip it so typing in the search box doesn't re-trigger the popup. RefocusSearchEditBox runs on popup OnHide. - Apply and Exit pushes pending settings into SettingsPanel.modified and runs Blizzard's CommitSettings(false) so Revertable settings (monitor, resolution) get the GAME_SETTINGS_TIMED_CONFIRMATION countdown popup, just like the per-row Apply path. - FindStaticPopupSlot walks all four StaticPopup* slots so the timed-confirmation popup gets lifted above our results frame even when StaticPopup1 is busy with the unapplied-changes popup. - LiftPopupAndRefresh helper consolidates strata lift + RefreshResults on hide so the row reflects revert/timeout outcomes instead of showing the staged-but-reverted value. --- BlizzOptionsSearch.lua | 283 +++++++++++++++++++++++++++++++++-------- UI.lua | 159 ++++++++++++++++++++--- 2 files changed, 374 insertions(+), 68 deletions(-) diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index 220dbd0..1c06830 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -739,37 +739,48 @@ function BlizzOptionsSearch:NotePendingApply(variable) end end --- Apply all pending changes at once. Mirrors SettingsPanel:CommitSettings --- so secondary effects (gx restart, window update, save bindings) fire --- once for the batch instead of per-setting. -function BlizzOptionsSearch:ApplyPendingChanges() - if not next(pendingApplySettings) then return end - if not Settings or not Settings.CommitFlag then return end - - local list = {} - for setting in pairs(pendingApplySettings) do - if setting.LockPendingValue then pcall(setting.LockPendingValue, setting) end - list[#list + 1] = setting +-- Find the StaticPopup slot currently displaying the named popup. +-- Blizzard reuses StaticPopup1..4 across all popups; when one is busy +-- (e.g., our unapplied-changes popup), the next StaticPopup_Show lands +-- in a different slot. Walk all slots to find the one we want. +local function FindStaticPopupSlot(popupName) + for i = 1, 4 do + local p = _G["StaticPopup" .. i] + if p and p:IsShown() and p.which == popupName then return p end end - table.sort(list, function(a, b) - local oa = a.GetCommitOrder and a:GetCommitOrder() or 0 - local ob = b.GetCommitOrder and b:GetCommitOrder() or 0 - return oa < ob + return nil +end + +local function LiftPopupAndRefresh(popup) + if not popup or popup._easyFindStrataLifted then return end + popup._easyFindStrataLifted = true + popup._easyFindOriginalStrata = popup:GetFrameStrata() + popup:SetFrameStrata("TOOLTIP") + popup:HookScript("OnHide", function(self) + if self._easyFindStrataLifted then + if self._easyFindOriginalStrata then + self:SetFrameStrata(self._easyFindOriginalStrata) + end + self._easyFindStrataLifted = nil + self._easyFindOriginalStrata = nil + end + if ns.UI and ns.UI.RefreshResults then ns.UI:RefreshResults() end end) - local saveBindings, gxRestart, windowUpdate = false, false, false - for i = 1, #list do - local s = list[i] - if s.HasCommitFlag then - saveBindings = saveBindings or (pcall(s.HasCommitFlag, s, Settings.CommitFlag.SaveBindings) and s:HasCommitFlag(Settings.CommitFlag.SaveBindings)) - gxRestart = gxRestart or (pcall(s.HasCommitFlag, s, Settings.CommitFlag.GxRestart) and s:HasCommitFlag(Settings.CommitFlag.GxRestart)) - windowUpdate= windowUpdate or (pcall(s.HasCommitFlag, s, Settings.CommitFlag.UpdateWindow) and s:HasCommitFlag(Settings.CommitFlag.UpdateWindow)) +end + +-- Apply all pending changes at once. Push them into SettingsPanel.modified +-- and run its CommitSettings(false) so Blizzard's full pipeline fires: +-- gx restart / window update / save bindings AND the +-- GAME_SETTINGS_TIMED_CONFIRMATION popup for any Revertable settings +-- in the batch (monitor, resolution, etc.). +function BlizzOptionsSearch:ApplyPendingChanges() + if not next(pendingApplySettings) then return end + if SettingsPanel and SettingsPanel.modified and SettingsPanel.CommitSettings then + for setting in pairs(pendingApplySettings) do + SettingsPanel.modified[setting] = setting end - if s.Commit then pcall(s.Commit, s) end - end - if gxRestart and RestartGx then pcall(RestartGx) end - if windowUpdate and UpdateWindow then pcall(UpdateWindow) end - if saveBindings and SaveBindings and GetCurrentBindingSet then - pcall(SaveBindings, GetCurrentBindingSet()) + pcall(SettingsPanel.CommitSettings, SettingsPanel, false) + LiftPopupAndRefresh(FindStaticPopupSlot("GAME_SETTINGS_TIMED_CONFIRMATION")) end wipe(pendingApplySettings) FirePendingChanged() @@ -887,25 +898,7 @@ function BlizzOptionsSearch:ApplyVariable(variable) end end pcall(SettingsPanel.CommitSettings, SettingsPanel, false) - -- Lift the timed-confirmation popup above our search panel - -- (resultsFrame is FULLSCREEN_DIALOG; StaticPopup1 defaults to - -- DIALOG which renders behind us). Hook OnHide once to restore - -- so other StaticPopup1 uses aren't affected. - local popup = _G["StaticPopup1"] - if popup and popup:IsShown() and not popup._easyFindStrataLifted then - popup._easyFindStrataLifted = true - popup._easyFindOriginalStrata = popup:GetFrameStrata() - popup:SetFrameStrata("TOOLTIP") - popup:HookScript("OnHide", function(self) - if self._easyFindStrataLifted then - if self._easyFindOriginalStrata then - self:SetFrameStrata(self._easyFindOriginalStrata) - end - self._easyFindStrataLifted = nil - self._easyFindOriginalStrata = nil - end - end) - end + LiftPopupAndRefresh(FindStaticPopupSlot("GAME_SETTINGS_TIMED_CONFIRMATION")) pendingApplySettings[settObj] = nil if deps then for i = 1, #deps do @@ -928,6 +921,45 @@ function BlizzOptionsSearch:ApplyVariable(variable) FirePendingChanged() end +-- Mirrors Blizzard's GAME_SETTINGS_CONFIRM_DISCARD popup so closing +-- our search panel with pending Apply-flagged settings prompts the +-- same three-button choice. Defined once at file load; the StaticPopup +-- registry keeps it across reloads. +StaticPopupDialogs = StaticPopupDialogs or {} +if not StaticPopupDialogs["EASYFIND_UNAPPLIED_SETTINGS"] then + StaticPopupDialogs["EASYFIND_UNAPPLIED_SETTINGS"] = { + text = SETTINGS_CONFIRM_DISCARD or + "You have settings that have not been applied.\nAre you sure you wish to exit?", + button1 = SETTINGS_UNAPPLIED_EXIT or "Exit", + button2 = SETTINGS_UNAPPLIED_APPLY_AND_EXIT or "Apply and Exit", + button3 = SETTINGS_UNAPPLIED_CANCEL or "Cancel", + OnButton1 = function() + if BlizzOptionsSearch.RevertPendingChanges then + BlizzOptionsSearch:RevertPendingChanges() + end + if ns.UI and ns.UI.HideResults then ns.UI:HideResults() end + end, + OnButton2 = function() + if BlizzOptionsSearch.ApplyPendingChanges then + BlizzOptionsSearch:ApplyPendingChanges() + end + if ns.UI and ns.UI.HideResults then ns.UI:HideResults() end + end, + OnButton3 = function() end, + OnHide = function() + -- Restore search bar focus when the popup dismisses, no + -- matter the path (button click, ESC, or click-away). + if ns.UI and ns.UI.RefocusSearchEditBox then + ns.UI:RefocusSearchEditBox() + end + end, + selectCallbackByIndex = true, + hideOnEscape = 1, + whileDead = 1, + fullScreenCover = true, + } +end + function BlizzOptionsSearch:RevertVariable(variable) if not variable or not Settings or not Settings.GetSetting then return end local sok, settObj = pcall(Settings.GetSetting, variable) @@ -1447,18 +1479,97 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) local iok, inits = pcall(layout.GetInitializers, layout) if not iok or not inits then return out end - for _, init in ipairs(inits) do + -- Combined initializers from CreateSettingsCheckboxSliderInitializer + -- ("Use UI Scale" with the checkbox + companion slider in one row) + -- bundle two settings into one initializer at init.data.cbSetting + + -- init.data.sliderSetting. We emit a single "checkboxSlider" entry + -- so the row mirrors Blizzard's combined widget instead of showing + -- two separate searchable rows. + local function inspectInit(init) + local d = init.data + if type(d) == "table" and d.cbSetting and d.sliderSetting then + return { combined = true, init = init } + end local setting if init.GetSetting then local sok, s = pcall(init.GetSetting, init) if sok then setting = s end end - if not setting and init.data then setting = init.data.setting end + if not setting and d then setting = d.setting end if not setting and init.GetData then - local dok, d = pcall(init.GetData, init) - if dok and d then setting = d.setting end + local dok, dd = pcall(init.GetData, init) + if dok and dd then setting = dd.setting end end + return { setting = setting, init = init } + end + for _, init in ipairs(inits) do + local info = inspectInit(init) + if info.combined then + local d = init.data + local cb, sl = d.cbSetting, d.sliderSetting + local cvok, cvar = pcall(cb.GetVariable, cb) + local svok, svar = pcall(sl.GetVariable, sl) + local label = d.cbLabel or d.sliderLabel + if cvok and svok and cvar and svar and label and label ~= "" then + local opts = d.sliderOptions + if type(opts) == "function" then + local ook, o = pcall(opts, sl) + if ook then opts = o end + end + local sMin, sMax, sStep, sFmt + if type(opts) == "table" then + sMin = opts.minValue + sMax = opts.maxValue + -- Settings.CreateSliderOptions stores steps as the + -- NUMBER OF STEPS across the range, not the per-tick + -- delta. Convert to step size: (max - min) / steps. + if opts.stepSize then + sStep = opts.stepSize + elseif opts.steps and opts.steps > 0 and sMax > sMin then + sStep = (sMax - sMin) / opts.steps + else + sStep = 1 + end + if type(opts.formatters) == "table" then + sFmt = opts.formatters[2] or opts.formatters[1] + or opts.formatters[0] or opts.formatters.Top + or opts.formatters.Right + if type(sFmt) ~= "function" then + sFmt = nil + for _, fn in pairs(opts.formatters) do + if type(fn) == "function" then sFmt = fn; break end + end + end + end + end + local nameLower = slower(label) + local kw = { "addon", "setting", "option", nameLower, slower(catName or "") } + tinsert(out, { + name = label, + nameLower = nameLower, + keywords = kw, + keywordsLower = kw, + category = "AddOn Settings", + path = pathPrefix, + settingsCategory = catName, + settingCategoryID = catID, + settingVariable = cvar, + cbVariable = cvar, + sliderVariable = svar, + settingType = "checkboxSlider", + settingMin = sMin, + settingMax = sMax, + settingStep = sStep, + settingFormatter = sFmt, + steps = { + { settingsCategory = catName, settingCategoryID = catID, + settingVariable = cvar }, + }, + }) + end + else + local setting = info.setting if setting and setting.GetVariable then local vok, variable = pcall(setting.GetVariable, setting) local nok, settingName = pcall(setting.GetName, setting) @@ -1481,7 +1592,16 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) resolvedType = "slider" sMin = opts.minValue sMax = opts.maxValue - sStep = opts.steps or opts.stepSize or 1 + -- Settings.CreateSliderOptions stores steps as the + -- NUMBER OF STEPS across the range, not the per-tick + -- delta. Convert to step size: (max - min) / steps. + if opts.stepSize then + sStep = opts.stepSize + elseif opts.steps and opts.steps > 0 and sMax > sMin then + sStep = (sMax - sMin) / opts.steps + else + sStep = 1 + end -- Slider display value goes through Blizzard's -- formatter (uiScale 0.64 -> "64%", etc.). Labels -- are keyed by MinimalSliderWithSteppersMixin.Label @@ -1548,6 +1668,7 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) tinsert(out, entry) end end + end end return out end @@ -1659,6 +1780,60 @@ local function CollectAddonCategories() end BlizzOptionsSearch.CollectAddonCategories = CollectAddonCategories +-- Walk Game (non-AddOn) categories for individual settings the curated +-- SETTINGS_DATA list doesn't surface (Use UI Scale, vsync, etc.). The +-- live registry is authoritative for what's actually in the panel +-- this build, so this captures settings Blizzard added without us +-- touching the curated list. Skip variables already in SETTINGS_DATA +-- so we don't double-emit. +local function BuildCuratedVariableSet() + local set = {} + for i = 1, #SETTINGS_DATA do + local v = SETTINGS_DATA[i] and SETTINGS_DATA[i][2] + if v then set[v] = true end + end + return set +end + +local function CollectGameSettings() + local entries = {} + if not Settings then return entries end + local curated = BuildCuratedVariableSet() + local seenCatIDs = {} + local function emit(cat, parentName) + if not cat or not cat.GetName then return end + if IsAddonCategory(cat) then return end + local catID = cat.GetID and cat:GetID() + if not catID or seenCatIDs[catID] then return end + seenCatIDs[catID] = true + local catName = cat:GetName() + if not catName or catName == "" then return end + local pathPrefix = { "Game Settings", catName } + local inline = WalkCategorySettings(cat, catName, catID, pathPrefix) + for _, e in ipairs(inline) do + if not curated[e.settingVariable] then + e.category = "Game Settings" + tinsert(entries, e) + end + end + end + local list = GetSettingsCategoryList() + if type(list) == "table" then + for _, cat in ipairs(list) do + emit(cat, nil) + if cat.GetSubcategories then + local sok, subs = pcall(cat.GetSubcategories, cat) + if sok and type(subs) == "table" then + local parentName = cat.GetName and cat:GetName() + for _, sub in ipairs(subs) do emit(sub, parentName) end + end + end + end + end + return entries +end +BlizzOptionsSearch.CollectGameSettings = CollectGameSettings + -- Register the collected entries into the Database. Called once -- after PLAYER_LOGIN so Settings.* is fully populated. function BlizzOptionsSearch:Populate() @@ -1676,6 +1851,10 @@ function BlizzOptionsSearch:Populate() for i = 1, #addonEntries do tinsert(data, addonEntries[i]) end + local gameEntries = CollectGameSettings() + for i = 1, #gameEntries do + tinsert(data, gameEntries[i]) + end if ns.Database.ResetSearchCache then ns.Database:ResetSearchCache() end end diff --git a/UI.lua b/UI.lua index b3a4b73..db6ec43 100644 --- a/UI.lua +++ b/UI.lua @@ -401,7 +401,8 @@ local function GetActionHint(data) if data.encounterID and data.category == "Boss" then return "Select to open Encounter Journal" end - if data.settingType == "checkbox" and data.settingVariable then + if (data.settingType == "checkbox" or data.settingType == "checkboxSlider") + and data.settingVariable then return "Select to toggle | Ctrl+click to open settings menu" end if data.settingVariable or data.bindingAction then @@ -4232,7 +4233,7 @@ function UI:CreateResultsFrame() for _, g in ipairs(guards) do if Utils.IsFrameOrChildMouseOver(g) then return end end - UI:HideResults() + UI:RequestHideResults() end) local resizeTimer @@ -4467,6 +4468,7 @@ local function RefocusSearchEditBox() searchFrame.editBox:SetFocus() end end +function UI:RefocusSearchEditBox() RefocusSearchEditBox() end local function ReadSettingVariable(variable) if Settings and Settings.GetSetting then @@ -4553,9 +4555,11 @@ end local function ActivateSettingResult(data, ctrlHeld) if not data or not data.settingVariable then return false end local stype = data.settingType - if stype == "checkbox" and not ctrlHeld then + if (stype == "checkbox" or stype == "checkboxSlider") and not ctrlHeld then -- Plain click toggles inline. Ctrl+click falls through to open - -- the in-game Settings panel for the same variable. + -- the in-game Settings panel for the same variable. For + -- checkboxSlider, the cb variable lives at data.settingVariable + -- so the existing toggle path Just Works. UI:ToggleSettingCheckbox(data) else -- Slider / keybind / dropdown / unknown: open the Settings @@ -5351,19 +5355,28 @@ function UI:CreateResultButton(index) applyBtn:SetSize(58, 18) applyBtn:SetText("Apply") applyBtn:SetPoint("LEFT", applyExt, "CENTER", 2, -2) - applyBtn:SetScript("OnClick", function() - local v = resultRow.data and resultRow.data.settingVariable - if v and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.ApplyVariable then - ns.BlizzOptionsSearch:ApplyVariable(v) - UI:RefreshResults() + local function bothVars(d) + if not d then return nil, nil end + local primary = d.settingVariable + local secondary + if d.sliderVariable and d.sliderVariable ~= primary then + secondary = d.sliderVariable end + return primary, secondary + end + applyBtn:SetScript("OnClick", function() + if not ns.BlizzOptionsSearch or not ns.BlizzOptionsSearch.ApplyVariable then return end + local primary, secondary = bothVars(resultRow.data) + if primary then ns.BlizzOptionsSearch:ApplyVariable(primary) end + if secondary then ns.BlizzOptionsSearch:ApplyVariable(secondary) end + UI:RefreshResults() end) resetBtn:SetScript("OnClick", function() - local v = resultRow.data and resultRow.data.settingVariable - if v and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.RevertVariable then - ns.BlizzOptionsSearch:RevertVariable(v) - UI:RefreshResults() - end + if not ns.BlizzOptionsSearch or not ns.BlizzOptionsSearch.RevertVariable then return end + local primary, secondary = bothVars(resultRow.data) + if primary then ns.BlizzOptionsSearch:RevertVariable(primary) end + if secondary then ns.BlizzOptionsSearch:RevertVariable(secondary) end + UI:RefreshResults() end) resultRow.settingApplyExt = applyExt resultRow.settingApplyExtH = APPLY_EXT_H @@ -7749,7 +7762,80 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) elseif data and data.settingVariable and not entry.isPathNode then if resultRow.settingKeybindGroup then resultRow.settingKeybindGroup:Hide() end local settingType = data.settingType - if settingType == "checkbox" then + if settingType == "checkboxSlider" and data.cbVariable and data.sliderVariable then + -- Combined widget: checkbox state + slider in one row + -- (mirrors Blizzard's CreateSettingsCheckboxSliderInitializer + -- visual e.g. "Use UI Scale" with the % slider). + local isOn = false + if Settings and Settings.GetSetting then + local sok, cbObj = pcall(Settings.GetSetting, data.cbVariable) + if sok and cbObj and cbObj.GetValue then + local vok, v = pcall(cbObj.GetValue, cbObj) + if vok then isOn = (v == true or v == "1" or v == 1) end + end + end + resultRow.settingState:Show() + resultRow.settingCheck:SetShown(isOn) + resultRow.amountText:Hide() + if resultRow.settingDropdownGroup then resultRow.settingDropdownGroup:Hide() end + + local sliderVar = data.sliderVariable + local rawVal + if Settings and Settings.GetSetting then + local sok, sObj = pcall(Settings.GetSetting, sliderVar) + if sok and sObj and sObj.GetValue then + local vok, v = pcall(sObj.GetValue, sObj) + if vok then rawVal = v end + end + end + if rawVal == nil and GetCVar then rawVal = GetCVar(sliderVar) end + local numVal = tonumber(rawVal) or data.settingMin or 0 + local sMin = data.settingMin or 0 + local sMax = data.settingMax or 1 + local stepVal = data.settingStep or 1 + if sMax <= sMin then sMax = sMin + 1 end + local slider = resultRow.settingSlider + slider._settingVar = sliderVar + slider._settingFormatter = data.settingFormatter + slider._updating = true + slider:SetMinMaxValues(sMin, sMax) + slider:SetValueStep(stepVal) + slider:SetValue(numVal) + slider._updating = false + + if not data.settingFormatter + and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetFormatterForVariable then + local fmt = ns.BlizzOptionsSearch.GetFormatterForVariable(sliderVar) + if fmt then data.settingFormatter = fmt end + end + local displayVal + if data.settingFormatter then + local fok, formatted = pcall(data.settingFormatter, numVal) + if fok and formatted ~= nil then + local ft = type(formatted) + if ft == "string" and formatted ~= "" then displayVal = formatted + elseif ft == "number" then + displayVal = (formatted == mfloor(formatted)) + and tostring(mfloor(formatted)) + or sformat("%.2f", formatted) + end + end + end + if not displayVal then + displayVal = (numVal == mfloor(numVal)) + and tostring(mfloor(numVal)) + or sformat("%.2f", numVal) + end + resultRow.settingSliderValue:SetText(displayVal) + + -- Slider group sits to the LEFT of the checkbox + -- so both fit on the right side of the row. + resultRow.settingSliderGroup:ClearAllPoints() + resultRow.settingSliderGroup:SetPoint("RIGHT", resultRow.settingState, "LEFT", -6, 0) + resultRow.settingSliderGroup:Show() + + resultRow.text:SetPoint("RIGHT", resultRow.settingSliderGroup, "LEFT", -4, 0) + elseif settingType == "checkbox" then local isOn = false if Settings and Settings.GetSetting then local sok, settObj = pcall(Settings.GetSetting, data.settingVariable) @@ -7798,6 +7884,10 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) slider:SetValueStep(stepVal) slider:SetValue(numVal) slider._updating = false + -- Reset to default RIGHT anchor in case the previous + -- render of this row was a checkboxSlider entry. + resultRow.settingSliderGroup:ClearAllPoints() + resultRow.settingSliderGroup:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) resultRow.settingSliderGroup:Show() -- Curated SETTINGS_DATA rows don't ship with a @@ -8036,6 +8126,10 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if data and data.settingVariable and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.HasPendingChange then hasPendingApply = ns.BlizzOptionsSearch:HasPendingChange(data.settingVariable) + if not hasPendingApply and data.sliderVariable + and data.sliderVariable ~= data.settingVariable then + hasPendingApply = ns.BlizzOptionsSearch:HasPendingChange(data.sliderVariable) + end end if resultRow.settingApplyExt then resultRow.settingApplyExt:SetShown(hasPendingApply) @@ -8416,6 +8510,39 @@ function UI:CloseFilterDropdownIfOpen() return closedAny end +-- Explicit user-close request: shows the unapplied-changes popup if +-- there are pending Apply-flagged settings, otherwise hides directly. +-- Used by the click-outside-to-close watcher and ESC handlers; the +-- internal HideResults callers (no-results refresh, etc.) skip it. +function UI:RequestHideResults() + if resultsFrame and resultsFrame:IsShown() + and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetPendingApplyCount + and ns.BlizzOptionsSearch:GetPendingApplyCount() > 0 then + local alreadyShowing = StaticPopup_Visible + and StaticPopup_Visible("EASYFIND_UNAPPLIED_SETTINGS") + if not alreadyShowing and StaticPopup_Show then + StaticPopup_Show("EASYFIND_UNAPPLIED_SETTINGS") + end + local popup = _G["StaticPopup1"] + if popup and popup:IsShown() and not popup._easyFindStrataLifted then + popup._easyFindStrataLifted = true + popup._easyFindOriginalStrata = popup:GetFrameStrata() + popup:SetFrameStrata("TOOLTIP") + popup:HookScript("OnHide", function(self) + if self._easyFindStrataLifted then + if self._easyFindOriginalStrata then + self:SetFrameStrata(self._easyFindOriginalStrata) + end + self._easyFindStrataLifted = nil + self._easyFindOriginalStrata = nil + end + end) + end + return + end + self:HideResults() +end + function UI:HideResults() if not searchFrame then return end if activeKeybindBtn and activeKeybindBtn._stopCapture then @@ -11173,7 +11300,7 @@ function UI:HandleEscape() if editBox and editBox:GetText() ~= "" then editBox:SetText("") if editBox.placeholder then editBox.placeholder:Show() end - self:HideResults() + self:RequestHideResults() Refocus() return end From c2ef71a48bcaca0fb95e9680d187318e3c1aa139 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 03:16:53 -0400 Subject: [PATCH 071/103] Settings: surface graphics-quality orphans with inline dropdowns Shadow Quality, Liquid Detail, Particle Density, View Distance, Environment Detail and Ground Clutter (plus their raid variants) are registered as Settings but their option lists live in private closures inside SettingsAdvancedQualityControlsMixin (GetShadowQualityOptions etc.), so layout:GetInitializers and the live registry walk both miss them. The fallback registry pass now picks them up via SettingsPanel.settings, suffixes the raid variants with "(Raid)" so the duplicate display names disambiguate, and -- for the well-known graphics-quality variables -- attaches a hardcoded option list using the same VIDEO_OPTIONS_LOW / FAIR / MEDIUM / HIGH / ULTRA / ULTRA_HIGH labels Blizzard uses, filtered through IsGraphicsSettingValueSupported so unsupported quality levels for the player's hardware are dropped. Booleans render as inline checkbox toggles. Other unknown orphans fall through to "open Settings panel on click". --- BlizzOptionsSearch.lua | 131 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index 1c06830..0f9d498 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -1780,6 +1780,65 @@ local function CollectAddonCategories() end BlizzOptionsSearch.CollectAddonCategories = CollectAddonCategories +-- Hardcoded option lists for graphics-quality dropdowns whose options +-- live inside SettingsAdvancedQualityControlsMixin's private closures +-- (GetShadowQualityOptions etc.). Mirror Blizzard's Graphics.lua: same +-- value sets, same localized label globals, same hardware-validity +-- filter via IsGraphicsSettingValueSupported. +local function MakeQualityOpts(cvar, raid, optionDefs) + return { cvar = cvar, raid = raid, optionDefs = optionDefs } +end +local function L(name, fallback) + local v = _G[name] + if type(v) == "string" and v ~= "" then return v end + return fallback +end +local QUALITY_LIKE_5 = { + { 0, "VIDEO_OPTIONS_LOW", "Low" }, + { 1, "VIDEO_OPTIONS_FAIR", "Fair" }, + { 2, "VIDEO_OPTIONS_MEDIUM", "Medium" }, + { 3, "VIDEO_OPTIONS_HIGH", "High" }, + { 4, "VIDEO_OPTIONS_ULTRA", "Ultra" }, + { 5, "VIDEO_OPTIONS_ULTRA_HIGH", "Ultra High" }, +} +local QUALITY_LIKE_4 = { + { 0, "VIDEO_OPTIONS_LOW", "Low" }, + { 1, "VIDEO_OPTIONS_FAIR", "Fair" }, + { 2, "VIDEO_OPTIONS_MEDIUM", "Medium" }, + { 3, "VIDEO_OPTIONS_HIGH", "High" }, +} +local HARDCODED_OPTIONS = { + PROXY_SHADOW_QUALITY = MakeQualityOpts("graphicsShadowQuality", false, QUALITY_LIKE_5), + PROXY_RAID_SHADOW_QUALITY = MakeQualityOpts("raidGraphicsShadowQuality", true, QUALITY_LIKE_5), + PROXY_LIQUID_DETAIL = MakeQualityOpts("graphicsLiquidDetail", false, QUALITY_LIKE_4), + PROXY_RAID_LIQUID_DETAIL = MakeQualityOpts("raidGraphicsLiquidDetail", true, QUALITY_LIKE_4), + PROXY_PARTICLE_DENSITY = MakeQualityOpts("graphicsParticleDensity", false, QUALITY_LIKE_5), + PROXY_RAID_PARTICLE_DENSITY = MakeQualityOpts("raidGraphicsParticleDensity", true, QUALITY_LIKE_5), + PROXY_VIEW_DISTANCE = MakeQualityOpts("graphicsViewDistance", false, QUALITY_LIKE_5), + PROXY_RAID_VIEW_DISTANCE = MakeQualityOpts("raidGraphicsViewDistance", true, QUALITY_LIKE_5), + PROXY_ENVIRONMENT_DETAIL = MakeQualityOpts("graphicsEnvironmentDetail", false, QUALITY_LIKE_5), + PROXY_RAID_ENVIRONMENT_DETAIL = MakeQualityOpts("raidGraphicsEnvironmentDetail", true, QUALITY_LIKE_5), + PROXY_GROUND_CLUTTER = MakeQualityOpts("graphicsGroundClutter", false, QUALITY_LIKE_5), + PROXY_RAID_GROUND_CLUTTER = MakeQualityOpts("raidGraphicsGroundClutter", true, QUALITY_LIKE_5), +} +local function BuildQualityOptions(spec) + if not spec then return nil end + local out = {} + for _, def in ipairs(spec.optionDefs) do + local value, labelGlobal, fallback = def[1], def[2], def[3] + local supported = true + if IsGraphicsSettingValueSupported and spec.cvar then + local ok, err = pcall(IsGraphicsSettingValueSupported, spec.cvar, value, spec.raid) + if ok then supported = (err == nil or err == 0) end + end + if supported then + tinsert(out, { value = value, label = L(labelGlobal, fallback) }) + end + end + if #out == 0 then return nil end + return out +end + -- Walk Game (non-AddOn) categories for individual settings the curated -- SETTINGS_DATA list doesn't surface (Use UI Scale, vsync, etc.). The -- live registry is authoritative for what's actually in the panel @@ -1799,6 +1858,8 @@ local function CollectGameSettings() local entries = {} if not Settings then return entries end local curated = BuildCuratedVariableSet() + local emittedVars = {} + for v in pairs(curated) do emittedVars[v] = true end local seenCatIDs = {} local function emit(cat, parentName) if not cat or not cat.GetName then return end @@ -1811,9 +1872,10 @@ local function CollectGameSettings() local pathPrefix = { "Game Settings", catName } local inline = WalkCategorySettings(cat, catName, catID, pathPrefix) for _, e in ipairs(inline) do - if not curated[e.settingVariable] then + if not emittedVars[e.settingVariable] then e.category = "Game Settings" tinsert(entries, e) + emittedVars[e.settingVariable] = true end end end @@ -1830,6 +1892,73 @@ local function CollectGameSettings() end end end + -- Fallback pass: SettingsPanel.settings holds every Setting object + -- registered via Settings.RegisterCVar/Proxy/AddOn. Custom container + -- initializers (Graphics Quality's BaseQualityControls, which bundles + -- Shadow Quality / Liquid Detail / etc. into one frame) hide their + -- child settings from layout:GetInitializers, so the walk above + -- misses them. Pull them straight from the registry as basic + -- navigation entries: click the row, panel opens to the category. + if SettingsPanel and SettingsPanel.settings then + for setting, cat in pairs(SettingsPanel.settings) do + if cat and not IsAddonCategory(cat) and setting.GetVariable then + local vok, variable = pcall(setting.GetVariable, setting) + if vok and variable and not emittedVars[variable] then + local nok, settingName = pcall(setting.GetName, setting) + if nok and settingName and settingName ~= "" then + -- Base + Raid variants of each Graphics Quality + -- setting share the same display name with + -- different proxy variables (PROXY_SHADOW_QUALITY + -- vs the raid one). Suffix the raid version so + -- the search results don't show two identical + -- rows. + local displayName = settingName + local lowerVar = slower(variable) + if lowerVar:find("raid", 1, true) then + displayName = settingName .. " (Raid)" + end + local catName = cat.GetName and cat:GetName() or "Game Settings" + local catID = cat.GetID and cat:GetID() + local nameLower = slower(displayName) + local kw = { "setting", "option", "config", nameLower, slower(catName) } + -- Booleans render with the inline checkbox row. + -- Known graphics-quality dropdowns get a hardcoded + -- option list so the inline dropdown widget works. + -- Other non-boolean orphans fall back to "open the + -- panel on click" since we can't enumerate their + -- options from outside the custom mixin. + local resolvedType, settingOptions + local qspec = HARDCODED_OPTIONS[variable] + if qspec then + settingOptions = BuildQualityOptions(qspec) + if settingOptions then resolvedType = "dropdown" end + elseif setting.GetVariableType then + local tok, vt = pcall(setting.GetVariableType, setting) + if tok and vt == "boolean" then resolvedType = "checkbox" end + end + tinsert(entries, { + name = displayName, + nameLower = nameLower, + keywords = kw, + keywordsLower = kw, + category = "Game Settings", + path = { "Game Settings", catName }, + settingsCategory = catName, + settingCategoryID = catID, + settingVariable = variable, + settingType = resolvedType, + settingOptions = settingOptions, + steps = { + { settingsCategory = catName, settingCategoryID = catID, + settingVariable = variable }, + }, + }) + emittedVars[variable] = true + end + end + end + end + end return entries end BlizzOptionsSearch.CollectGameSettings = CollectGameSettings From 66a7dde26335e17c442a2dfab1cef2a65b500dfe Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 12:26:22 -0400 Subject: [PATCH 072/103] Guide: breadcrumb live achievements; intermediate hover protection Live-search achievement entries used to be a single-step guide (just open the AchievementMicroButton). Now BuildAchievementSteps walks GetAchievementCategory + GetCategoryInfo to build a step per parent category root-down, terminating with an achievementID step that highlights the selected achievement row in AchievementFrameAchievements.ScrollBox via FindFrame. Highlight watcher reverted to repurpose-only: - No more blanket hover-dismiss in the OnUpdate watcher (it was killing intermediate breadcrumb highlights regardless of step position). - Visibility check removed too -- it false-positived on ScrollBox virtualized targets that briefly report not-shown during a re-render. Validator path stays for callers that opt in. - canHoverDismiss returns false for any step before the last so the per-step IsMouseOver checks scattered through UpdateGuide can't accidentally cancel an intermediate. achievementCategory step: - Drops the parent-expanded auto-advance shortcut. Each step in the breadcrumb now waits for the user's actual click instead of jumping the gun when an upstream click expanded the parent. - If AchievementFrame is closed mid-guide, just cancel. The rewind-to-prereq behavior was removed -- closing the panel shouldn't trigger an auto-reopen / breadcrumb-back chain. Sidebar tab block also dropped its hover-advance branch (only last-step Cancel remains). --- Highlight.lua | 86 ++++++++++++++++++++++++++++++++++----------------- UI.lua | 44 +++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 29 deletions(-) diff --git a/Highlight.lua b/Highlight.lua index 70d544a..d9d8cf6 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -36,6 +36,15 @@ local stepTicker local highlightShownAt -- GetTime() when the current highlight was first shown local function canHoverDismiss() + -- Hover-to-dismiss only fires on the LAST step of a guide (or when + -- there's no guide running -- a single-highlight, like clicking a + -- search result that didn't open Guide mode). Intermediate guide + -- steps must stick around long enough for the player to see them + -- light up; clearing on hover would short-circuit the breadcrumb. + if currentGuide and currentGuide.steps then + local total = #currentGuide.steps + if currentStepIndex and currentStepIndex < total then return false end + end return not highlightShownAt or (GetTime() - highlightShownAt) >= HOVER_MIN_DISPLAY end @@ -92,28 +101,25 @@ function Highlight:CreateHighlightFrame() -- ScrollBox button repurposed for a different spell after the user -- pages the spellbook. Caller passes a closure that re-checks -- identity (SpellFrameMatchesSelf etc.). + -- Repurpose watcher only -- NO hover-dismiss here, NO visibility + -- check. The per-step UpdateGuide loop already advances/rewinds + -- when the destination panel changes; a blanket visibility watcher + -- false-positives on ScrollBox-virtualized targets (achievement + -- categories, currency rows, etc.) which briefly report not-shown + -- during their own re-render and would kill intermediate highlights. + -- The validator path stays for explicit identity checks the caller + -- opted into (e.g. spellbook page-flip). local watchAccum = 0 highlightFrame:HookScript("OnUpdate", function(self, elapsed) watchAccum = watchAccum + elapsed if watchAccum < 0.1 then return end watchAccum = 0 local target = self._targetFrame - if target and target.IsVisible and not target:IsVisible() then - Highlight:HideHighlight() - return - end local validator = self._targetValidator if validator and not validator(target) then Highlight:HideHighlight() return end - -- Hover-to-dismiss: cursor is on the target, so the player - -- found it. Cancel (not HideHighlight) stops the step ticker; - -- otherwise the next tick's HighlightFrame re-shows and resets - -- highlightShownAt, this watcher hides again, and we strobe. - if canHoverDismiss() and target and target.IsMouseOver and target:IsMouseOver() then - Highlight:Cancel() - end end) end @@ -686,8 +692,47 @@ function Highlight:UpdateGuide() return end + -- Achievement-by-ID terminal step. Drive Blizzard's own + -- OpenAchievementFrameToAchievement to scroll the + -- AchievementFrameAchievements.ScrollBox to the row, then + -- look up the visible button via the data provider's selected + -- element and layer our yellow border on it. + if step.achievementID then + if not (AchievementFrame and AchievementFrame:IsShown()) then + self:Cancel() + return + end + local opener = _G["OpenAchievementFrameToAchievement"] + if opener then pcall(opener, step.achievementID) end + local btn + local list = _G["AchievementFrameAchievements"] + if list and list.ScrollBox then + local getSelected = _G["AchievementFrameAchievements_GetSelectedElementData"] + local elementData = getSelected and getSelected() + if elementData and elementData.id == step.achievementID + and list.ScrollBox.FindFrame then + local ok, found = pcall(list.ScrollBox.FindFrame, list.ScrollBox, elementData) + if ok and found and found:IsShown() then btn = found end + end + end + if btn then + self:HighlightFrame(btn) + if isLastStep and canHoverDismiss() and btn:IsMouseOver() then + self:Cancel() + end + end + return + end + -- Achievement category navigation (tree-based category selection in Achievements/Guild tabs) if step.achievementCategory then + -- If the player closed the achievement panel, don't try to + -- reopen / rewind. We deprecated that behavior; the guide + -- just cancels. + if not (AchievementFrame and AchievementFrame:IsShown()) then + self:Cancel() + return + end -- First check: are we still on the correct tab? local requiredTabIndex = nil for i = currentStepIndex - 1, 1, -1 do @@ -743,16 +788,6 @@ function Highlight:UpdateGuide() -- Selected but collapsed - fall through to highlight so user expands it end - -- For non-final steps: if the category is a parent that's expanded (children visible), - -- skip ahead - don't force the user to re-select a parent they've already drilled into - if not isLastStep then - local elementData = self:FindCategoryElementData(step.achievementCategory) - if elementData and elementData.parent and not elementData.collapsed then - self:AdvanceStep() - return - end - end - -- Not selected - find the button (scrolls into view automatically) local categoryBtn = self:GetAchievementCategoryButton(step.achievementCategory) if categoryBtn then @@ -803,13 +838,8 @@ function Highlight:UpdateGuide() local sidebarBtn = self:GetSidebarTabButton(step.sidebarIndex) if sidebarBtn then self:HighlightFrame(sidebarBtn) - -- Check for click/hover to advance - if canHoverDismiss() and sidebarBtn:IsMouseOver() then - if isLastStep then - self:Cancel() - else - self:AdvanceStep() - end + if isLastStep and canHoverDismiss() and sidebarBtn:IsMouseOver() then + self:Cancel() end elseif isLastStep then local tabNames = {"Character Stats", "Titles", "Equipment Manager"} diff --git a/UI.lua b/UI.lua index db6ec43..103d731 100644 --- a/UI.lua +++ b/UI.lua @@ -4326,6 +4326,48 @@ local achSearchListener local achSearchPrewarmed = false local ACH_MAX_RESULTS = 8 +-- Walk the achievement's category chain root-down so the guide +-- breadcrumbs through each parent before highlighting the achievement +-- row. GetAchievementCategory + GetCategoryInfo (parentID) walks up +-- toward -1 (root sentinel). +local function BuildAchievementSteps(achievementID) + local steps = { { buttonFrame = "AchievementMicroButton" } } + local getCat = _G["GetAchievementCategory"] + local getInfo = _G["GetCategoryInfo"] + if not getCat or not getInfo then + steps[1].achievementID = achievementID + return steps + end + local catID = getCat(achievementID) + if not catID or catID < 0 then + steps[1].achievementID = achievementID + return steps + end + local chain = {} + local seen = {} + local current = catID + while current and current > 0 and not seen[current] do + seen[current] = true + local title, parentID = getInfo(current) + if not title then break end + chain[#chain + 1] = title + current = parentID + end + -- Reverse so root-most appears first. + for i = #chain, 1, -1 do + steps[#steps + 1] = { + waitForFrame = "AchievementFrame", + achievementCategory = chain[i], + } + end + -- Final step targets the achievement itself. + steps[#steps + 1] = { + waitForFrame = "AchievementFrame", + achievementID = achievementID, + } + return steps +end + local function GetOrCreateAchievementEntry(id, name, icon) local entry = achievementEntryByID[id] if entry then @@ -4341,7 +4383,7 @@ local function GetOrCreateAchievementEntry(id, name, icon) nameLower = slower(name or ""), achievementID = id, icon = icon, - steps = { { buttonFrame = "AchievementMicroButton", achievementID = id } }, + steps = BuildAchievementSteps(id), }, ACHIEVEMENT_MT) achievementEntryByID[id] = entry return entry From f53bc64613f02a4e9b80e8b0b771101de359d856 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 13:02:18 -0400 Subject: [PATCH 073/103] Pins: float matches to top; ESC restores pins; map fields persist - Stable-partition flat search results so pinned matches sit at the top of the visible list while keeping their score-sorted order. Previously the pinned entry stayed wherever its score put it; now it lands above the unpinned matches with the gold separator between the two groups. - Drop the inter-pinned-item horizontal line. Only the pinned-vs-rest divider remains. - HandleEscape no longer follows SetText("") with RequestHideResults. SetText fires OnTextChanged -> OnSearchTextChanged -> ShowPinnedItems while the editbox still has focus, so the pinned list lights up; the redundant hide was closing the panel right after. - UIPins SIMPLE_FIELDS gained mapSearchResult / isZone / mapID / zoneName / pathPrefix / zoneMapType / zoneParentMapID / achievementID. Without these the saved pin couldn't reach the map render branch, so the general icon went missing and the specific icon fell back to the LEFT slot. - UIPins.Pin now overwrites a stale entry in place when the same key is already pinned, so re-pinning after a SIMPLE_FIELDS change refreshes the saved data instead of no-op'ing. --- UI.lua | 48 +++++++++++++++++++++++++++++++++++------------- UIPins.lua | 17 ++++++++++++++++- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/UI.lua b/UI.lua index 103d731..0b8137d 100644 --- a/UI.lua +++ b/UI.lua @@ -6503,11 +6503,38 @@ function UI:OnSearchTextChanged(text, force) flatEntries[i] = nil end + -- Stable partition: pinned matches float to the top, non-pinned + -- follow. Each group keeps its score-sorted order. Pinned items + -- the user has stuck stay at the head of every relevant search. + if n > 1 then + local pinnedBuf = SCRATCH.pinnedFlat or {} + SCRATCH.pinnedFlat = pinnedBuf + local otherBuf = SCRATCH.otherFlat or {} + SCRATCH.otherFlat = otherBuf + wipe(pinnedBuf) + wipe(otherBuf) + for i = 1, n do + local e = flatEntries[i] + if e.isPinned then + pinnedBuf[#pinnedBuf + 1] = e + else + otherBuf[#otherBuf + 1] = e + end + end + if #pinnedBuf > 0 and #pinnedBuf < n then + local out = 0 + for i = 1, #pinnedBuf do + out = out + 1 + flatEntries[out] = pinnedBuf[i] + end + for i = 1, #otherBuf do + out = out + 1 + flatEntries[out] = otherBuf[i] + end + end + end + local hierarchical = flatEntries - -- Pins are quick-access entries that only show when the search bar is - -- empty (handled by ShowPinnedItems). During an active text search we - -- skip them so a user who pinned 6 settings doesn't see them prepended - -- to every unrelated query like "achieve". wipe(pinnedSearchEntries) local _perfTBuild = ns.PERF and debugprofilestop() or 0 self:ShowHierarchicalResults(hierarchical) @@ -7043,14 +7070,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) 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 @@ -11342,7 +11361,10 @@ function UI:HandleEscape() if editBox and editBox:GetText() ~= "" then editBox:SetText("") if editBox.placeholder then editBox.placeholder:Show() end - self:RequestHideResults() + -- SetText("") fires OnTextChanged -> OnSearchTextChanged which + -- calls ShowPinnedItems when the editbox still has focus, so + -- the pinned list lights up. Don't follow with RequestHideResults + -- here; that would close the results we just opened to pins. Refocus() return end diff --git a/UIPins.lua b/UIPins.lua index e4ef6b4..0c0d51d 100644 --- a/UIPins.lua +++ b/UIPins.lua @@ -16,6 +16,12 @@ local SIMPLE_FIELDS = { "itemID", "encounterID", "instanceID", "lootSlotName", "lootSourceName", "lootInstanceName", "lootSourceType", "transmogSetID", "factionID", "hasRepBar", "canQueue", "isPvP", "isPvE", + -- Map-search fields. Without these the row's render branch can't + -- recognize the entry as a map result, so the general icon goes + -- missing and the specific icon falls back to the LEFT slot. + "mapSearchResult", "isZone", "mapID", "zoneName", "pathPrefix", + "zoneMapType", "zoneParentMapID", + "achievementID", } local TABLE_FIELDS = { @@ -117,11 +123,20 @@ function UIPins.IsPinned(data) end function UIPins.Pin(data) - if UIPins.IsPinned(data) then return end local list = GetPinList(data) if not list then return end local clean = CleanForStorage(data) clean.isPinned = true + -- If a stale entry exists (e.g. saved before a new field was added + -- to SIMPLE_FIELDS), replace it in place so the next render picks + -- up the fresh fields. + local key = UIPins.GetKey(data) + for i, existing in ipairs(list) do + if UIPins.GetKey(existing) == key then + list[i] = clean + return + end + end tinsert(list, clean) end From e92718db8a71bd7c76b8db2d7ff538c38c5cb49f Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 13:12:49 -0400 Subject: [PATCH 074/103] Settings: Game Menu via /click instead of /run ShowUIPanel The /run ShowUIPanel(GameMenuFrame) entry point fired ClearTarget inside ToggleGameMenu from a non-hardware-event context, raising an ADDON_ACTION_FORBIDDEN tagged to EasyFind and tainting subsequent secure execution (caught later as a "secret number" compare error when CharacterFrame opened and ShowStatusBarText hit the player StatusBar). Switch to /click MainMenuMicroButton so the click routes through the secure dispatch -- ToggleGameMenu's ClearTarget then runs in a hardware-event context and succeeds. --- Database.lua | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Database.lua b/Database.lua index f240e99..c0d5565 100644 --- a/Database.lua +++ b/Database.lua @@ -3396,18 +3396,17 @@ function Database:BuildUIDatabase() }, -- GAME MENU / HELP / SHOP - -- buttonFrame is kept solely so GetButtonIcon can derive the - -- "UI-HUD-MicroMenu-GameMenu-Up" atlas from MainMenuMicroButton's - -- textureName. The actual click goes through slashCommand because - -- the micro button's OnClick (and ToggleGameMenu) call - -- SpellStopCasting() when a spell is targeting -- protected, and - -- it taints when fired from a /run script context. + -- /click routes through the secure dispatch so the micro + -- button's OnClick fires in a hardware-event context; + -- ToggleGameMenu's internal ClearTarget then succeeds. A + -- /run ShowUIPanel(GameMenuFrame) here forbid-errors on + -- ClearTarget because /run is not a hardware event. { name = "Game Menu", keywords = {"menu", "settings", "options", "escape", "esc", "logout", "quit", "exit", "interface"}, category = "Menu Bar", buttonFrame = "MainMenuMicroButton", - slashCommand = "/run if GameMenuFrame:IsShown() then HideUIPanel(GameMenuFrame) else ShowUIPanel(GameMenuFrame) end", + slashCommand = "/click MainMenuMicroButton", }, { name = "Help", From f0181abca0704bee14d9175c34b2dbddece24e2e Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 13:42:42 -0400 Subject: [PATCH 075/103] WIP: taint debugging (untested) --- BlizzOptionsSearch.lua | 14 +++- Database.lua | 2 +- Rescaler.lua | 2 - UI.lua | 156 +++++++++++++++++++++++++++++++++-------- 4 files changed, 141 insertions(+), 33 deletions(-) diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index 0f9d498..07b0a52 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -14,6 +14,16 @@ local slower = Utils.slower local SafeAfter = Utils.SafeAfter local pcall = pcall +local function SecureCall(fn, ...) + if not fn then return false end + if securecallfunction then + securecallfunction(fn, ...) + else + pcall(fn, ...) + end + return true +end + -- Modern WoW exposes the categories via SettingsPanel:GetAllCategories. -- The legacy Settings.GetCategoryList was removed in Midnight (12.0), -- so callers that walked it returned nil and silently failed. @@ -1101,9 +1111,9 @@ local function ShowSettings() if not SettingsPanel then return end if SettingsPanel:IsShown() then return end if SettingsPanel.Open then - pcall(SettingsPanel.Open, SettingsPanel) + SecureCall(SettingsPanel.Open, SettingsPanel) elseif ShowUIPanel then - pcall(ShowUIPanel, SettingsPanel) + SecureCall(ShowUIPanel, SettingsPanel) end end diff --git a/Database.lua b/Database.lua index c0d5565..8f99191 100644 --- a/Database.lua +++ b/Database.lua @@ -3477,7 +3477,7 @@ function Database:BuildUIDatabase() keywords = {"map", "world map", "navigation", "toggle"}, category = "Navigation", icon = 134269, - slashCommand = "/run if WorldMapFrame:IsShown() then HideUIPanel(WorldMapFrame) else ShowUIPanel(WorldMapFrame) end", + slashCommand = "/run ToggleWorldMap()", }, { name = "Toggle Zone Map", diff --git a/Rescaler.lua b/Rescaler.lua index f84046b..160a561 100644 --- a/Rescaler.lua +++ b/Rescaler.lua @@ -616,8 +616,6 @@ local function GetOrCreateBackdrop() tex:SetAllPoints() tex:SetColorTexture(0, 0, 0, 0.5) - tinsert(UISpecialFrames, "EasyFindRescaleBackdrop") - local SafeCallMethod = Utils.SafeCallMethod backdrop:SetScript("OnKeyDown", function(self, key) if key == "ESCAPE" then diff --git a/UI.lua b/UI.lua index 0b8137d..d30c6b8 100644 --- a/UI.lua +++ b/UI.lua @@ -31,6 +31,110 @@ local wipe = wipe local REP_BAR_WIDTH = 100 +local CHARACTER_TAB_SUBFRAME = { + [1] = "PaperDollFrame", + [2] = "ReputationFrame", + [3] = "TokenFrame", +} + +local function SecureCall(fn, ...) + if not fn then return false end + if securecallfunction then + securecallfunction(fn, ...) + else + pcall(fn, ...) + end + return true +end + +local function SecureShowUIPanel(frame) + if not frame or not ShowUIPanel then return false end + return SecureCall(ShowUIPanel, frame) +end + +local function IsPlayerSpellsTabSelected(tabIndex) + local frame = _G["PlayerSpellsFrame"] + if not frame then return false end + if frame.GetTab then + local currentTab = frame:GetTab() + if currentTab == tabIndex then return true end + end + if tabIndex == 1 and frame.SpecFrame and frame.SpecFrame:IsShown() then + return true + elseif tabIndex == 2 and frame.TalentsFrame and frame.TalentsFrame:IsShown() then + return true + elseif tabIndex == 2 and ClassTalentFrame and ClassTalentFrame:IsShown() then + return true + elseif tabIndex == 3 and frame.SpellBookFrame and frame.SpellBookFrame:IsShown() then + return true + end + return false +end + +local function OpenPlayerSpellsFrame(tabIndex) + local frame = _G["PlayerSpellsFrame"] + if frame and frame:IsShown() then + return true + end + + -- Opening PlayerSpellsFrame through the microbutton taints Blizzard's + -- ShowUIPanel/UIParent path. Call Blizzard's opener through + -- securecallfunction so later protected panel work, including + -- CharacterFrame status bars, does not inherit EasyFind taint. + local util = _G.PlayerSpellsUtil + if util and SecureCall(util.TogglePlayerSpellsFrame, tabIndex) then + return true + end + + return ClickButton(_G["PlayerSpellsMicroButton"]) +end + +local function IsCharacterTabSelected(tabIndex) + if not tabIndex then return CharacterFrame and CharacterFrame:IsShown() end + if not (CharacterFrame and CharacterFrame:IsShown()) then return false end + if PanelTemplates_GetSelectedTab + and PanelTemplates_GetSelectedTab(CharacterFrame) == tabIndex then + return true + end + if tabIndex == 1 then + return (PaperDollFrame and PaperDollFrame:IsShown()) + or (CharacterStatsPane and CharacterStatsPane:IsShown()) + elseif tabIndex == 2 then + return ReputationFrame and ReputationFrame:IsShown() + elseif tabIndex == 3 then + return (TokenFrame and TokenFrame:IsShown()) + or (CurrencyFrame and CurrencyFrame:IsShown()) + end + return false +end + +local function OpenCharacterFrame(tabIndex) + if IsCharacterTabSelected(tabIndex) then return true end + + local subFrame = CHARACTER_TAB_SUBFRAME[tabIndex] or "PaperDollFrame" + if SecureCall(_G.ToggleCharacter, subFrame) then + return true + end + + return ClickButton(_G["CharacterMicroButton"]) +end + +local function OpenButtonFrame(buttonFrame, nextStep) + if buttonFrame == "PlayerSpellsMicroButton" then + local tabIndex = nextStep and nextStep.waitForFrame == "PlayerSpellsFrame" + and nextStep.tabIndex or nil + return OpenPlayerSpellsFrame(tabIndex) + elseif buttonFrame == "CharacterMicroButton" then + local tabIndex = nextStep and nextStep.waitForFrame == "CharacterFrame" + and nextStep.tabIndex or nil + return OpenCharacterFrame(tabIndex) + end + + local stepFrame = Utils.GetFrameByPath(buttonFrame) or _G[buttonFrame] + if stepFrame then return ClickButton(stepFrame) end + return false +end + local searchFrame local resultsFrame -- Combined-frame backdrop: rounded-rect 9-slice that wraps the bar @@ -1731,7 +1835,11 @@ function UI:CreateSearchFrame() escCatcher = CreateFrame("Frame", "EasyFindEscCatcher", UIParent) escCatcher:SetSize(1, 1) escCatcher:Hide() - tinsert(UISpecialFrames, "EasyFindEscCatcher") + -- UISpecialFrames registration removed: any insecure mutation of + -- a frame in that table can taint Blizzard's ESC pipeline, and we + -- mutate this frame heavily. Our editbox's OnEscapePressed and the + -- searchFrame's keyboard handler at the navFrame level handle ESC + -- on their own, so giving up the UISpecialFrames fallback is safe. escCatcher:SetScript("OnHide", function(self) if not searchFrame or not searchFrame:IsShown() then return end -- editBox having focus means WoW's ESC pipeline already routed to @@ -9602,12 +9710,7 @@ function UI:OpenTalentInTalentsTab(data) local function ensureFrameOnTab(attempt) local frame = _G["PlayerSpellsFrame"] if not (frame and frame:IsShown()) then - local util = _G.PlayerSpellsUtil - if util and util.TogglePlayerSpellsFrame then - pcall(util.TogglePlayerSpellsFrame, TALENTS_TAB) - else - ClickButton(_G["PlayerSpellsMicroButton"]) - end + OpenPlayerSpellsFrame(TALENTS_TAB) if attempt < 30 then C_Timer.After(0.05, function() ensureFrameOnTab(attempt + 1) end) end @@ -9722,12 +9825,7 @@ function UI:OpenAbilityInSpellbook(data) return false end - local util = _G.PlayerSpellsUtil - if util and util.TogglePlayerSpellsFrame then - pcall(util.TogglePlayerSpellsFrame, 3) - else - ClickButton(_G["PlayerSpellsMicroButton"]) - end + OpenPlayerSpellsFrame(3) return true end @@ -9858,7 +9956,7 @@ function UI:SelectResult(data, forceGuide) Transmog_LoadUI() end if TransmogFrame then - ShowUIPanel(TransmogFrame) + SecureShowUIPanel(TransmogFrame) self:ApplyTransmogBrowseMode() end return @@ -10309,7 +10407,7 @@ function UI:DirectOpen(data) Transmog_LoadUI() end if TransmogFrame then - ShowUIPanel(TransmogFrame) + SecureShowUIPanel(TransmogFrame) UI:ApplyTransmogBrowseMode() end return @@ -10334,7 +10432,7 @@ function UI:DirectOpen(data) if nextStep and nextStep.waitForFrame == "EncounterJournal" and nextStep.tabIndex then EncounterJournal_LoadUI() EncounterJournal.selectedTab = nextStep.tabIndex - ShowUIPanel(EncounterJournal) + SecureShowUIPanel(EncounterJournal) -- Skip the tab step, continue from the step after it. -- Defer one frame so the ScrollBox populates its items. local resume = i + 2 @@ -10352,7 +10450,7 @@ function UI:DirectOpen(data) if EJ_SelectTier then EJ_SelectTier(nextStep.ejTier) end local tabIdx = nextStep.ejTabIsRaid and 5 or 4 EncounterJournal.selectedTab = tabIdx - ShowUIPanel(EncounterJournal) + SecureShowUIPanel(EncounterJournal) local tabBtn = Highlight:GetTabButton("EncounterJournal", tabIdx) if tabBtn then ClickButton(tabBtn) end local resume = i + 2 @@ -10360,8 +10458,7 @@ function UI:DirectOpen(data) return end end - local stepFrame = Utils.GetFrameByPath(step.buttonFrame) or _G[step.buttonFrame] - if stepFrame then ClickButton(stepFrame) end + OpenButtonFrame(step.buttonFrame, steps[i + 1]) end if step.waitForFrame and step.tabIndex then @@ -10379,16 +10476,22 @@ function UI:DirectOpen(data) if resync then -- Toggle tabs to force ScrollBox rebuild with expanded headers. -- Needs one frame to propagate; defer remaining steps. - ClickButton(Highlight:GetTabButton("CharacterFrame", 1)) - local waitFrame = step.waitForFrame + OpenCharacterFrame(1) local tabIdx = step.tabIndex local resume = i + 1 C_Timer.After(0.05, function() - ClickButton(Highlight:GetTabButton(waitFrame, tabIdx)) + OpenCharacterFrame(tabIdx) executeFrom(resume) end) return - elseif step.waitForFrame ~= "EncounterJournal" then + elseif step.waitForFrame == "CharacterFrame" then + OpenCharacterFrame(step.tabIndex) + elseif step.waitForFrame == "PlayerSpellsFrame" + and not IsPlayerSpellsTabSelected(step.tabIndex) then + local tabBtn = Highlight:GetTabButton(step.waitForFrame, step.tabIndex) + if tabBtn then ClickButton(tabBtn) end + elseif step.waitForFrame ~= "EncounterJournal" + and step.waitForFrame ~= "PlayerSpellsFrame" then ClickButton(Highlight:GetTabButton(step.waitForFrame, step.tabIndex)) end end @@ -10679,7 +10782,7 @@ function UI:ClickCharacterSidebar(sidebarIndex) -- Switch to the Character tab (tab 1) first if PanelTemplates_GetSelectedTab and PanelTemplates_GetSelectedTab(CharacterFrame) ~= 1 then - ClickButton(_G["CharacterFrameTab1"]) + OpenCharacterFrame(1) end -- Method 1: Try PaperDollSidebarTab buttons directly (Frame Inspector confirmed names) @@ -11235,7 +11338,7 @@ function UI:OpenAchievementByID(achievementID) end local frame = _G["AchievementFrame"] if frame and not frame:IsShown() and ShowUIPanel then - ShowUIPanel(frame) + SecureShowUIPanel(frame) end local opener = _G["OpenAchievementFrameToAchievement"] if opener then @@ -11687,9 +11790,6 @@ function UI:ShowWhatsNew(version) f:SetScript("OnDragStart", f.StartMoving) f:SetScript("OnDragStop", f.StopMovingOrSizing) - -- Escape to close - tinsert(UISpecialFrames, "EasyFindWhatsNew") - -- Close button (X) local closeBtn = CreateFrame("Button", nil, f, "UIPanelCloseButton") closeBtn:SetPoint("TOPRIGHT", -5, -5) From ba0060dd1cd699c7287a087dc761543003d7c641 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 14:49:12 -0400 Subject: [PATCH 076/103] Settings: drop StaticPopupDialogs global write to avoid taint StaticPopupDialogs = StaticPopupDialogs or {} writes the global slot even when the table already exists, which taints it. Blizzard's ShowUIPanel reads StaticPopupDialogs[name] during panel-manager work, inheriting the taint and crashing TextStatusBar comparisons on later panel opens. Guard the popup registration on the existing global without writing it back. --- BlizzOptionsSearch.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index 07b0a52..ae7c1e8 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -935,8 +935,11 @@ end -- our search panel with pending Apply-flagged settings prompts the -- same three-button choice. Defined once at file load; the StaticPopup -- registry keeps it across reloads. -StaticPopupDialogs = StaticPopupDialogs or {} -if not StaticPopupDialogs["EASYFIND_UNAPPLIED_SETTINGS"] then +-- Do NOT add `StaticPopupDialogs = StaticPopupDialogs or {}` here. The +-- write to the global slot taints StaticPopupDialogs even when the +-- value identity is preserved, and that taint propagates into +-- ShowUIPanel / UIParentPanelManager, crashing later panel opens. +if StaticPopupDialogs and not StaticPopupDialogs["EASYFIND_UNAPPLIED_SETTINGS"] then StaticPopupDialogs["EASYFIND_UNAPPLIED_SETTINGS"] = { text = SETTINGS_CONFIRM_DISCARD or "You have settings that have not been applied.\nAre you sure you wish to exit?", From e2aff0d890a9f0282b6084c4796988c037ec7635 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 17:25:01 -0400 Subject: [PATCH 077/103] Stats: API-driven categories, separate filter, inline values Replace hardcoded achievement and statistics category trees in uiTree with runtime walks of GetCategoryList / GetStatisticsCategoryList + GetCategoryInfo so the search index always matches the live AchievementFrame sidebar for the current build. Statistics get a separate filter (Statistics, distinct from Achievements) and one entry per stat row via GetCategoryNumAchievements + GetAchievementInfo. Each row carries the stat ID so the result shows the live GetStatistic value inline (similar to currency quantity) and the highlight engine drives the AchievementFrameStats ScrollBox to scroll-and-select the exact row. The per-row enumeration runs synchronously at PLAYER_LOGIN before the staggered dynamic-provider chain so the ~400 API calls land during the load screen rather than as a post-login hitch. The provider machinery's loaded flag prevents re-running. --- .luacheckrc | 1 + Core.lua | 14 + Database.lua | 752 +++++++++++++++----------------------------- DatabaseDynamic.lua | 13 +- Highlight.lua | 35 +++ Options.lua | 10 +- UI.lua | 63 +++- 7 files changed, 378 insertions(+), 510 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index c41ef29..654218d 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -68,6 +68,7 @@ read_globals = { "AchievementFrame_LoadUI", "AchievementFrame_SelectAchievement", "OpenAchievementFrameToAchievement", "AchievementFrame", "GetCategoryList", "GetCategoryNumAchievements", + "GetStatisticsCategoryList", "GetStatistic", "GetAchievementInfo", "SetAchievementSearchString", "GetNumFilteredAchievements", "GetFilteredAchievementID", diff --git a/Core.lua b/Core.lua index bfaee55..06bc286 100644 --- a/Core.lua +++ b/Core.lua @@ -496,6 +496,20 @@ function EasyFind:EnsureDynamicLoaded() end local function OnPlayerLogin() + -- Heavy synchronous statistics enumeration runs first so it lands + -- during the load-screen window — the player isn't controlling the + -- character yet, so the ~400 API calls are invisible. The dynamic + -- provider machinery checks `loaded` and skips this when the + -- staggered chain reaches it later. + if ns.Database and ns.Database.PopulateDynamicStatistics + and EasyFind.db.enableUISearch ~= false then + local ok = xpcall(ns.Database.PopulateDynamicStatistics, + ErrorHandler, ns.Database) + if ok and ns.Database.MarkDynamicProviderLoaded then + ns.Database:MarkDynamicProviderLoaded("statistics") + end + end + local function SafeInit(mod, name) if not mod then return end local ok, err = xpcall(mod.Initialize, ErrorHandler, mod) diff --git a/Database.lua b/Database.lua index 8f99191..4e38133 100644 --- a/Database.lua +++ b/Database.lua @@ -2526,6 +2526,246 @@ function Database:_ResetHeavyProviderCaches() wipe(lootSpecsScanned) end +-- Aliases the API category names don't carry. Keyed by lowercase name. +-- Words from the name are tokenized automatically by the search scorer, +-- so these only need acronyms and shortenings the user might type. +local CATEGORY_KEYWORD_OVERLAY = { + ["rated battlegrounds"] = {"rbg", "rated bg"}, + ["alterac valley"] = {"av"}, + ["arathi basin"] = {"ab"}, + ["eye of the storm"] = {"eots"}, + ["warsong gulch"] = {"wsg"}, + ["wintergrasp"] = {"wg"}, + ["strand of the ancients"] = {"sota"}, + ["isle of conquest"] = {"ioc"}, + ["temple of kotmogu"] = {"tok"}, + ["silvershard mines"] = {"ssm"}, + ["the burning crusade"] = {"tbc", "burning crusade"}, + ["wrath of the lich king"] = {"wotlk", "wrath", "lich king"}, + ["mists of pandaria"] = {"mop", "pandaria"}, + ["warlords of draenor"] = {"wod", "draenor"}, + ["battle for azeroth"] = {"bfa"}, + ["shadowlands"] = {"sl"}, + ["dragonflight"] = {"df"}, + ["the war within"] = {"tww", "war within"}, + ["feats of strength"] = {"fos"}, + ["player vs. player"] = {"pvp"}, + ["dungeons & raids"] = {"dungeons", "raids"}, +} + +local CATEGORY_FLAG_GUILD = 0x00000001 + +local function buildCategoryEntry(opts) + local nameLower = slower(opts.categoryName) + local kw = { nameLower } + for w in nameLower:gmatch("[%w']+") do + if #w > 2 and w ~= nameLower then kw[#kw + 1] = w end + end + local overlay = CATEGORY_KEYWORD_OVERLAY[nameLower] + if overlay then + for i = 1, #overlay do kw[#kw + 1] = overlay[i] end + end + local entry = { + name = opts.displayName, + keywords = kw, + category = opts.category, + buttonFrame = "AchievementMicroButton", + path = opts.path, + steps = opts.steps, + flashLabel = opts.flashLabel, + } + entry.nameLower = slower(opts.displayName) + entry.keywordsLower = {} + for j = 1, #kw do entry.keywordsLower[j] = slower(kw[j]) end + return entry +end + +-- Walks GetCategoryList()/GetCategoryInfo() to build personal + +-- guild achievement category entries directly from the live registry, +-- so search results always match what the AchievementFrame sidebar +-- actually contains for the current build. +function Database:PopulateDynamicAchievements() + if not GetCategoryList or not GetCategoryInfo then return false end + RemoveEntriesByCategory("Achievement Category") + + local categories = GetCategoryList() + if not categories then return false end + + -- 12.0's GetCategoryList returns the union of achievement + + -- statistics category IDs. Subtract the statistics set so we only + -- emit real achievement-tab sidebar entries here; statistics get + -- their own dynamic populator. + local statsCategorySet = {} + if GetStatisticsCategoryList then + local statCats = GetStatisticsCategoryList() + if statCats then + for i = 1, #statCats do statsCategorySet[statCats[i]] = true end + end + end + + local catMap = {} + for i = 1, #categories do + local catID = categories[i] + if not statsCategorySet[catID] then + local name, parentID, flags = GetCategoryInfo(catID) + if name and name ~= "" then + catMap[catID] = { + id = catID, name = name, parentID = parentID, + isGuild = band(flags or 0, CATEGORY_FLAG_GUILD) == 1, + children = {}, + } + end + end + end + + local roots = {} + for _, cat in pairs(catMap) do + local parent = cat.parentID and cat.parentID ~= -1 and catMap[cat.parentID] + if parent then + parent.children[#parent.children + 1] = cat + else + roots[#roots + 1] = cat + end + end + + local function emit(cat, parentChain, isGuildBranch) + local prefix = isGuildBranch and "Guild: " or "" + local suffix = isGuildBranch and "" or " (Achievements)" + local pathRoot = isGuildBranch + and { "Achievements", "Guild Achievements" } + or { "Achievements", "Personal Achievements" } + + local steps = { + { buttonFrame = "AchievementMicroButton" }, + { waitForFrame = "AchievementFrame", tabIndex = isGuildBranch and 2 or 1 }, + } + local path = {} + for i = 1, #pathRoot do path[i] = pathRoot[i] end + for i = 1, #parentChain do + local pn = parentChain[i].name + steps[#steps + 1] = { waitForFrame = "AchievementFrame", achievementCategory = pn } + path[#path + 1] = pn + end + steps[#steps + 1] = { waitForFrame = "AchievementFrame", achievementCategory = cat.name } + + uiSearchData[#uiSearchData + 1] = buildCategoryEntry({ + displayName = prefix .. cat.name .. suffix, + categoryName = cat.name, + category = "Achievement Category", + path = path, + steps = steps, + flashLabel = "Achievements", + }) + + if #cat.children > 0 then + local nextChain = {} + for i = 1, #parentChain do nextChain[i] = parentChain[i] end + nextChain[#nextChain + 1] = cat + for i = 1, #cat.children do emit(cat.children[i], nextChain, isGuildBranch) end + end + end + + for i = 1, #roots do emit(roots[i], {}, roots[i].isGuild) end + return true +end + +-- Walks GetStatisticsCategoryList()/GetCategoryInfo() to emit the +-- Statistics tab's category tree. Same shape as achievements but +-- targets the Statistics tab (tabIndex = 3). +function Database:PopulateDynamicStatistics() + if not GetStatisticsCategoryList or not GetCategoryInfo then return false end + RemoveEntriesByCategory("Statistic") + + local categories = GetStatisticsCategoryList() + if not categories then return false end + + local catMap = {} + for i = 1, #categories do + local catID = categories[i] + local name, parentID = GetCategoryInfo(catID) + if name and name ~= "" then + catMap[catID] = { + id = catID, name = name, parentID = parentID, children = {}, + } + end + end + + local roots = {} + for _, cat in pairs(catMap) do + local parent = cat.parentID and cat.parentID ~= -1 and catMap[cat.parentID] + if parent then + parent.children[#parent.children + 1] = cat + else + roots[#roots + 1] = cat + end + end + + -- Emit one entry per individual stat row inside each category. + -- Each "row" in the Statistics tab is an achievement ID whose + -- value is fetched via GetStatistic(achievementID). Walking + -- GetCategoryNumAchievements + GetAchievementInfo gives us the + -- per-row IDs and titles for inline display. Heavy (~400 API + -- calls); call this synchronously at PLAYER_LOGIN so it lands + -- during the load-screen window where the cost is hidden. + local function emit(cat, parentChain) + local baseSteps = { + { buttonFrame = "AchievementMicroButton" }, + { waitForFrame = "AchievementFrame", tabIndex = 3 }, + } + local pathBase = { "Achievements", "Statistics" } + for i = 1, #parentChain do + local pn = parentChain[i].name + baseSteps[#baseSteps + 1] = { waitForFrame = "AchievementFrame", statisticsCategory = pn } + pathBase[#pathBase + 1] = pn + end + baseSteps[#baseSteps + 1] = { waitForFrame = "AchievementFrame", statisticsCategory = cat.name } + pathBase[#pathBase + 1] = cat.name + + if GetCategoryNumAchievements and GetAchievementInfo then + local total = GetCategoryNumAchievements(cat.id, true) + for i = 1, (total or 0) do + local id, title = GetAchievementInfo(cat.id, i) + if id and title and title ~= "" then + local steps = {} + for s = 1, #baseSteps do steps[s] = baseSteps[s] end + -- Leaf step: ONLY statisticID + statisticName. + -- Including statisticsCategory here would make the + -- statisticsCategory handler match first (it runs + -- before the statisticID handler) and Cancel the + -- guide because the category is already selected. + steps[#steps + 1] = { + waitForFrame = "AchievementFrame", + statisticID = id, + statisticName = title, + } + local path = {} + for p = 1, #pathBase do path[p] = pathBase[p] end + local entry = buildCategoryEntry({ + displayName = title, + categoryName = title, + category = "Statistic", + path = path, + steps = steps, + flashLabel = "Statistics", + }) + entry.statisticID = id + uiSearchData[#uiSearchData + 1] = entry + end + end + end + + if #cat.children > 0 then + local nextChain = {} + for i = 1, #parentChain do nextChain[i] = parentChain[i] end + nextChain[#nextChain + 1] = cat + for i = 1, #cat.children do emit(cat.children[i], nextChain) end + end + end + + for i = 1, #roots do emit(roots[i], {}) end + return true +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. @@ -2704,430 +2944,11 @@ function Database:BuildUIDatabase() keywords = {"achievements", "achievement tab", "personal achievements"}, category = "Achievements", steps = {{ waitForFrame = "AchievementFrame", tabIndex = 1 }}, - children = { - -- ACHIEVEMENT CATEGORIES (Auto-generated by Harvester) - { - name = "Characters (Achievements)", - keywords = {"characters"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Characters" }}, - }, - { - name = "Collections (Achievements)", - keywords = {"collections", "collection", "transmog", "tmog"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Collections" }}, - children = { - { - name = "Appearances (Achievements)", - keywords = {"appearances"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Appearances" }}, - }, - { - name = "Decor (Achievements)", - keywords = {"decor"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Decor" }}, - }, - { - name = "Dragon Isle Drake Cosmetics (Achievements)", - keywords = {"dragon isle drake cosmetics", "drake cosmetics"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Dragon Isle Drake Cosmetics" }}, - }, - { - name = "Mounts - Collections (Achievements)", - keywords = {"mounts"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Mounts" }}, - }, - { - name = "Toy Box (Achievements)", - keywords = {"toy box", "toys"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Toy Box" }}, - }, - }, - }, - { - name = "Delves (Achievements)", - keywords = {"delves"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Delves" }}, - children = { - { - name = "Midnight - Delves (Achievements)", - keywords = {"midnight"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Midnight" }}, - }, - { - name = "The War Within (Achievements)", - keywords = {"the war within", "tww"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "The War Within" }}, - }, - }, - }, - { - name = "Dungeons & Raids (Achievements)", - keywords = {"dungeons & raids", "dungeons", "raids", "dungeon", "raid"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Dungeons & Raids" }}, - children = { - { - name = "Battle Dungeon (Achievements)", - keywords = {"battle dungeon"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Battle Dungeon" }}, - }, - { - name = "Battle Raid (Achievements)", - keywords = {"battle raid"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Battle Raid" }}, - }, - { - name = "Cataclysm Dungeon (Achievements)", - keywords = {"cataclysm dungeon", "cata dungeon"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Cataclysm Dungeon" }}, - }, - { - name = "Cataclysm Raid (Achievements)", - keywords = {"cataclysm raid", "cata raid"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Cataclysm Raid" }}, - }, - { - name = "Classic - Dungeons & Raids (Achievements)", - keywords = {"classic"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Classic" }}, - }, - { - name = "Draenor Dungeon (Achievements)", - keywords = {"draenor dungeon", "wod dungeon"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Draenor Dungeon" }}, - }, - { - name = "Draenor Raid (Achievements)", - keywords = {"draenor raid", "wod raid"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Draenor Raid" }}, - }, - { - name = "Dragonflight Dungeon (Achievements)", - keywords = {"dragonflight dungeon", "df dungeon"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Dragonflight Dungeon" }}, - }, - { - name = "Dragonflight Raid (Achievements)", - keywords = {"dragonflight raid", "df raid"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Dragonflight Raid" }}, - }, - { - name = "Legion Dungeon (Achievements)", - keywords = {"legion dungeon"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Legion Dungeon" }}, - }, - { - name = "Legion Raid (Achievements)", - keywords = {"legion raid"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Legion Raid" }}, - }, - { - name = "Lich King Dungeon (Achievements)", - keywords = {"lich king dungeon", "wotlk dungeon"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Lich King Dungeon" }}, - }, - { - name = "Lich King Raid (Achievements)", - keywords = {"lich king raid", "wotlk raid"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Lich King Raid" }}, - }, - { - name = "Midnight Dungeon (Achievements)", - keywords = {"midnight dungeon"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Midnight Dungeon" }}, - }, - { - name = "Midnight Raid (Achievements)", - keywords = {"midnight raid"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Midnight Raid" }}, - }, - { - name = "Pandaria Dungeon (Achievements)", - keywords = {"pandaria dungeon", "mop dungeon"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Pandaria Dungeon" }}, - }, - { - name = "Pandaria Raid (Achievements)", - keywords = {"pandaria raid", "mop raid"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Pandaria Raid" }}, - }, - { - name = "Shadowlands Dungeon (Achievements)", - keywords = {"shadowlands dungeon", "sl dungeon"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Shadowlands Dungeon" }}, - }, - { - name = "Shadowlands Raid (Achievements)", - keywords = {"shadowlands raid", "sl raid"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Shadowlands Raid" }}, - }, - { - name = "The Burning Crusade - Dungeons & Raids (Achievements)", - keywords = {"the burning crusade", "tbc"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "The Burning Crusade" }}, - }, - { - name = "War Within Dungeon (Achievements)", - keywords = {"war within dungeon", "tww dungeon"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "War Within Dungeon" }}, - }, - { - name = "War Within Raid (Achievements)", - keywords = {"war within raid", "tww raid"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "War Within Raid" }}, - }, - }, - }, - { - name = "Expansion Features (Achievements)", - keywords = {"expansion features", "expansion"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Expansion Features" }}, - children = { - { name = "Argent Tournament (Achievements)", keywords = {"argent tournament"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Argent Tournament" }} }, - { name = "Covenant Sanctums (Achievements)", keywords = {"covenant sanctums", "covenant", "sanctums"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Covenant Sanctums" }} }, - { name = "Draenor Garrison (Achievements)", keywords = {"draenor garrison", "garrison"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Draenor Garrison" }} }, - { name = "Heart of Azeroth (Achievements)", keywords = {"heart of azeroth", "hoa"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Heart of Azeroth" }} }, - { name = "Island Expeditions (Achievements)", keywords = {"island expeditions", "islands"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Island Expeditions" }} }, - { name = "Legion Class Hall (Achievements)", keywords = {"legion class hall", "class hall", "order hall"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Legion Class Hall" }} }, - { name = "Lorewalking (Achievements)", keywords = {"lorewalking"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Lorewalking" }} }, - { name = "Pandaria Scenarios (Achievements)", keywords = {"pandaria scenarios", "scenarios"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Pandaria Scenarios" }} }, - { name = "Prey (Achievements)", keywords = {"prey"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Prey" }} }, - { name = "Proving Grounds (Achievements)", keywords = {"proving grounds"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Proving Grounds" }} }, - { name = "Skyriding (Achievements)", keywords = {"skyriding", "dragonriding"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Skyriding" }} }, - { name = "Tol Barad (Achievements)", keywords = {"tol barad", "tb"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Tol Barad" }} }, - { name = "Torghast (Achievements)", keywords = {"torghast", "tower of the damned"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Torghast" }} }, - { name = "Visions of N'Zoth (Achievements)", keywords = {"visions of n'zoth", "visions", "nzoth"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Visions of N'Zoth" }} }, - { name = "Visions of N'Zoth Revisited (Achievements)", keywords = {"visions of n'zoth revisited", "nzoth revisited"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Visions of N'Zoth Revisited" }} }, - { name = "War Effort (Achievements)", keywords = {"war effort"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "War Effort" }} }, - { name = "Warfronts (Achievements)", keywords = {"warfronts", "warfront"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Warfronts" }} }, - }, - }, - { - name = "Exploration (Achievements)", - keywords = {"exploration", "explore", "explorer"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Exploration" }}, - children = { - { name = "Battle for Azeroth - Exploration (Achievements)", keywords = {"battle for azeroth", "bfa"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Battle for Azeroth" }} }, - { name = "Cataclysm - Exploration (Achievements)", keywords = {"cataclysm"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Cataclysm" }} }, - { name = "Draenor - Exploration (Achievements)", keywords = {"draenor"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Draenor" }} }, - { name = "Eastern Kingdoms - Exploration (Achievements)", keywords = {"eastern kingdoms"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Eastern Kingdoms" }} }, - { name = "Kalimdor - Exploration (Achievements)", keywords = {"kalimdor"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Kalimdor" }} }, - { name = "Legion - Exploration (Achievements)", keywords = {"legion"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Legion" }} }, - { name = "Midnight - Exploration (Achievements)", keywords = {"midnight"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Midnight" }} }, - { name = "Northrend - Exploration (Achievements)", keywords = {"northrend"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Northrend" }} }, - { name = "Outland - Exploration (Achievements)", keywords = {"outland"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Outland" }} }, - { name = "Pandaria - Exploration (Achievements)", keywords = {"pandaria"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Pandaria" }} }, - { name = "Shadowlands - Exploration (Achievements)", keywords = {"shadowlands"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Shadowlands" }} }, - { name = "Dragon Isles (Achievements)", keywords = {"dragon isles", "dragonflight"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Dragon Isles" }} }, - { name = "War Within - Exploration (Achievements)", keywords = {"war within"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "War Within" }} }, - }, - }, - { - name = "Feats of Strength (Achievements)", - keywords = {"feats of strength", "feats", "feat", "fos"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Feats of Strength" }}, - children = { - { name = "Delves - Feats of Strength (Achievements)", keywords = {"delves"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Delves" }} }, - { name = "Dungeons - Feats of Strength (Achievements)", keywords = {"dungeons", "dungeon"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Dungeons" }} }, - { name = "Events (Achievements)", keywords = {"events"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Events" }} }, - { name = "Mounts - Feats of Strength (Achievements)", keywords = {"mounts"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Mounts" }} }, - { name = "Player vs. Player - Feats of Strength (Achievements)", keywords = {"player vs. player", "pvp"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Player vs. Player" }} }, - { name = "Promotions (Achievements)", keywords = {"promotions"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Promotions" }} }, - { name = "Raids - Feats of Strength (Achievements)", keywords = {"raids", "raid"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Raids" }} }, - { name = "Reputation - Feats of Strength (Achievements)", keywords = {"reputation", "rep", "factions"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Reputation" }} }, - }, - }, - { - name = "Legacy (Achievements)", - keywords = {"legacy", "old", "removed"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Legacy" }}, - children = { - { name = "Character (Achievements)", keywords = {"character"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Character" }} }, - { name = "Currencies (Achievements)", keywords = {"currencies", "currency"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Currencies" }} }, - { name = "Dungeons - Legacy (Achievements)", keywords = {"dungeons", "dungeon"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Dungeons" }} }, - { name = "Expansion Features - Legacy (Achievements)", keywords = {"expansion features", "expansion"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Expansion Features" }} }, - { name = "Legion Remix (Achievements)", keywords = {"legion remix", "legion", "remix"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Legion Remix" }} }, - { name = "Player vs. Player - Legacy (Achievements)", keywords = {"player vs. player", "pvp"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Player vs. Player" }} }, - { name = "Professions - Legacy (Achievements)", keywords = {"professions", "profession", "crafting"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Professions" }} }, - { name = "Quests - Legacy (Achievements)", keywords = {"quests", "quest"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Quests" }} }, - { name = "Raids - Legacy (Achievements)", keywords = {"raids", "raid"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Raids" }} }, - { name = "Remix: Mists of Pandaria (Achievements)", keywords = {"remix: mists of pandaria", "remix", "mists", "pandaria"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Remix: Mists of Pandaria" }} }, - { name = "World Events - Legacy (Achievements)", keywords = {"world events", "holidays", "seasonal"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "World Events" }} }, - }, - }, - { name = "Pet Battles (Achievements)", keywords = {"pet battles", "pets", "battle pets"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Pet Battles" }}, - children = { - { name = "Battle (Achievements)", keywords = {"battle"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Battle" }} }, - { name = "Collect (Achievements)", keywords = {"collect"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Collect" }} }, - { name = "Level (Achievements)", keywords = {"level"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Level" }} }, - }, - }, - { - name = "Player vs. Player (Achievements)", - keywords = {"player vs. player", "pvp"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Player vs. Player" }}, - children = { - { name = "Alterac Valley (Achievements)", keywords = {"alterac valley", "av"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Alterac Valley" }} }, - { name = "Arathi Basin (Achievements)", keywords = {"arathi basin", "ab"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Arathi Basin" }} }, - { name = "Arena (Achievements)", keywords = {"arena", "arenas"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Arena" }} }, - { name = "Ashran (Achievements)", keywords = {"ashran"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Ashran" }} }, - { name = "Battle for Gilneas (Achievements)", keywords = {"battle for gilneas", "gilneas"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Battle for Gilneas" }} }, - { name = "Deephaul Ravine (Achievements)", keywords = {"deephaul ravine", "deephaul"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Deephaul Ravine" }} }, - { name = "Deepwind Gorge (Achievements)", keywords = {"deepwind gorge", "deepwind"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Deepwind Gorge" }} }, - { name = "Eye of the Storm (Achievements)", keywords = {"eye of the storm", "eots"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Eye of the Storm" }} }, - { name = "Honor (Achievements)", keywords = {"honor"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Honor" }} }, - { name = "Isle of Conquest (Achievements)", keywords = {"isle of conquest", "ioc"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Isle of Conquest" }} }, - { name = "Rated Battleground (Achievements)", keywords = {"rated battleground", "rated"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Rated Battleground" }} }, - { name = "Seething Shore (Achievements)", keywords = {"seething shore", "seething"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Seething Shore" }} }, - { name = "Silvershard Mines (Achievements)", keywords = {"silvershard mines", "silvershard"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Silvershard Mines" }} }, - { name = "Temple of Kotmogu (Achievements)", keywords = {"temple of kotmogu", "kotmogu"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Temple of Kotmogu" }} }, - { name = "Training Grounds (Achievements)", keywords = {"training grounds", "training"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Training Grounds" }} }, - { name = "Twin Peaks (Achievements)", keywords = {"twin peaks"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Twin Peaks" }} }, - { name = "Warsong Gulch (Achievements)", keywords = {"warsong gulch", "wsg"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Warsong Gulch" }} }, - { name = "Wintergrasp (Achievements)", keywords = {"wintergrasp", "wg"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Wintergrasp" }} }, - { name = "World (Achievements)", keywords = {"world"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "World" }} }, - }, - }, - { - name = "Professions (Achievements)", - keywords = {"professions", "profession", "crafting"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Professions" }}, - children = { - { name = "Alchemy (Achievements)", keywords = {"alchemy"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Alchemy" }} }, - { name = "Archaeology (Achievements)", keywords = {"archaeology"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Archaeology" }} }, - { name = "Blacksmithing (Achievements)", keywords = {"blacksmithing"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Blacksmithing" }} }, - { name = "Cooking (Achievements)", keywords = {"cooking"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Cooking" }} }, - { name = "Enchanting (Achievements)", keywords = {"enchanting"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Enchanting" }} }, - { name = "Engineering (Achievements)", keywords = {"engineering"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Engineering" }} }, - { name = "Fishing (Achievements)", keywords = {"fishing"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Fishing" }} }, - { name = "First Aid (Achievements)", keywords = {"first aid"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "First Aid" }} }, - { name = "Herbalism (Achievements)", keywords = {"herbalism"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Herbalism" }} }, - { name = "Inscription (Achievements)", keywords = {"inscription"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Inscription" }} }, - { name = "Jewelcrafting (Achievements)", keywords = {"jewelcrafting"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Jewelcrafting" }} }, - { name = "Leatherworking (Achievements)", keywords = {"leatherworking"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Leatherworking" }} }, - { name = "Mining (Achievements)", keywords = {"mining"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Mining" }} }, - { name = "Skinning (Achievements)", keywords = {"skinning"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Skinning" }} }, - { name = "Tailoring (Achievements)", keywords = {"tailoring"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Tailoring" }} }, - }, - }, - { - name = "Quests (Achievements)", - keywords = {"quests", "quest"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Quests" }}, - children = { - { name = "Battle for Azeroth - Quests (Achievements)", keywords = {"battle for azeroth", "bfa"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Battle for Azeroth" }} }, - { name = "Cataclysm - Quests (Achievements)", keywords = {"cataclysm"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Cataclysm" }} }, - { name = "Draenor - Quests (Achievements)", keywords = {"draenor"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Draenor" }} }, - { name = "Dragonflight - Quests (Achievements)", keywords = {"dragonflight"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Dragonflight" }} }, - { name = "Eastern Kingdoms - Quests (Achievements)", keywords = {"eastern kingdoms"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Eastern Kingdoms" }} }, - { name = "Kalimdor - Quests (Achievements)", keywords = {"kalimdor"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Kalimdor" }} }, - { name = "Legion - Quests (Achievements)", keywords = {"legion"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Legion" }} }, - { name = "Midnight - Quests (Achievements)", keywords = {"midnight"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Midnight" }} }, - { name = "Northrend - Quests (Achievements)", keywords = {"northrend"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Northrend" }} }, - { name = "Outland - Quests (Achievements)", keywords = {"outland"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Outland" }} }, - { name = "Pandaria - Quests (Achievements)", keywords = {"pandaria"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Pandaria" }} }, - { name = "Shadowlands - Quests (Achievements)", keywords = {"shadowlands"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Shadowlands" }} }, - { name = "The Dragon Isles - Quests (Achievements)", keywords = {"dragon isles", "dragonflight"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "The Dragon Isles" }} }, - { name = "The War Within - Quests (Achievements)", keywords = {"war within"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "The War Within" }} }, - }, - }, - { - name = "Reputation (Achievements)", - keywords = {"reputation", "rep", "factions"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Reputation" }}, - children = { - { name = "Battle for Azeroth - Reputation (Achievements)", keywords = {"battle for azeroth", "bfa"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Battle for Azeroth" }} }, - { name = "Cataclysm - Reputation (Achievements)", keywords = {"cataclysm"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Cataclysm" }} }, - { name = "Classic - Reputation (Achievements)", keywords = {"classic"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Classic" }} }, - { name = "Draenor - Reputation (Achievements)", keywords = {"draenor"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Draenor" }} }, - { name = "Dragonflight - Reputation (Achievements)", keywords = {"dragonflight"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Dragonflight" }} }, - { name = "Legion - Reputation (Achievements)", keywords = {"legion"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Legion" }} }, - { name = "Midnight - Reputation (Achievements)", keywords = {"midnight"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Midnight" }} }, - { name = "Northrend - Reputation (Achievements)", keywords = {"northrend"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Northrend" }} }, - { name = "Outland - Reputation (Achievements)", keywords = {"outland"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Outland" }} }, - { name = "Pandaria - Reputation (Achievements)", keywords = {"pandaria"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Pandaria" }} }, - { name = "Shadowlands - Reputation (Achievements)", keywords = {"shadowlands"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Shadowlands" }} }, - { name = "The Burning Crusade - Reputation (Achievements)", keywords = {"the burning crusade", "tbc"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "The Burning Crusade" }} }, - { name = "The Dragon Isles - Reputation (Achievements)", keywords = {"dragon isles", "dragonflight"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "The Dragon Isles" }} }, - { name = "The War Within - Reputation (Achievements)", keywords = {"war within"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "The War Within" }} }, - { name = "Wrath of the Lich King (Achievements)", keywords = {"wrath of the lich king", "wrath", "wotlk"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Wrath of the Lich King" }} }, - }, - }, - { - name = "World Events (Achievements)", - keywords = {"world events", "holidays", "seasonal"}, - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "World Events" }}, - children = { - { name = "Anniversary Celebration (Achievements)", keywords = {"anniversary celebration", "anniversary"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Anniversary Celebration" }} }, - { name = "Brawler's Guild (Achievements)", keywords = {"brawler's guild", "brawler"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Brawler's Guild" }} }, - { name = "Brewfest (Achievements)", keywords = {"brewfest"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Brewfest" }} }, - { name = "Children's Week (Achievements)", keywords = {"children's week"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Children's Week" }} }, - { name = "Dastardly Duos (Achievements)", keywords = {"dastardly duos", "dastardly"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Dastardly Duos" }} }, - { name = "Day of the Dead (Achievements)", keywords = {"day of the dead"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Day of the Dead" }} }, - { name = "Darkmoon Faire (Achievements)", keywords = {"darkmoon faire", "darkmoon"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Darkmoon Faire" }} }, - { name = "Feast of Winter Veil (Achievements)", keywords = {"feast of winter veil", "winter veil", "christmas"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Feast of Winter Veil" }} }, - { name = "Hallow's End (Achievements)", keywords = {"hallow's end", "halloween"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Hallow's End" }} }, - { name = "Lunar Festival (Achievements)", keywords = {"lunar festival"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Lunar Festival" }} }, - { name = "Love is in the Air (Achievements)", keywords = {"love is in the air", "valentine"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Love is in the Air" }} }, - { name = "Midsummer (Achievements)", keywords = {"midsummer", "midsummer fire festival"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Midsummer" }} }, - { name = "Noblegarden (Achievements)", keywords = {"noblegarden", "easter"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Noblegarden" }} }, - { name = "Pilgrim's Bounty (Achievements)", keywords = {"pilgrim's bounty", "thanksgiving"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Pilgrim's Bounty" }} }, - { name = "Timewalking (Achievements)", keywords = {"timewalking"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Timewalking" }} }, - { name = "Winter Veil (Achievements)", keywords = {"winter veil"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Winter Veil" }} }, - }, - }, - }, - }, - - -- GUILD ACHIEVEMENTS (Tab 2) - { - name = "Guild Achievements", - keywords = {"guild achievements", "guild tab", "guild points"}, - category = "Achievements", - steps = {{ waitForFrame = "AchievementFrame", tabIndex = 2 }}, - children = { - -- GUILD ACHIEVEMENT CATEGORIES (Auto-generated by Harvester) - { - name = "Guild: Dungeons & Raids", - keywords = {"dungeons & raids", "dungeons", "raids"}, - category = "Guild Achievements", - steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Dungeons & Raids" }}, - children = { - { name = "Guild: Battle Dungeon", keywords = {"battle dungeon"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Battle Dungeon" }} }, - { name = "Guild: Battle Raid", keywords = {"battle raid"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Battle Raid" }} }, - { name = "Guild: Cataclysm Dungeon", keywords = {"cataclysm dungeon", "cata dungeon"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Cataclysm Dungeon" }} }, - { name = "Guild: Cataclysm Raid", keywords = {"cataclysm raid", "cata raid"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Cataclysm Raid" }} }, - { name = "Guild: Classic", keywords = {"classic"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Classic" }} }, - { name = "Guild: Draenor Dungeon", keywords = {"draenor dungeon", "wod dungeon"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Draenor Dungeon" }} }, - { name = "Guild: Draenor Raid", keywords = {"draenor raid", "wod raid"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Draenor Raid" }} }, - { name = "Guild: Dragonflight Dungeon", keywords = {"dragonflight dungeon", "df dungeon"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Dragonflight Dungeon" }} }, - { name = "Guild: Dragonflight Raid", keywords = {"dragonflight raid", "df raid"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Dragonflight Raid" }} }, - { name = "Guild: Legion Dungeon", keywords = {"legion dungeon"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Legion Dungeon" }} }, - { name = "Guild: Legion Raid", keywords = {"legion raid"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Legion Raid" }} }, - { name = "Guild: Lich King Dungeon", keywords = {"lich king dungeon", "wotlk dungeon"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Lich King Dungeon" }} }, - { name = "Guild: Lich King Raid", keywords = {"lich king raid", "wotlk raid"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Lich King Raid" }} }, - { name = "Guild: Midnight Dungeon", keywords = {"midnight dungeon"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Midnight Dungeon" }} }, - { name = "Guild: Midnight Raid", keywords = {"midnight raid"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Midnight Raid" }} }, - { name = "Guild: Pandaria Dungeon", keywords = {"pandaria dungeon", "mop dungeon"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Pandaria Dungeon" }} }, - { name = "Guild: Pandaria Raid", keywords = {"pandaria raid", "mop raid"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Pandaria Raid" }} }, - { name = "Guild: Shadowlands Dungeon", keywords = {"shadowlands dungeon", "sl dungeon"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Shadowlands Dungeon" }} }, - { name = "Guild: Shadowlands Raid", keywords = {"shadowlands raid", "sl raid"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Shadowlands Raid" }} }, - { name = "Guild: The Burning Crusade", keywords = {"the burning crusade", "tbc"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "The Burning Crusade" }} }, - { name = "Guild: War Within Dungeon", keywords = {"war within dungeon", "tww dungeon"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "War Within Dungeon" }} }, - { name = "Guild: War Within Raid", keywords = {"war within raid", "tww raid"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "War Within Raid" }} }, - }, - }, - { name = "Guild: General", keywords = {"general"}, category = "Guild Achievements", steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "General" }} }, - { name = "Guild: Guild Feats of Strength", keywords = {"guild feats of strength", "guild feats", "fos"}, category = "Guild Achievements", steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Guild Feats of Strength" }} }, - { name = "Guild: Player vs. Player", keywords = {"player vs. player", "pvp", "guild pvp"}, category = "Guild Achievements", steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Player vs. Player" }}, - children = { - { name = "Guild: Arena", keywords = {"arena"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Arena" }} }, - { name = "Guild: Battlegrounds", keywords = {"battlegrounds", "bg"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Battlegrounds" }} }, - }, - }, - { name = "Guild: Professions", keywords = {"professions", "profession", "crafting"}, category = "Guild Achievements", steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Professions" }} }, - { name = "Guild: Quests", keywords = {"quests", "quest"}, category = "Guild Achievements", steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Quests" }} }, - { name = "Guild: Reputation", keywords = {"reputation", "rep", "factions"}, category = "Guild Achievements", steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Reputation" }} }, - }, + -- Category entries are emitted at runtime by + -- Database:PopulateDynamicAchievements() using + -- GetCategoryList() / GetCategoryInfo() so the search + -- index always matches the live AchievementFrame + -- sidebar for the current build. }, -- STATISTICS (Tab 3) @@ -3136,82 +2957,15 @@ function Database:BuildUIDatabase() keywords = {"statistics", "stats tab", "player statistics"}, category = "Achievements", steps = {{ waitForFrame = "AchievementFrame", tabIndex = 3 }}, + -- Statistics category entries are emitted at runtime by + -- Database:PopulateDynamicStatistics() using + -- GetStatisticsCategoryList() / GetCategoryInfo() so the + -- search index always matches the live AchievementFrame + -- Statistics sidebar for the current build. children = { - -- STATISTICS CATEGORIES (Auto-generated by Harvester) - { - name = "Character Statistics", - keywords = {"character"}, - category = "Statistics", - steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Character" }}, - children = { - { name = "Consumables Statistics", keywords = {"consumables"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Consumables" }} }, - { name = "Wealth Statistics", keywords = {"wealth"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Wealth" }} }, - }, - }, - { name = "Kills Statistics", keywords = {"kills", "kill count"}, category = "Statistics", steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Kills" }} }, - { name = "Deaths Statistics", keywords = {"deaths"}, category = "Statistics", steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Deaths" }} }, - { name = "Quests Statistics", keywords = {"quests", "quest count"}, category = "Statistics", steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Quests" }} }, - { name = "Skills Statistics", keywords = {"skills"}, category = "Statistics", steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Skills" }} }, - { name = "Travel Statistics", keywords = {"travel", "distance", "flight paths"}, category = "Statistics", steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Travel" }} }, - { name = "Social Statistics", keywords = {"social", "friends", "groups"}, category = "Statistics", steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Social" }} }, - { name = "Delves Statistics", keywords = {"delves"}, category = "Statistics", steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Delves" }} }, - { - name = "Combat Statistics", - keywords = {"combat"}, - category = "Statistics", - steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Combat" }}, - children = { - { name = "Buffs Statistics", keywords = {"buffs"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Buffs" }} }, - { name = "Damage Statistics", keywords = {"damage"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Damage" }} }, - { name = "Healing Statistics", keywords = {"healing"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Healing" }} }, - }, - }, - { - name = "Dungeons & Raids Statistics", - keywords = {"dungeons & raids", "dungeons", "raids"}, - category = "Statistics", - steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Dungeons & Raids" }}, - children = { - { name = "Lich King - D&R Statistics", keywords = {"lich king", "wotlk"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Lich King" }} }, - { name = "Cataclysm - D&R Statistics", keywords = {"cataclysm"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Cataclysm" }} }, - { name = "Pandaria - D&R Statistics", keywords = {"pandaria"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Pandaria" }} }, - { name = "Draenor - D&R Statistics", keywords = {"draenor"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Draenor" }} }, - { name = "Legion - D&R Statistics", keywords = {"legion"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Legion" }} }, - { name = "Battle for Azeroth - D&R Statistics", keywords = {"battle for azeroth", "bfa"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Battle for Azeroth" }} }, - { name = "Shadowlands - D&R Statistics", keywords = {"shadowlands"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Shadowlands" }} }, - { name = "Dragonflight - D&R Statistics", keywords = {"dragonflight"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Dragonflight" }} }, - { name = "The War Within - D&R Statistics", keywords = {"war within"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "The War Within" }} }, - }, - }, - { - name = "Player vs. Player Statistics", - keywords = {"player vs. player", "pvp"}, - category = "Statistics", - steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Player vs. Player" }}, - children = { - { name = "Rated Arenas Statistics", keywords = {"arena", "rated arenas"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Rated Arenas" }} }, - { - name = "Battlegrounds Statistics", - keywords = {"battlegrounds", "bg"}, - steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Battlegrounds" }}, - children = { - { name = "Alterac Valley Statistics", keywords = {"alterac valley", "av"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Alterac Valley" }} }, - { name = "Arathi Basin Statistics", keywords = {"arathi basin", "ab"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Arathi Basin" }} }, - { name = "Eye of the Storm Statistics", keywords = {"eye of the storm", "eots"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Eye of the Storm" }} }, - { name = "Strand of the Ancients Statistics", keywords = {"strand of the ancients", "sota"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Strand of the Ancients" }} }, - { name = "Warsong Gulch Statistics", keywords = {"warsong gulch", "wsg"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Warsong Gulch" }} }, - { name = "Wintergrasp Statistics", keywords = {"wintergrasp", "wg"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Wintergrasp" }} }, - }, - }, - { name = "Rated Battlegrounds Statistics", keywords = {"rated battlegrounds", "rbg"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Rated Battlegrounds" }} }, - { name = "World Statistics", keywords = {"world"}, steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "World" }} }, - }, - }, - { name = "Pet Battles Statistics", keywords = {"pet battles", "battle pets"}, category = "Statistics", steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Pet Battles" }} }, - { name = "Proving Grounds Statistics", keywords = {"proving grounds"}, category = "Statistics", steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Proving Grounds" }} }, - { name = "Legacy Statistics", keywords = {"legacy"}, category = "Statistics", steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "Legacy" }} }, - { name = "World Events Statistics", keywords = {"world events", "holidays"}, category = "Statistics", steps = {{ waitForFrame = "AchievementFrame", statisticsCategory = "World Events" }} }, - -- Manual entry: Duel Statistics (specific deep navigation) + -- Manual: Duel Statistics is a specific row inside + -- Player vs. Player > World, not a sidebar category, + -- so the API walk doesn't surface it as a row. { name = "Duel Statistics", keywords = {"duel", "duels", "dueling", "1v1", "duels won", "duels lost"}, diff --git a/DatabaseDynamic.lua b/DatabaseDynamic.lua index 2d537e9..7826f26 100644 --- a/DatabaseDynamic.lua +++ b/DatabaseDynamic.lua @@ -6,8 +6,10 @@ local Utils = ns.Utils if not Database then return end local dynamicProviders = { - { key = "currencies", category = "Currency", fn = "PopulateDynamicCurrencies" }, - { key = "reputations", category = "Reputation", fn = "PopulateDynamicReputations" }, + { key = "currencies", category = "Currency", fn = "PopulateDynamicCurrencies" }, + { key = "reputations", category = "Reputation", fn = "PopulateDynamicReputations" }, + { key = "achievements", category = "Achievement Category", fn = "PopulateDynamicAchievements" }, + { key = "statistics", category = "Statistic", fn = "PopulateDynamicStatistics" }, { key = "mounts", category = "Mount", fn = "PopulateDynamicMounts" }, { key = "toys", category = "Toy", fn = "PopulateDynamicToys" }, { key = "pets", category = "Pet", fn = "PopulateDynamicPets" }, @@ -94,6 +96,13 @@ function Database:CancelDynamicWarmup() if self.CancelDynamicScans then self:CancelDynamicScans() end end +function Database:MarkDynamicProviderLoaded(key) + local provider = dynamicProviderByKey[key] + if not provider then return end + provider.loaded = true + provider.dirty = false +end + function Database:MarkDynamicCategoryDirty(key) local provider = dynamicProviderByKey[key] if not provider then return end diff --git a/Highlight.lua b/Highlight.lua index d9d8cf6..0ae6d8a 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -692,6 +692,41 @@ function Highlight:UpdateGuide() return end + -- Statistic terminal step. AchievementFrameStats.ScrollBox's + -- data provider stores entries shaped { id, colorIndex, header } + -- where `id` is the statistic ID. Match on that, scroll the + -- row into view, then highlight the rendered button. + if step.statisticID then + if not (AchievementFrame and AchievementFrame:IsShown()) then + self:Cancel() + return + end + local stats = _G["AchievementFrameStats"] + local box = stats and stats.ScrollBox + if not box then return end + + local function matches(data) + return type(data) == "table" and data.id == step.statisticID + end + + local align = ScrollBoxConstants and ScrollBoxConstants.AlignCenter or 0.5 + if box.ScrollToElementDataByPredicate then + pcall(box.ScrollToElementDataByPredicate, box, matches, align) + end + + local btn = ScrollBoxFindButton(box, function(b) + local data = b.GetElementData and b:GetElementData() + return matches(data) + end) + if btn then + self:HighlightFrame(btn) + if isLastStep and canHoverDismiss() and btn:IsMouseOver() then + self:Cancel() + end + end + return + end + -- Achievement-by-ID terminal step. Drive Blizzard's own -- OpenAchievementFrameToAchievement to scroll the -- AchievementFrameAchievements.ScrollBox to the row, then diff --git a/Options.lua b/Options.lua index c929852..9e77c19 100644 --- a/Options.lua +++ b/Options.lua @@ -26,11 +26,11 @@ local FRAME_BACKDROP = { local NIL = {} local DEFAULT_UI_FILTERS = { - ui = true, achievements = true, currencies = true, reputations = true, - collections = true, mounts = true, toys = true, pets = true, - outfits = true, heirlooms = true, loot = true, appearanceSets = true, - bags = true, macros = true, options = true, abilities = true, - bosses = true, map = true, + ui = true, achievements = true, statistics = true, currencies = true, + reputations = true, collections = true, mounts = true, toys = true, + pets = true, outfits = true, heirlooms = true, loot = true, + appearanceSets = true, bags = true, macros = true, options = true, + abilities = true, bosses = true, map = true, } local DEFAULT_GLOBAL_SEARCH_FILTERS = { zones = true, dungeons = true, raids = true, delves = true } local DEFAULT_LOCAL_SEARCH_FILTERS = { instances = true, travel = true, services = true, rares = true } diff --git a/UI.lua b/UI.lua index d30c6b8..939929f 100644 --- a/UI.lua +++ b/UI.lua @@ -180,6 +180,7 @@ local FLAT_CATEGORY_ICONS = { appearanceSet = { tex = "Interface\\Icons\\INV_Helmet_03" }, currency = { tex = 136452 }, -- Same coin/AH glyph the map uses reputation = { tex = 1121272, coords = { 0.3783, 0.4072, 0.9066, 0.9350 } }, + statistic = { tex = 1121272, coords = { 0.1997, 0.2437, 0.5933, 0.6266 } }, map = { tex = 1121272, coords = { 0.3457, 0.3856, 0.2549, 0.2951 } }, -- Ability / boss: matches the filter-menu icons (boss tab + overview tab -- glyphs from the Encounter Journal spritesheet). The row's per-entry @@ -252,6 +253,7 @@ local function GetFlatCategoryIcon(data) if data.category == "Game Settings" then return FLAT_CATEGORY_ICONS.setting end if data.category == "AddOn Settings" then return FLAT_CATEGORY_ICONS.settingAddon end if data.category == "Currency" then return FLAT_CATEGORY_ICONS.currency end + if data.statisticID or data.category == "Statistic" then return FLAT_CATEGORY_ICONS.statistic end if data.titleID then return FLAT_CATEGORY_ICONS.title end if data.gearSetID then ResolveGearSetIcon() @@ -2089,6 +2091,8 @@ local UI_FILTER_OPTIONS = { }, } }, { key = "achievements", label = "Achievements", iconAtlas = "UI-HUD-MicroMenu-Achievements-Up" }, + { key = "statistics", label = "Statistics", iconTex = 1121272, + iconCoords = { 0.1997, 0.2437, 0.5933, 0.6266 } }, { key = "bags", label = "Bags", iconAtlas = "bag-main" }, -- Bosses: EJ overview tab icon from texture 522972. { key = "bosses", label = "Bosses", iconTex = 522972, @@ -2181,10 +2185,12 @@ end local UI_BUCKET_BY_CATEGORY = { ["Ability"] = "abilities", ["Boss"] = "bosses", - ["Achievement"] = "achievements", - ["Achievements"] = "achievements", - ["Guild Achievements"] = "achievements", - ["Statistics"] = "achievements", + ["Achievement"] = "achievements", + ["Achievements"] = "achievements", + ["Achievement Category"] = "achievements", + ["Guild Achievements"] = "achievements", + ["Statistics"] = "statistics", + ["Statistic"] = "statistics", ["Currency"] = "currencies", ["Reputation"] = "reputations", ["Bag"] = "bags", @@ -7473,6 +7479,55 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.text:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) iconSet = true + -- Statistic rows: show the live stat value inline via amountText. + -- GetStatistic returns a string ("394", "23%", "1d 4h 12m") or + -- "--" for stats with no recorded value yet. + elseif data and data.statisticID and not entry.isPathNode then + local value + if GetStatistic then + local ok, v = pcall(GetStatistic, data.statisticID) + if ok then value = v end + end + if value and value ~= "" and value ~= "--" then + resultRow.amountText:SetText(value) + resultRow.amountText:SetTextColor(0.9, 0.82, 0.65, 1.0) + else + resultRow.amountText:SetText("--") + resultRow.amountText:SetTextColor(0.5, 0.5, 0.5, 1.0) + end + resultRow.amountText:ClearAllPoints() + resultRow.amountText:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) + resultRow.amountText:Show() + SetRowIcon(resultRow, "hidden", nil, rowIconSize) + + local indentPixels = depth * indPx + local leftAnchor + local catIconDef = FLAT_CATEGORY_ICONS.statistic + if catIconDef and resultRow.flatCatIcon then + local sz = entry.isFlat and (entryRowH - 16) or rowIconSize + resultRow.flatCatIcon:SetTexture(catIconDef.tex) + if catIconDef.coords then + resultRow.flatCatIcon:SetTexCoord(catIconDef.coords[1], catIconDef.coords[2], + catIconDef.coords[3], catIconDef.coords[4]) + else + resultRow.flatCatIcon:SetTexCoord(0, 1, 0, 1) + end + resultRow.flatCatIcon:SetVertexColor(1, 1, 1, 1) + resultRow.flatCatIcon:SetSize(sz, sz) + resultRow.flatCatIcon:ClearAllPoints() + resultRow.flatCatIcon:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) + resultRow.flatCatIcon:Show() + leftAnchor = resultRow.flatCatIcon + end + resultRow.text:ClearAllPoints() + if leftAnchor then + resultRow.text:SetPoint("LEFT", leftAnchor, "RIGHT", 4, 0) + else + resultRow.text:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) + end + resultRow.text:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) + 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 or data.outfitID or data.heirloomItemID or data.transmogSetID or (data.spellID and data.category == "Ability") or (data.spellID and data.category == "Talent") or (data.encounterID and data.category == "Boss") or (data.macroIndex and data.category == "Macro") or (data.bagID and data.category == "Bag")) then local iconFileID = data.icon From fc2dc2adad9a57e32d30c40939daa0e45aa3280b Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 17:58:35 -0400 Subject: [PATCH 078/103] Stats: stop duplicate stat rows in achievement search Two leak paths were emitting stat-tracker rows under the Achievements filter alongside the dedicated Statistics rows: 1. PopulateDynamicAchievements walked GetCategoryList(), which in 12.0 contains every individual stat-row achievement ID using the stat name as its "category" name. Extend the exclusion set to cover the per-stat IDs inside each statistics category, not just the sidebar categories themselves. 2. UI.lua's inline achievement search (SetAchievementSearchString) surfaces every achievement matching the query, including stat trackers. Filter results against Database.statisticIDs so they appear only as book-icon Statistics rows. Also wires the missing filters.statistics check into the bucket-aware filter guard so toggling the Statistics chip off actually drops the stat entries. --- Database.lua | 39 ++++++++++++++++++++++++++++++++------- UI.lua | 6 ++++-- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/Database.lua b/Database.lua index 4e38133..0ee63e5 100644 --- a/Database.lua +++ b/Database.lua @@ -2591,22 +2591,33 @@ function Database:PopulateDynamicAchievements() local categories = GetCategoryList() if not categories then return false end - -- 12.0's GetCategoryList returns the union of achievement + - -- statistics category IDs. Subtract the statistics set so we only - -- emit real achievement-tab sidebar entries here; statistics get - -- their own dynamic populator. - local statsCategorySet = {} + -- 12.0's GetCategoryList returns the union of achievement category + -- IDs AND every individual stat-row ID (each stat row is itself an + -- achievement ID and appears in this list with its stat name as + -- the "category" name). Build an exclusion set covering both: + -- every sidebar stats category + every stat row inside them. + local statsExclude = {} if GetStatisticsCategoryList then local statCats = GetStatisticsCategoryList() if statCats then - for i = 1, #statCats do statsCategorySet[statCats[i]] = true end + for i = 1, #statCats do + local statCatID = statCats[i] + statsExclude[statCatID] = true + if GetCategoryNumAchievements and GetAchievementInfo then + local total = GetCategoryNumAchievements(statCatID, true) + for j = 1, (total or 0) do + local statID = GetAchievementInfo(statCatID, j) + if statID then statsExclude[statID] = true end + end + end + end end end local catMap = {} for i = 1, #categories do local catID = categories[i] - if not statsCategorySet[catID] then + if not statsExclude[catID] then local name, parentID, flags = GetCategoryInfo(catID) if name and name ~= "" then catMap[catID] = { @@ -2672,9 +2683,22 @@ end -- Walks GetStatisticsCategoryList()/GetCategoryInfo() to emit the -- Statistics tab's category tree. Same shape as achievements but -- targets the Statistics tab (tabIndex = 3). +-- Set of achievement IDs that are statistic-tracker achievements. +-- Populated by PopulateDynamicStatistics; consumed by callers that +-- enumerate achievements via Blizzard's APIs (e.g. UI.lua's inline +-- achievement search) so they can filter stat-trackers out -- those +-- show up under their own dedicated Statistics filter, not as +-- duplicate "Achievement" rows. +Database.statisticIDs = Database.statisticIDs or {} + +function Database:IsStatisticAchievement(achievementID) + return Database.statisticIDs[achievementID] == true +end + function Database:PopulateDynamicStatistics() if not GetStatisticsCategoryList or not GetCategoryInfo then return false end RemoveEntriesByCategory("Statistic") + wipe(Database.statisticIDs) local categories = GetStatisticsCategoryList() if not categories then return false end @@ -2726,6 +2750,7 @@ function Database:PopulateDynamicStatistics() for i = 1, (total or 0) do local id, title = GetAchievementInfo(cat.id, i) if id and title and title ~= "" then + Database.statisticIDs[id] = true local steps = {} for s = 1, #baseSteps do steps[s] = baseSteps[s] end -- Leaf step: ONLY statisticID + statisticName. diff --git a/UI.lua b/UI.lua index 939929f..c7142cb 100644 --- a/UI.lua +++ b/UI.lua @@ -4512,9 +4512,11 @@ local function CollectAchievementSearchResults(query) if count == 0 then return {} end local capped = count > ACH_MAX_RESULTS and ACH_MAX_RESULTS or count local results = {} + local isStat = ns.Database and ns.Database.IsStatisticAchievement + and function(id) return ns.Database:IsStatisticAchievement(id) end for i = 1, capped do local id = getID(i) - if id then + if id and not (isStat and isStat(id)) then local _, name, _, _, _, _, _, _, _, icon = getInfo(id) if name and name ~= "" then results[#results + 1] = GetOrCreateAchievementEntry(id, name, icon) @@ -6498,7 +6500,7 @@ function UI:OnSearchTextChanged(text, force) local hidePassives = EasyFind.db.abilityHidePassives if filters and (filters.ui == false or filters.abilities == false or filters.bosses == false - or filters.achievements == false + or filters.achievements == false or filters.statistics == false or filters.currencies == false or filters.reputations == false or filters.bags == false or filters.macros == false or filters.options == false From 1ac75d278704d8c29d30343ec175176572b59746 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 18:52:34 -0400 Subject: [PATCH 079/103] Settings: unapplied-changes popup wiring + Apply path fixes ESC and click-outside both now route through ShowUnappliedSettingsPopup, a shared helper that finds the popup's actual StaticPopup slot (1..4) and lifts its strata to TOOLTIP so it renders above the FULLSCREEN_DIALOG results panel. The popup preempts text-clear and panel-close on ESC so Cancel preserves the exact pre-ESC state (text, scroll, pending change). StaticPopup1..4 added to the click-outside guards so clicking the popup buttons doesn't register as outside-the-results. ApplyPendingChanges now reuses ApplyVariable per pending setting (the same proven path the per-row Apply button uses) instead of the SettingsPanel.modified + CommitSettings approach which silently no-oped from addon code. Apply and Exit / Apply now actually apply. Per-row Apply / Reset buttons refocus the search editbox after running so the click doesn't steal focus. Inline setting dropdown button toggles -- a second click on the same dropdown closes the popup instead of being a no-op. --- BlizzOptionsSearch.lua | 21 ++++++---- UI.lua | 91 +++++++++++++++++++++++++++++++----------- 2 files changed, 82 insertions(+), 30 deletions(-) diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index ae7c1e8..88a710c 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -785,13 +785,20 @@ end -- in the batch (monitor, resolution, etc.). function BlizzOptionsSearch:ApplyPendingChanges() if not next(pendingApplySettings) then return end - if SettingsPanel and SettingsPanel.modified and SettingsPanel.CommitSettings then - for setting in pairs(pendingApplySettings) do - SettingsPanel.modified[setting] = setting + -- Apply each pending setting via the same path the per-row Apply + -- button uses (ApplyVariable). That path is proven to actually + -- commit the value -- per-setting Commit() and SettingsPanel + -- writes both silently no-op on some build paths from addon code. + -- Snapshot variables first so wiping pendingApplySettings inside + -- ApplyVariable doesn't break iteration. + local vars = {} + for setting in pairs(pendingApplySettings) do + if setting.GetVariable then + local ok, v = pcall(setting.GetVariable, setting) + if ok and v then vars[#vars + 1] = v end end - pcall(SettingsPanel.CommitSettings, SettingsPanel, false) - LiftPopupAndRefresh(FindStaticPopupSlot("GAME_SETTINGS_TIMED_CONFIRMATION")) end + for i = 1, #vars do self:ApplyVariable(vars[i]) end wipe(pendingApplySettings) FirePendingChanged() end @@ -950,13 +957,13 @@ if StaticPopupDialogs and not StaticPopupDialogs["EASYFIND_UNAPPLIED_SETTINGS"] if BlizzOptionsSearch.RevertPendingChanges then BlizzOptionsSearch:RevertPendingChanges() end - if ns.UI and ns.UI.HideResults then ns.UI:HideResults() end + if ns.UI and ns.UI.Hide then ns.UI:Hide() end end, OnButton2 = function() if BlizzOptionsSearch.ApplyPendingChanges then BlizzOptionsSearch:ApplyPendingChanges() end - if ns.UI and ns.UI.HideResults then ns.UI:HideResults() end + if ns.UI and ns.UI.Hide then ns.UI:Hide() end end, OnButton3 = function() end, OnHide = function() diff --git a/UI.lua b/UI.lua index c7142cb..ddcf96c 100644 --- a/UI.lua +++ b/UI.lua @@ -4343,6 +4343,12 @@ function UI:CreateResultsFrame() _G["EasyFindDiffPopup"], _G["EasyFindSpecPopup"], _G["EasyFindSpecFlyout"], + -- Blizzard's StaticPopup slots: clicks on our unapplied- + -- settings popup buttons (Apply / Exit / Cancel) must not + -- register as "outside" or they'd trigger an extra + -- RequestHideResults that closes the panel. + _G["StaticPopup1"], _G["StaticPopup2"], + _G["StaticPopup3"], _G["StaticPopup4"], } for _, g in ipairs(guards) do if Utils.IsFrameOrChildMouseOver(g) then return end @@ -5456,6 +5462,13 @@ function UI:CreateResultButton(index) -- than the visible label for very long strings, silently swallowing -- selection on the longest entry. ddCenter:SetScript("OnClick", function(self) + -- Toggle: a second click on the same button closes the + -- already-open popup instead of re-opening it. + if inlineDropdownPopup and inlineDropdownPopup:IsShown() + and inlineDropdownPopup.owner == self then + inlineDropdownPopup:Hide() + return + end local rowData = resultRow.data if not rowData or not rowData.settingVariable then return end local opts = rowData.settingOptions @@ -5528,6 +5541,7 @@ function UI:CreateResultButton(index) if primary then ns.BlizzOptionsSearch:ApplyVariable(primary) end if secondary then ns.BlizzOptionsSearch:ApplyVariable(secondary) end UI:RefreshResults() + RefocusSearchEditBox() end) resetBtn:SetScript("OnClick", function() if not ns.BlizzOptionsSearch or not ns.BlizzOptionsSearch.RevertVariable then return end @@ -5535,6 +5549,7 @@ function UI:CreateResultButton(index) if primary then ns.BlizzOptionsSearch:RevertVariable(primary) end if secondary then ns.BlizzOptionsSearch:RevertVariable(secondary) end UI:RefreshResults() + RefocusSearchEditBox() end) resultRow.settingApplyExt = applyExt resultRow.settingApplyExtH = APPLY_EXT_H @@ -8740,30 +8755,55 @@ end -- there are pending Apply-flagged settings, otherwise hides directly. -- Used by the click-outside-to-close watcher and ESC handlers; the -- internal HideResults callers (no-results refresh, etc.) skip it. -function UI:RequestHideResults() - if resultsFrame and resultsFrame:IsShown() - and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetPendingApplyCount - and ns.BlizzOptionsSearch:GetPendingApplyCount() > 0 then - local alreadyShowing = StaticPopup_Visible - and StaticPopup_Visible("EASYFIND_UNAPPLIED_SETTINGS") - if not alreadyShowing and StaticPopup_Show then - StaticPopup_Show("EASYFIND_UNAPPLIED_SETTINGS") - end - local popup = _G["StaticPopup1"] - if popup and popup:IsShown() and not popup._easyFindStrataLifted then - popup._easyFindStrataLifted = true - popup._easyFindOriginalStrata = popup:GetFrameStrata() - popup:SetFrameStrata("TOOLTIP") - popup:HookScript("OnHide", function(self) - if self._easyFindStrataLifted then - if self._easyFindOriginalStrata then - self:SetFrameStrata(self._easyFindOriginalStrata) - end - self._easyFindStrataLifted = nil - self._easyFindOriginalStrata = nil - end - end) +-- StaticPopup_Show lands the popup in StaticPopup1..4 depending on +-- which slots are busy. Walk all four to find the one we just opened. +local function FindPopupSlot(popupName) + for i = 1, 4 do + local p = _G["StaticPopup" .. i] + if p and p:IsShown() and p.which == popupName then return p end + end + return nil +end + +-- The default DIALOG strata renders behind our FULLSCREEN_DIALOG +-- results panel. Lift the popup to TOOLTIP for the duration of its +-- visibility, then restore the original strata on hide so we don't +-- pollute other StaticPopup1 uses elsewhere in the UI. +local function LiftPopupStrata(popup) + if not popup or popup._easyFindStrataLifted then return end + popup._easyFindStrataLifted = true + popup._easyFindOriginalStrata = popup:GetFrameStrata() + popup:SetFrameStrata("TOOLTIP") + popup:HookScript("OnHide", function(self) + if self._easyFindStrataLifted then + if self._easyFindOriginalStrata then + self:SetFrameStrata(self._easyFindOriginalStrata) + end + self._easyFindStrataLifted = nil + self._easyFindOriginalStrata = nil end + end) +end + +-- Show the unapplied-settings popup (if not already up) and lift its +-- strata above the results panel. Returns true if the popup is now +-- visible (so the caller can short-circuit its dismiss path). +function UI:ShowUnappliedSettingsPopup() + if not (ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetPendingApplyCount + and ns.BlizzOptionsSearch:GetPendingApplyCount() > 0) then + return false + end + local already = StaticPopup_Visible + and StaticPopup_Visible("EASYFIND_UNAPPLIED_SETTINGS") + if not already and StaticPopup_Show then + StaticPopup_Show("EASYFIND_UNAPPLIED_SETTINGS") + end + LiftPopupStrata(FindPopupSlot("EASYFIND_UNAPPLIED_SETTINGS")) + return true +end + +function UI:RequestHideResults() + if resultsFrame and resultsFrame:IsShown() and self:ShowUnappliedSettingsPopup() then return end self:HideResults() @@ -11518,6 +11558,11 @@ function UI:HandleEscape() Refocus() return end + -- Pending Apply-flagged settings: the popup must preempt the + -- text-clear and panel-close branches so Cancel preserves the + -- exact pre-ESC state (text, scroll, pending change). The helper + -- also lifts the popup above our results panel strata. + if self:ShowUnappliedSettingsPopup() then return end if editBox and editBox:GetText() ~= "" then editBox:SetText("") if editBox.placeholder then editBox.placeholder:Show() end From 08e9a89b1c14f910f17456f84aa2cd28067e6cd5 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 19:03:06 -0400 Subject: [PATCH 080/103] Stats: remove hardcoded Duel Statistics entry --- Database.lua | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/Database.lua b/Database.lua index 0ee63e5..f6a19ac 100644 --- a/Database.lua +++ b/Database.lua @@ -2987,20 +2987,8 @@ function Database:BuildUIDatabase() -- GetStatisticsCategoryList() / GetCategoryInfo() so the -- search index always matches the live AchievementFrame -- Statistics sidebar for the current build. - children = { - -- Manual: Duel Statistics is a specific row inside - -- Player vs. Player > World, not a sidebar category, - -- so the API walk doesn't surface it as a row. - { - name = "Duel Statistics", - keywords = {"duel", "duels", "dueling", "1v1", "duels won", "duels lost"}, - category = "Statistics", - steps = { - { waitForFrame = "AchievementFrame", statisticsCategory = "Player vs. Player" }, - { waitForFrame = "AchievementFrame", statisticsCategory = "World" }, - }, - }, - }, + -- Per-row stat entries (including duels) come from + -- PopulateDynamicStatistics; no manual entries needed. }, }, }, From 245d67e4c04e3a617b25ed883d771963140a147a Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 9 May 2026 19:20:55 -0400 Subject: [PATCH 081/103] Talents: use SpecTalents micro-button atlas for row + filter icon --- UI.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UI.lua b/UI.lua index ddcf96c..e006e82 100644 --- a/UI.lua +++ b/UI.lua @@ -187,7 +187,7 @@ local FLAT_CATEGORY_ICONS = { -- icon (spell icon / boss portrait) is pushed to the RIGHT side. ability = { tex = 522972, coords = { 0.904, 0.996, 0.707, 0.748 } }, boss = { tex = 522972, coords = { 0.855, 0.949, 0.524, 0.566 } }, - talent = { atlas = "UI-HUD-MicroMenu-SpellbookAbilities-Up" }, + talent = { atlas = "UI-HUD-MicroMenu-SpecTalents-Up" }, achievement = { atlas = "UI-HUD-MicroMenu-Achievements-Up" }, macro = { tex = "Interface\\MacroFrame\\MacroFrame-Icon" }, bag = { atlas = "bag-main" }, @@ -2159,7 +2159,7 @@ local UI_FILTER_OPTIONS = { } }, -- Talents: leaf icon from the talents atlas spritesheet (4556093), -- visually consistent with the in-game talent tree. - { key = "talents", label = "Talents", iconAtlas = "UI-HUD-MicroMenu-SpellbookAbilities-Up" }, + { key = "talents", label = "Talents", iconAtlas = "UI-HUD-MicroMenu-SpecTalents-Up" }, -- Title icon from PaperDollSidebarTab2 (Titles tab) spritesheet 514608. { key = "titles", label = "Titles", iconTex = 514608, iconCoords = { 0.016, 0.531, 0.324, 0.461 } }, From 2d96c43bdcbebccfd06d047496735893c201157d Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 10 May 2026 00:00:35 -0400 Subject: [PATCH 082/103] Fix achievement and statistics navigation --- Core.lua | 40 +++-- Database.lua | 405 +++++++++++++++++++++++++++++++++++++------- DatabaseDynamic.lua | 47 ++++- Highlight.lua | 116 ++++++------- UI.lua | 218 ++++++++++++++++++------ Utils.lua | 33 ++++ 6 files changed, 661 insertions(+), 198 deletions(-) diff --git a/Core.lua b/Core.lua index 06bc286..c24c731 100644 --- a/Core.lua +++ b/Core.lua @@ -496,18 +496,23 @@ function EasyFind:EnsureDynamicLoaded() end local function OnPlayerLogin() - -- Heavy synchronous statistics enumeration runs first so it lands - -- during the load-screen window — the player isn't controlling the - -- character yet, so the ~400 API calls are invisible. The dynamic - -- provider machinery checks `loaded` and skips this when the - -- staggered chain reaches it later. - if ns.Database and ns.Database.PopulateDynamicStatistics + -- Kick off the time-sliced statistics enumeration immediately + -- (2ms-per-tick budget, no load-screen block). Run it through the + -- provider manager so later heavy-load requests don't restart it. + if ns.Database and ns.Database.EnsureDynamicProviderLoaded and EasyFind.db.enableUISearch ~= false then - local ok = xpcall(ns.Database.PopulateDynamicStatistics, - ErrorHandler, ns.Database) - if ok and ns.Database.MarkDynamicProviderLoaded then - ns.Database:MarkDynamicProviderLoaded("statistics") - end + ns.Database:EnsureDynamicProviderLoaded("statistics", function(changed) + if changed and ns.Database.MarkDynamicProviderLoaded then + ns.Database:MarkDynamicProviderLoaded("statistics") + end + end) + elseif ns.Database and ns.Database.PopulateDynamicStatisticsAsync + and EasyFind.db.enableUISearch ~= false then + ns.Database:PopulateDynamicStatisticsAsync(function(changed) + if changed and ns.Database.MarkDynamicProviderLoaded then + ns.Database:MarkDynamicProviderLoaded("statistics") + end + end) end local function SafeInit(mod, name) @@ -547,15 +552,14 @@ local function OnPlayerLogin() EasyFind:EnsureDynamicLoaded() end - -- Background-load heavy async providers (bosses) a few seconds after + -- Background-load boss entries directly shortly after -- login so individual encounter names ("Professor Putricide") match - -- on the first search instead of requiring a "boss" / "icc" keyword - -- to trigger the lazy load. The async loader is time-sliced so the - -- ~1000 encounter scan doesn't stutter. - SafeAfter(5.0, function() + -- on the first search. Do not route this through the generic heavy + -- chain; boss results should not sit behind Statistics/Loot. + SafeAfter(1.0, function() if EasyFind.db.enableUISearch == false then return end - if ns.Database and ns.Database.LoadHeavyDynamicSearchData then - ns.Database:LoadHeavyDynamicSearchData(function() end) + if ns.Database and ns.Database.EnsureDynamicProviderLoaded then + ns.Database:EnsureDynamicProviderLoaded("bosses", function() end) end end) diff --git a/Database.lua b/Database.lua index f6a19ac..ac12085 100644 --- a/Database.lua +++ b/Database.lua @@ -1597,9 +1597,11 @@ local function CacheLootInfo(database, lootInfo, inst, encName, encID, diff, sp, lootItemCache[itemID] = entry end -function Database:CancelDynamicScans() +function Database:CancelDynamicScans(includeBosses) lootScanGeneration = lootScanGeneration + 1 - bossScanGeneration = bossScanGeneration + 1 + if includeBosses then + bossScanGeneration = bossScanGeneration + 1 + end end -- Called after PLAYER_LOGIN. Scans the Encounter Journal for current-tier loot @@ -2554,6 +2556,10 @@ local CATEGORY_KEYWORD_OVERLAY = { } local CATEGORY_FLAG_GUILD = 0x00000001 +local RefreshStatisticsCategoryIDs +local IsInStatisticsCategoryTree +local MarkID +local HasID local function buildCategoryEntry(opts) local nameLower = slower(opts.categoryName) @@ -2586,40 +2592,26 @@ end -- actually contains for the current build. function Database:PopulateDynamicAchievements() if not GetCategoryList or not GetCategoryInfo then return false end + -- Midnight's GetCategoryList can include statistic tracker IDs. + -- Filter those from live category ancestry immediately; the async + -- Statistics pass later refreshes this provider with the full row-ID + -- set as a backup without blocking achievement results at login. + RemoveEntriesByCategory("Achievement Category") local categories = GetCategoryList() if not categories then return false end - -- 12.0's GetCategoryList returns the union of achievement category - -- IDs AND every individual stat-row ID (each stat row is itself an - -- achievement ID and appears in this list with its stat name as - -- the "category" name). Build an exclusion set covering both: - -- every sidebar stats category + every stat row inside them. - local statsExclude = {} - if GetStatisticsCategoryList then - local statCats = GetStatisticsCategoryList() - if statCats then - for i = 1, #statCats do - local statCatID = statCats[i] - statsExclude[statCatID] = true - if GetCategoryNumAchievements and GetAchievementInfo then - local total = GetCategoryNumAchievements(statCatID, true) - for j = 1, (total or 0) do - local statID = GetAchievementInfo(statCatID, j) - if statID then statsExclude[statID] = true end - end - end - end - end - end + local statsCatSet = RefreshStatisticsCategoryIDs() local catMap = {} for i = 1, #categories do local catID = categories[i] - if not statsExclude[catID] then + if not HasID(statsCatSet, catID) and not HasID(Database.statisticIDs, catID) then local name, parentID, flags = GetCategoryInfo(catID) - if name and name ~= "" then + if name and name ~= "" + and not IsInStatisticsCategoryTree(catID, parentID) + and not Database:IsStatisticAchievement(catID) then catMap[catID] = { id = catID, name = name, parentID = parentID, isGuild = band(flags or 0, CATEGORY_FLAG_GUILD) == 1, @@ -2641,7 +2633,6 @@ function Database:PopulateDynamicAchievements() local function emit(cat, parentChain, isGuildBranch) local prefix = isGuildBranch and "Guild: " or "" - local suffix = isGuildBranch and "" or " (Achievements)" local pathRoot = isGuildBranch and { "Achievements", "Guild Achievements" } or { "Achievements", "Personal Achievements" } @@ -2653,14 +2644,23 @@ function Database:PopulateDynamicAchievements() local path = {} for i = 1, #pathRoot do path[i] = pathRoot[i] end for i = 1, #parentChain do - local pn = parentChain[i].name - steps[#steps + 1] = { waitForFrame = "AchievementFrame", achievementCategory = pn } + local parent = parentChain[i] + local pn = parent.name + steps[#steps + 1] = { + waitForFrame = "AchievementFrame", + achievementCategory = pn, + achievementCategoryID = parent.id, + } path[#path + 1] = pn end - steps[#steps + 1] = { waitForFrame = "AchievementFrame", achievementCategory = cat.name } + steps[#steps + 1] = { + waitForFrame = "AchievementFrame", + achievementCategory = cat.name, + achievementCategoryID = cat.id, + } uiSearchData[#uiSearchData + 1] = buildCategoryEntry({ - displayName = prefix .. cat.name .. suffix, + displayName = prefix .. cat.name, categoryName = cat.name, category = "Achievement Category", path = path, @@ -2690,18 +2690,276 @@ end -- show up under their own dedicated Statistics filter, not as -- duplicate "Achievement" rows. Database.statisticIDs = Database.statisticIDs or {} +Database.statisticsCategoryIDs = Database.statisticsCategoryIDs or {} +Database.statisticsCategoryChildIDs = Database.statisticsCategoryChildIDs or {} +Database.statisticsComplete = Database.statisticsComplete or false +Database.statisticsVersion = Database.statisticsVersion or 0 + +MarkID = function(set, id) + if id == nil then return end + set[id] = true + local numericID = tonumber(id) + if numericID then set[numericID] = true end +end + +HasID = function(set, id) + if id == nil then return false end + if set[id] == true then return true end + local numericID = tonumber(id) + return numericID and set[numericID] == true or false +end + +RefreshStatisticsCategoryIDs = function(statCats) + wipe(Database.statisticsCategoryIDs) + wipe(Database.statisticsCategoryChildIDs) + if not statCats and GetStatisticsCategoryList then + statCats = GetStatisticsCategoryList() + end + if statCats then + for i = 1, #statCats do + MarkID(Database.statisticsCategoryIDs, statCats[i]) + end + end + return Database.statisticsCategoryIDs +end + +IsInStatisticsCategoryTree = function(categoryID, parentID) + if not categoryID then return false end + local statCats = Database.statisticsCategoryIDs + if HasID(statCats, categoryID) then return true end + if HasID(statCats, parentID) then return true end + + local cached = Database.statisticsCategoryChildIDs[categoryID] + if cached ~= nil then return cached end + + local current = parentID + local seen + while current and current ~= -1 do + if HasID(statCats, current) then + Database.statisticsCategoryChildIDs[categoryID] = true + return true + end + if not GetCategoryInfo then break end + seen = seen or {} + if seen[current] then break end + seen[current] = true + local _, nextParentID = GetCategoryInfo(current) + current = nextParentID + end + + Database.statisticsCategoryChildIDs[categoryID] = false + return false +end function Database:IsStatisticAchievement(achievementID) - return Database.statisticIDs[achievementID] == true + if not achievementID then return false end + if HasID(Database.statisticIDs, achievementID) then return true end + if not GetCategoryInfo then return false end + if not next(Database.statisticsCategoryIDs) then RefreshStatisticsCategoryIDs() end + local getAchievementCategory = _G["GetAchievementCategory"] + if getAchievementCategory then + local categoryID = getAchievementCategory(achievementID) + if IsInStatisticsCategoryTree(categoryID, nil) then return true end + local categoryParentID + if categoryID then + local _ + _, categoryParentID = GetCategoryInfo(categoryID) + end + if IsInStatisticsCategoryTree(categoryID, categoryParentID) then return true end + end + local _, parentID = GetCategoryInfo(achievementID) + return IsInStatisticsCategoryTree(achievementID, parentID) +end + +-- Cancel token for async stat scans -- bumping it during a scan +-- causes the in-flight time-sliced loop to bail without writing. +local statsScanGeneration = 0 + +-- Time-sliced version of PopulateDynamicStatistics. The per-row +-- enumeration is ~400 API calls; running it sync at PLAYER_LOGIN +-- doubles the load screen, and running it sync in one frame +-- post-login causes a visible hitch. Spread the work across frames +-- with a small per-tick budget instead. +function Database:PopulateDynamicStatisticsAsync(done) + if not GetStatisticsCategoryList or not GetCategoryInfo + or not C_Timer or not C_Timer.After then + local ok = self:PopulateDynamicStatistics() + done(ok) + return + end + + RemoveEntriesByCategory("Statistic") + wipe(Database.statisticIDs) + Database.statisticsComplete = false + statsScanGeneration = statsScanGeneration + 1 + local myGen = statsScanGeneration + + local categories = GetStatisticsCategoryList() + if not categories then done(false); return end + RefreshStatisticsCategoryIDs(categories) + + local catMap = {} + for i = 1, #categories do + local catID = categories[i] + local name, parentID = GetCategoryInfo(catID) + if name and name ~= "" then + catMap[catID] = { id = catID, name = name, parentID = parentID, children = {} } + end + end + + local roots = {} + for _, cat in pairs(catMap) do + local parent = cat.parentID and cat.parentID ~= -1 and catMap[cat.parentID] + if parent then + parent.children[#parent.children + 1] = cat + else + roots[#roots + 1] = cat + end + end + + -- Flat queue of per-row work items. The expensive per-category + -- setup (steps prefix, path, prototype with shared fields) is + -- built ONCE per category and stored on the queue item; per-row + -- work then only needs the API call + minimal entry construction. + -- + -- Each entry uses an __index prototype to share the constant + -- fields (category, buttonFrame, flashLabel, keywords, path, + -- stepsPrefix) so each row only stores its unique fields + -- (name, nameLower, statisticID, full steps array). + local STAT_KEYWORDS_EMPTY = {} -- shared empty + local queue = {} + + local function enqueueCategory(cat, parentChain) + local stepsPrefix = { + { buttonFrame = "AchievementMicroButton" }, + { waitForFrame = "AchievementFrame", tabIndex = 3 }, + } + local path = { "Achievements", "Statistics" } + for i = 1, #parentChain do + local parent = parentChain[i] + local pn = parent.name + stepsPrefix[#stepsPrefix + 1] = { + waitForFrame = "AchievementFrame", + statisticsCategory = pn, + statisticsCategoryID = parent.id, + } + path[#path + 1] = pn + end + stepsPrefix[#stepsPrefix + 1] = { + waitForFrame = "AchievementFrame", + statisticsCategory = cat.name, + statisticsCategoryID = cat.id, + } + path[#path + 1] = cat.name + + local proto = { + category = "Statistic", + buttonFrame = "AchievementMicroButton", + flashLabel = "Statistics", + keywords = STAT_KEYWORDS_EMPTY, + keywordsLower = STAT_KEYWORDS_EMPTY, + path = path, + } + local protoMT = { __index = proto } + local prefixLen = #stepsPrefix + + if GetCategoryNumAchievements then + local total = GetCategoryNumAchievements(cat.id) or 0 + if total == 0 and #cat.children == 0 then + total = GetCategoryNumAchievements(cat.id, true) or 0 + end + for i = 1, total do + queue[#queue + 1] = { + catID = cat.id, rowIndex = i, + stepsPrefix = stepsPrefix, prefixLen = prefixLen, + protoMT = protoMT, + } + end + end + + if #cat.children > 0 then + local nextChain = {} + for i = 1, #parentChain do nextChain[i] = parentChain[i] end + nextChain[#nextChain + 1] = cat + for i = 1, #cat.children do enqueueCategory(cat.children[i], nextChain) end + end + end + for i = 1, #roots do enqueueCategory(roots[i], {}) end + + -- Per-row work: one API call, one slower, one steps copy, one + -- table allocation with metatable. ~0.05-0.1ms each instead of + -- the 0.5ms buildCategoryEntry path. Keywords are skipped + -- entirely; matching falls back to the unique stat name which + -- is sufficient (search engine handles word-substring on names). + local seenStatisticIDs = {} + local function processRow(item) + if not GetAchievementInfo then return end + local id, title = GetAchievementInfo(item.catID, item.rowIndex) + if not (id and title and title ~= "") then return end + if HasID(seenStatisticIDs, id) then + MarkID(Database.statisticIDs, id) + return + end + MarkID(seenStatisticIDs, id) + MarkID(Database.statisticIDs, id) + local stepsPrefix = item.stepsPrefix + local steps = {} + for s = 1, item.prefixLen do steps[s] = stepsPrefix[s] end + steps[item.prefixLen + 1] = { + waitForFrame = "AchievementFrame", + statisticID = id, + statisticName = title, + } + local entry = setmetatable({ + name = title, + nameLower = slower(title), + statisticID = id, + steps = steps, + }, item.protoMT) + uiSearchData[#uiSearchData + 1] = entry + end + + -- ~2ms per tick. With the prototype-based per-row work at + -- ~0.05-0.1ms each, ~25 rows fit per tick. ~2000 rows / 25 = + -- ~80 yields total. At 60fps that's ~1.3s, imperceptible per-tick. + local BUDGET_MS = 2 + local cursor = 1 + local function step() + if myGen ~= statsScanGeneration then + done(false, "cancelled") + return + end + local startMs = debugprofilestop and debugprofilestop() or 0 + while cursor <= #queue do + processRow(queue[cursor]) + cursor = cursor + 1 + if debugprofilestop and (debugprofilestop() - startMs) > BUDGET_MS then + C_Timer.After(0, step) + return + end + end + -- Stats done; refresh achievements so the full stat row-ID set + -- is available as a backup to the live category-tree filter. + Database.statisticsComplete = true + Database.statisticsVersion = Database.statisticsVersion + 1 + if Database.RefreshDynamicCategory then + Database:RefreshDynamicCategory("achievements") + end + done(true) + end + step() end function Database:PopulateDynamicStatistics() if not GetStatisticsCategoryList or not GetCategoryInfo then return false end RemoveEntriesByCategory("Statistic") wipe(Database.statisticIDs) + Database.statisticsComplete = false + statsScanGeneration = statsScanGeneration + 1 local categories = GetStatisticsCategoryList() if not categories then return false end + RefreshStatisticsCategoryIDs(categories) local catMap = {} for i = 1, #categories do @@ -2728,9 +2986,9 @@ function Database:PopulateDynamicStatistics() -- Each "row" in the Statistics tab is an achievement ID whose -- value is fetched via GetStatistic(achievementID). Walking -- GetCategoryNumAchievements + GetAchievementInfo gives us the - -- per-row IDs and titles for inline display. Heavy (~400 API - -- calls); call this synchronously at PLAYER_LOGIN so it lands - -- during the load-screen window where the cost is hidden. + -- per-row IDs and titles for inline display. This synchronous path + -- remains as a fallback for environments without C_Timer. + local seenStatisticIDs = {} local function emit(cat, parentChain) local baseSteps = { { buttonFrame = "AchievementMicroButton" }, @@ -2738,43 +2996,60 @@ function Database:PopulateDynamicStatistics() } local pathBase = { "Achievements", "Statistics" } for i = 1, #parentChain do - local pn = parentChain[i].name - baseSteps[#baseSteps + 1] = { waitForFrame = "AchievementFrame", statisticsCategory = pn } + local parent = parentChain[i] + local pn = parent.name + baseSteps[#baseSteps + 1] = { + waitForFrame = "AchievementFrame", + statisticsCategory = pn, + statisticsCategoryID = parent.id, + } pathBase[#pathBase + 1] = pn end - baseSteps[#baseSteps + 1] = { waitForFrame = "AchievementFrame", statisticsCategory = cat.name } + baseSteps[#baseSteps + 1] = { + waitForFrame = "AchievementFrame", + statisticsCategory = cat.name, + statisticsCategoryID = cat.id, + } pathBase[#pathBase + 1] = cat.name if GetCategoryNumAchievements and GetAchievementInfo then - local total = GetCategoryNumAchievements(cat.id, true) + local total = GetCategoryNumAchievements(cat.id) + if (total or 0) == 0 and #cat.children == 0 then + total = GetCategoryNumAchievements(cat.id, true) + end for i = 1, (total or 0) do local id, title = GetAchievementInfo(cat.id, i) if id and title and title ~= "" then - Database.statisticIDs[id] = true - local steps = {} - for s = 1, #baseSteps do steps[s] = baseSteps[s] end - -- Leaf step: ONLY statisticID + statisticName. - -- Including statisticsCategory here would make the - -- statisticsCategory handler match first (it runs - -- before the statisticID handler) and Cancel the - -- guide because the category is already selected. - steps[#steps + 1] = { - waitForFrame = "AchievementFrame", - statisticID = id, - statisticName = title, - } - local path = {} - for p = 1, #pathBase do path[p] = pathBase[p] end - local entry = buildCategoryEntry({ - displayName = title, - categoryName = title, - category = "Statistic", - path = path, - steps = steps, - flashLabel = "Statistics", - }) - entry.statisticID = id - uiSearchData[#uiSearchData + 1] = entry + if HasID(seenStatisticIDs, id) then + MarkID(Database.statisticIDs, id) + else + MarkID(seenStatisticIDs, id) + MarkID(Database.statisticIDs, id) + local steps = {} + for s = 1, #baseSteps do steps[s] = baseSteps[s] end + -- Leaf step: ONLY statisticID + statisticName. + -- Including statisticsCategory here would make the + -- statisticsCategory handler match first (it runs + -- before the statisticID handler) and Cancel the + -- guide because the category is already selected. + steps[#steps + 1] = { + waitForFrame = "AchievementFrame", + statisticID = id, + statisticName = title, + } + local path = {} + for p = 1, #pathBase do path[p] = pathBase[p] end + local entry = buildCategoryEntry({ + displayName = title, + categoryName = title, + category = "Statistic", + path = path, + steps = steps, + flashLabel = "Statistics", + }) + entry.statisticID = id + uiSearchData[#uiSearchData + 1] = entry + end end end end @@ -2788,6 +3063,8 @@ function Database:PopulateDynamicStatistics() end for i = 1, #roots do emit(roots[i], {}) end + Database.statisticsComplete = true + Database.statisticsVersion = Database.statisticsVersion + 1 return true end diff --git a/DatabaseDynamic.lua b/DatabaseDynamic.lua index 7826f26..2332ae1 100644 --- a/DatabaseDynamic.lua +++ b/DatabaseDynamic.lua @@ -9,7 +9,7 @@ local dynamicProviders = { { key = "currencies", category = "Currency", fn = "PopulateDynamicCurrencies" }, { key = "reputations", category = "Reputation", fn = "PopulateDynamicReputations" }, { key = "achievements", category = "Achievement Category", fn = "PopulateDynamicAchievements" }, - { key = "statistics", category = "Statistic", fn = "PopulateDynamicStatistics" }, + { key = "statistics", category = "Statistic", fn = "PopulateDynamicStatistics", asyncFn = "PopulateDynamicStatisticsAsync" }, { key = "mounts", category = "Mount", fn = "PopulateDynamicMounts" }, { key = "toys", category = "Toy", fn = "PopulateDynamicToys" }, { key = "pets", category = "Pet", fn = "PopulateDynamicPets" }, @@ -63,18 +63,40 @@ local function FinishDynamicProvider(database, provider, ok, err, changed, onDon onDone(changed ~= false) end +local function NotifyProviderWaiters(provider, changed) + local waiters = provider.waiting + provider.waiting = nil + provider.loading = false + if waiters then + for i = 1, #waiters do + waiters[i](changed) + end + end +end + local function RunDynamicProvider(database, provider, onDone) if provider.loaded and not provider.dirty then onDone(false); return end + if provider.loading then + provider.waiting = provider.waiting or {} + provider.waiting[#provider.waiting + 1] = onDone + return + end + local pre = provider.pre and database[provider.pre] if pre then pre(database) end local asyncFn = provider.asyncFn and database[provider.asyncFn] if asyncFn then + provider.loading = true + provider.waiting = { onDone } + local function finishWaiters(changed) + NotifyProviderWaiters(provider, changed) + end local ok, err = xpcall(asyncFn, Utils.ErrorHandler, database, function(changed, asyncErr) - FinishDynamicProvider(database, provider, not asyncErr, asyncErr, changed, onDone) + FinishDynamicProvider(database, provider, not asyncErr, asyncErr, changed, finishWaiters) end) if not ok then - FinishDynamicProvider(database, provider, false, err, false, onDone) + FinishDynamicProvider(database, provider, false, err, false, finishWaiters) end return end @@ -93,12 +115,14 @@ local function RunDynamicProvider(database, provider, onDone) end function Database:CancelDynamicWarmup() - if self.CancelDynamicScans then self:CancelDynamicScans() end + if self.CancelDynamicScans then self:CancelDynamicScans(false) end end function Database:MarkDynamicProviderLoaded(key) local provider = dynamicProviderByKey[key] if not provider then return end + provider.loading = false + provider.waiting = nil provider.loaded = true provider.dirty = false end @@ -115,6 +139,13 @@ function Database:IsDynamicProviderLoaded(key) return provider and provider.loaded and not provider.dirty or false end +function Database:EnsureDynamicProviderLoaded(key, onDone) + local provider = dynamicProviderByKey[key] + if not provider then return false end + RunDynamicProvider(self, provider, onDone or function() end) + return true +end + function Database:RefreshDynamicCategory(key) local provider = dynamicProviderByKey[key] if not provider then return false end @@ -178,9 +209,9 @@ end -- Synchronous heavy-data load. Used at PLAYER_LOGIN so the load -- screen absorbs the cost. Only loot (current spec) is scanned here — -- it's small and matches a common search ("ring", "haste ring"). Boss --- scanning iterates ~1000+ encounters across every expansion tier and --- adds noticeable post-login CPU; bosses lazy-load on the first "boss" --- keyword via LoadHeavyDynamicSearchData instead. +-- scanning iterates ~1000+ encounters across every expansion tier, so +-- it runs through its async provider after login or on boss-related +-- searches instead of blocking this sync path. local SYNC_HEAVY_KEYS = { loot = true } function Database:LoadHeavyDynamicSearchDataSync() for i = 1, #dynamicProviders do @@ -243,7 +274,7 @@ function Database:LoadHeavyDynamicSearchData(onDone) end function Database:UnloadDynamicSearchData(includeCore) - self:CancelDynamicWarmup() + if self.CancelDynamicScans then self:CancelDynamicScans(true) end for i = 1, #dynamicProviders do local provider = dynamicProviders[i] if includeCore or provider.asyncFn then diff --git a/Highlight.lua b/Highlight.lua index 0ae6d8a..e4c1f0f 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -648,7 +648,7 @@ function Highlight:UpdateGuide() for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] if prevStep and prevStep.statisticsCategory then - if not self:IsCategoryExpandedOrSelected(prevStep.statisticsCategory) then + if not self:IsCategoryExpandedOrSelected(prevStep.statisticsCategory, prevStep.statisticsCategoryID) then currentStepIndex = i self:HideHighlight() return @@ -657,14 +657,14 @@ function Highlight:UpdateGuide() end -- Check if already on correct statistics category - if self:IsCategorySelectedByData(step.statisticsCategory) then + if self:IsCategorySelectedByData(step.statisticsCategory, step.statisticsCategoryID) then if isLastStep then self:Cancel() return end -- Non-final: only advance if children are actually visible (parent expanded), -- not just selected - clicking a collapsed parent selects it without showing children - local elementData = self:FindCategoryElementData(step.statisticsCategory) + local elementData = self:FindCategoryElementData(step.statisticsCategory, step.statisticsCategoryID) if not elementData or not elementData.parent or not elementData.collapsed then self:AdvanceStep() return @@ -675,7 +675,7 @@ function Highlight:UpdateGuide() -- For non-final steps: if the category is a parent that's expanded (children visible), -- skip ahead - don't force the user to re-select a parent they've already drilled into if not isLastStep then - local elementData = self:FindCategoryElementData(step.statisticsCategory) + local elementData = self:FindCategoryElementData(step.statisticsCategory, step.statisticsCategoryID) if elementData and elementData.parent and not elementData.collapsed then self:AdvanceStep() return @@ -683,7 +683,7 @@ function Highlight:UpdateGuide() end -- Not selected - find the button (scrolls into view automatically) - local categoryBtn = self:GetStatisticsCategoryButton(step.statisticsCategory) + local categoryBtn = self:GetStatisticsCategoryButton(step.statisticsCategory, step.statisticsCategoryID) if categoryBtn then self:HighlightFrame(categoryBtn) else @@ -799,7 +799,7 @@ function Highlight:UpdateGuide() for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] if prevStep and prevStep.achievementCategory then - if not self:IsCategoryExpandedOrSelected(prevStep.achievementCategory) then + if not self:IsCategoryExpandedOrSelected(prevStep.achievementCategory, prevStep.achievementCategoryID) then currentStepIndex = i self:HideHighlight() return @@ -808,14 +808,14 @@ function Highlight:UpdateGuide() end -- Check if already on correct category - if self:IsCategorySelectedByData(step.achievementCategory) then + if self:IsCategorySelectedByData(step.achievementCategory, step.achievementCategoryID) then if isLastStep then self:Cancel() return end -- Non-final: only advance if children are actually visible (parent expanded), -- not just selected - clicking a collapsed parent selects it without showing children - local elementData = self:FindCategoryElementData(step.achievementCategory) + local elementData = self:FindCategoryElementData(step.achievementCategory, step.achievementCategoryID) if not elementData or not elementData.parent or not elementData.collapsed then self:AdvanceStep() return @@ -824,7 +824,7 @@ function Highlight:UpdateGuide() end -- Not selected - find the button (scrolls into view automatically) - local categoryBtn = self:GetAchievementCategoryButton(step.achievementCategory) + local categoryBtn = self:GetAchievementCategoryButton(step.achievementCategory, step.achievementCategoryID) if categoryBtn then self:HighlightFrame(categoryBtn) else @@ -2007,9 +2007,24 @@ end -- Element data: { id = , selected = true/false, parent = ..., isChild = ..., ... } -- Category names are resolved via GetCategoryInfo(elementData.id). --- Shared helper: find element data in the ScrollBox data provider by category name. +local function CategoryDataMatches(data, categoryName, categoryID) + if not data then return false end + local catID = tonumber(data.id) + if not catID then return false end + + local numericCategoryID = tonumber(categoryID) + if numericCategoryID then return catID == numericCategoryID end + + if categoryName and GetCategoryInfo then + local title = GetCategoryInfo(catID) + if title and slower(title) == slower(categoryName) then return true end + end + return false +end + +-- Shared helper: find element data in the ScrollBox data provider by category name/id. -- Returns (elementData, scrollBox) or (nil, nil). -function Highlight:FindCategoryElementData(categoryName) +function Highlight:FindCategoryElementData(categoryName, categoryID) local categoriesFrame = _G["AchievementFrameCategories"] if not categoriesFrame or not categoriesFrame.ScrollBox then return nil, nil end @@ -2017,20 +2032,11 @@ function Highlight:FindCategoryElementData(categoryName) local dataProvider = scrollBox.GetDataProvider and scrollBox:GetDataProvider() if not dataProvider then return nil, nil end - local categoryNameLower = slower(categoryName) local finder = dataProvider.FindElementDataByPredicate or dataProvider.FindByPredicate if not finder then return nil, nil end local elementData = finder(dataProvider, function(data) - if not data then return false end - -- Element data has an `id` field that is a numeric category ID (or "summary") - local catID = data.id - if not catID or type(catID) ~= "number" then return false end - if GetCategoryInfo then - local title = GetCategoryInfo(catID) - if title and slower(title) == categoryNameLower then return true end - end - return false + return CategoryDataMatches(data, categoryName, categoryID) end) return elementData, scrollBox @@ -2040,22 +2046,19 @@ end -- Uses GetElementData().id → GetCategoryInfo() for reliable matching -- (AchievementCategoryTemplate stores text on btn.Button, not btn itself). -- Returns the button frame or nil. -function Highlight:FindVisibleCategoryButton(categoryName) +function Highlight:FindVisibleCategoryButton(categoryName, categoryID) local categoriesFrame = _G["AchievementFrameCategories"] if not categoriesFrame or not categoriesFrame.ScrollBox then return nil end local scrollBox = categoriesFrame.ScrollBox - local categoryNameLower = slower(categoryName) -- Primary: FindFrameByPredicate (cleanest ScrollBox API) if scrollBox.FindFrameByPredicate then local frame = scrollBox:FindFrameByPredicate(function(frame, elementData) - if not elementData or not elementData.id or type(elementData.id) ~= "number" then return false end - if GetCategoryInfo then - local title = GetCategoryInfo(elementData.id) - if title and slower(title) == categoryNameLower then return true end - end - return false + local data = elementData + or (frame and frame.GetElementData and frame:GetElementData()) + or frame + return CategoryDataMatches(data, categoryName, categoryID) end) if frame then return frame end end @@ -2065,11 +2068,8 @@ function Highlight:FindVisibleCategoryButton(categoryName) for _, btn in scrollBox:EnumerateFrames() do if btn and btn:IsShown() and btn.GetElementData then local data = btn:GetElementData() - if data and data.id and type(data.id) == "number" and GetCategoryInfo then - local title = GetCategoryInfo(data.id) - if title and slower(title) == categoryNameLower then - return btn - end + if CategoryDataMatches(data, categoryName, categoryID) then + return btn end end end @@ -2080,15 +2080,15 @@ end -- Shared helper: check if a category is currently selected. -- Uses elementData.selected (set by Blizzard's selection system). -function Highlight:IsCategorySelectedByData(categoryName) +function Highlight:IsCategorySelectedByData(categoryName, categoryID) -- Primary: check the data provider for elementData.selected - local elementData = self:FindCategoryElementData(categoryName) + local elementData = self:FindCategoryElementData(categoryName, categoryID) if elementData and elementData.selected then return true end -- Fallback: find visible button and check its elementData directly - local btn = self:FindVisibleCategoryButton(categoryName) + local btn = self:FindVisibleCategoryButton(categoryName, categoryID) if btn and btn.GetElementData then local btnData = btn:GetElementData() if btnData and btnData.selected then return true end @@ -2100,8 +2100,8 @@ end -- Shared helper: check if a category is expanded (its children visible) OR selected. -- Used for prerequisite validation - a parent category doesn't need to be "selected" -- once its child is selected; it only needs to still be expanded. -function Highlight:IsCategoryExpandedOrSelected(categoryName) - local elementData = self:FindCategoryElementData(categoryName) +function Highlight:IsCategoryExpandedOrSelected(categoryName, categoryID) + local elementData = self:FindCategoryElementData(categoryName, categoryID) if not elementData then return false end -- If it's directly selected, obviously satisfied @@ -2119,20 +2119,20 @@ end -- Shared helper: scroll to a category and return the button frame. -- Expands parent categories if needed via AchievementFrameCategories_ExpandToCategory. -function Highlight:ScrollToCategoryButton(categoryName) - local elementData, scrollBox = self:FindCategoryElementData(categoryName) +function Highlight:ScrollToCategoryButton(categoryName, categoryID) + local elementData, scrollBox = self:FindCategoryElementData(categoryName, categoryID) if not elementData or not scrollBox then return nil end -- If the category is hidden (parent collapsed), try to expand to it if elementData.hidden then - local catID = elementData.id - if catID and type(catID) == "number" and AchievementFrameCategories_ExpandToCategory then + local catID = tonumber(elementData.id) + if catID and AchievementFrameCategories_ExpandToCategory then AchievementFrameCategories_ExpandToCategory(catID) -- Data provider may have changed, re-find if AchievementFrameCategories_UpdateDataProvider then AchievementFrameCategories_UpdateDataProvider() end - elementData, scrollBox = self:FindCategoryElementData(categoryName) + elementData, scrollBox = self:FindCategoryElementData(categoryName, categoryID) if not elementData or not scrollBox then return nil end end end @@ -2140,16 +2140,16 @@ function Highlight:ScrollToCategoryButton(categoryName) -- Scroll to center the category in view local alignCenter = ScrollBoxConstants and ScrollBoxConstants.AlignCenter if scrollBox.ScrollToElementData then - scrollBox:ScrollToElementData(elementData, alignCenter) + pcall(scrollBox.ScrollToElementData, scrollBox, elementData, alignCenter) end -- Now find the visible button after scrolling - return self:FindVisibleCategoryButton(categoryName) + return self:FindVisibleCategoryButton(categoryName, categoryID) end -- STATISTICS CATEGORY -function Highlight:IsStatisticsCategorySelected(categoryName) +function Highlight:IsStatisticsCategorySelected(categoryName, categoryID) if not AchievementFrame or not AchievementFrame:IsShown() then return false end @@ -2158,7 +2158,7 @@ function Highlight:IsStatisticsCategorySelected(categoryName) end -- Primary: check elementData.selected via data provider - if self:IsCategorySelectedByData(categoryName) then + if self:IsCategorySelectedByData(categoryName, categoryID) then return true end @@ -2166,7 +2166,7 @@ function Highlight:IsStatisticsCategorySelected(categoryName) if currentGuide and currentStepIndex then local nextStep = currentGuide.steps[currentStepIndex + 1] if nextStep and nextStep.statisticsCategory then - local nextBtn = self:FindVisibleCategoryButton(nextStep.statisticsCategory) + local nextBtn = self:FindVisibleCategoryButton(nextStep.statisticsCategory, nextStep.statisticsCategoryID) if nextBtn then return true end @@ -2176,7 +2176,7 @@ function Highlight:IsStatisticsCategorySelected(categoryName) return false end -function Highlight:GetStatisticsCategoryButton(categoryName) +function Highlight:GetStatisticsCategoryButton(categoryName, categoryID) if not AchievementFrame or not AchievementFrame:IsShown() then return nil end @@ -2185,22 +2185,22 @@ function Highlight:GetStatisticsCategoryButton(categoryName) end -- First: check currently visible buttons (fast, no scrolling) - local visibleBtn = self:FindVisibleCategoryButton(categoryName) + local visibleBtn = self:FindVisibleCategoryButton(categoryName, categoryID) if visibleBtn then return visibleBtn end -- Not visible: scroll to it via data provider - return self:ScrollToCategoryButton(categoryName) + return self:ScrollToCategoryButton(categoryName, categoryID) end -- ACHIEVEMENT/GUILD CATEGORY -function Highlight:IsAchievementCategorySelected(categoryName) +function Highlight:IsAchievementCategorySelected(categoryName, categoryID) if not AchievementFrame or not AchievementFrame:IsShown() then return false end -- Primary: check elementData.selected via data provider - if self:IsCategorySelectedByData(categoryName) then + if self:IsCategorySelectedByData(categoryName, categoryID) then return true end @@ -2208,7 +2208,7 @@ function Highlight:IsAchievementCategorySelected(categoryName) if currentGuide and currentStepIndex then local nextStep = currentGuide.steps[currentStepIndex + 1] if nextStep and nextStep.achievementCategory then - local nextBtn = self:FindVisibleCategoryButton(nextStep.achievementCategory) + local nextBtn = self:FindVisibleCategoryButton(nextStep.achievementCategory, nextStep.achievementCategoryID) if nextBtn then return true end @@ -2218,18 +2218,18 @@ function Highlight:IsAchievementCategorySelected(categoryName) return false end -function Highlight:GetAchievementCategoryButton(categoryName, noScroll) +function Highlight:GetAchievementCategoryButton(categoryName, categoryID, noScroll) if not AchievementFrame or not AchievementFrame:IsShown() then return nil end -- First: check currently visible buttons (fast, no scrolling) - local visibleBtn = self:FindVisibleCategoryButton(categoryName) + local visibleBtn = self:FindVisibleCategoryButton(categoryName, categoryID) if visibleBtn then return visibleBtn end -- Not visible: scroll to it (unless noScroll requested by selection checks) if noScroll then return nil end - return self:ScrollToCategoryButton(categoryName) + return self:ScrollToCategoryButton(categoryName, categoryID) end -- Character Frame sidebar tab helpers (Character Stats, Titles, Equipment Manager) diff --git a/UI.lua b/UI.lua index e006e82..936de39 100644 --- a/UI.lua +++ b/UI.lua @@ -337,11 +337,7 @@ end local function GetFlatSubtext(data) if not data then return "" end if data.path and #data.path > 0 then - local cat = data.category - if data.achievementID or cat == "Achievement" or cat == "Achievements" then - return data.path[#data.path] - end - return tconcat(data.path, " > ") + return data.path[#data.path] end if data.mapSearchResult then local cat = data.category @@ -1072,6 +1068,15 @@ 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 + if UI._keepPinnedResultsOpenUntil then + local keepUntil = UI._keepPinnedResultsOpenUntil + UI._keepPinnedResultsOpenUntil = nil + local now = GetTime and GetTime() or 0 + if now <= keepUntil and #GetAllPins() > 0 then + UI:ShowPinnedItems() + return + end + end -- Don't hide if spec/class flyouts are open local sf = _G["EasyFindSpecFlyout"] local ssf = _G["EasyFindSpecSubFlyout"] @@ -4441,26 +4446,45 @@ local ACHIEVEMENT_PROTO = { local ACHIEVEMENT_MT = { __index = ACHIEVEMENT_PROTO } local achievementEntryByID = {} local achSearchCache = {} +local achSearchStatsVersion local achSearchPending = nil local achSearchListener local achSearchPrewarmed = false local ACH_MAX_RESULTS = 8 +local function SyncAchievementSearchStatsVersion() + local version = ns.Database and ns.Database.statisticsVersion or 0 + if achSearchStatsVersion == version then return end + wipe(achSearchCache) + wipe(achievementEntryByID) + achSearchPending = nil + achSearchStatsVersion = version +end + -- Walk the achievement's category chain root-down so the guide -- breadcrumbs through each parent before highlighting the achievement -- row. GetAchievementCategory + GetCategoryInfo (parentID) walks up -- toward -1 (root sentinel). local function BuildAchievementSteps(achievementID) - local steps = { { buttonFrame = "AchievementMicroButton" } } + local steps = { + { buttonFrame = "AchievementMicroButton" }, + { waitForFrame = "AchievementFrame", tabIndex = 1 }, + } local getCat = _G["GetAchievementCategory"] local getInfo = _G["GetCategoryInfo"] if not getCat or not getInfo then - steps[1].achievementID = achievementID + steps[#steps + 1] = { + waitForFrame = "AchievementFrame", + achievementID = achievementID, + } return steps end local catID = getCat(achievementID) if not catID or catID < 0 then - steps[1].achievementID = achievementID + steps[#steps + 1] = { + waitForFrame = "AchievementFrame", + achievementID = achievementID, + } return steps end local chain = {} @@ -4470,14 +4494,16 @@ local function BuildAchievementSteps(achievementID) seen[current] = true local title, parentID = getInfo(current) if not title then break end - chain[#chain + 1] = title + chain[#chain + 1] = { id = current, name = title } current = parentID end -- Reverse so root-most appears first. for i = #chain, 1, -1 do + local cat = chain[i] steps[#steps + 1] = { waitForFrame = "AchievementFrame", - achievementCategory = chain[i], + achievementCategory = cat.name, + achievementCategoryID = cat.id, } end -- Final step targets the achievement itself. @@ -4510,6 +4536,8 @@ local function GetOrCreateAchievementEntry(id, name, icon) end local function CollectAchievementSearchResults(query) + SyncAchievementSearchStatsVersion() + local getNum = _G["GetNumFilteredAchievements"] local getID = _G["GetFilteredAchievementID"] local getInfo = _G["GetAchievementInfo"] @@ -4559,6 +4587,8 @@ end function UI:RequestAchievementSearch(query) if not query or #query < 2 then return nil end + SyncAchievementSearchStatsVersion() + local cached = achSearchCache[query] if cached then return cached end local setSearch = _G["SetAchievementSearchString"] @@ -4604,8 +4634,34 @@ UI._resultButtons = resultButtons local heavySearchLoading = false +local function QueryLooksBossRelated(text) + if not text then return false end + for word in slower(text):gmatch("%S+") do + word = word:gsub("^%p+", ""):gsub("%p+$", "") + if word == "boss" or word == "bosses" + or word == "dungeon" or word == "dungeons" + or word == "raid" or word == "raids" then + return true + end + end + return false +end + +local function RefreshSearchAfterHeavyLoad(anyChanged) + if not anyChanged then return end + local currentText = searchFrame and searchFrame.editBox and searchFrame.editBox:GetText() + if searchFrame and searchFrame.editBox and searchFrame.editBox:HasFocus() + and currentText and currentText ~= "" then + UI:OnSearchTextChanged(currentText, true) + end +end + local function MaybeLoadHeavySearchData(text, needsHeavy) - if heavySearchLoading or not ns.Database or not ns.Database.LoadHeavyDynamicSearchData then return end + if not ns.Database then return end + if QueryLooksBossRelated(text) and ns.Database.EnsureDynamicProviderLoaded then + ns.Database:EnsureDynamicProviderLoaded("bosses", RefreshSearchAfterHeavyLoad) + end + if heavySearchLoading or not ns.Database.LoadHeavyDynamicSearchData then return end if not needsHeavy then return end heavySearchLoading = true local started = ns.Database:LoadHeavyDynamicSearchData(function(anyChanged) @@ -4615,12 +4671,7 @@ local function MaybeLoadHeavySearchData(text, needsHeavy) -- triggers heavy-load chain → callback → search → heavy-load -- chain → ... an infinite loop that allocates ~10 MB/sec across -- result re-rendering and SearchUI's per-iteration scratch. - if not anyChanged then return end - local currentText = searchFrame and searchFrame.editBox and searchFrame.editBox:GetText() - if searchFrame and searchFrame.editBox and searchFrame.editBox:HasFocus() - and currentText and currentText ~= "" then - UI:OnSearchTextChanged(currentText, true) - end + RefreshSearchAfterHeavyLoad(anyChanged) end) if not started then heavySearchLoading = false end end @@ -5909,7 +5960,14 @@ function UI:CreateResultButton(index) local editBox = searchFrame and searchFrame.editBox local text = editBox and editBox:GetText() or "" if text == "" then + local pinsRemain = #GetAllPins() > 0 + UI._keepPinnedResultsOpenUntil = pinsRemain and ((GetTime and GetTime() or 0) + 0.25) or nil UI:ShowPinnedItems() + if pinsRemain and editBox + and not (navFrame and navFrame:IsKeyboardEnabled()) then + editBox.blockFocus = nil + editBox:SetFocus() + end else UI:OnSearchTextChanged(text, true) end @@ -8710,9 +8768,9 @@ local FILTER_POPUP_NAMES = { function UI:CloseFilterDropdownIfOpen() if not searchFrame then return false end + local closedAny = Utils.HideCursorMenus and Utils.HideCursorMenus() or false local dropdown = searchFrame.filterDropdown - if not dropdown then return false end - local closedAny = false + if not dropdown then return closedAny end -- Brute-force hide every named popup. classPopup is never registered -- in guardFrames; relying on the cascade-via-OnHide chain misses it -- when the parent popup is already hidden. Hide by name is idempotent @@ -10495,6 +10553,8 @@ function UI:DirectOpen(data) -- child frames are available right after their parent is shown. -- The only exception is currency/reputation tab resync, which toggles -- tabs and needs one frame for the ScrollBox to rebuild. + local tabClickAttempts = {} + local categoryClickAttempts = {} local function executeFrom(start) for i = start, executeCount do local step = steps[i] @@ -10591,6 +10651,28 @@ function UI:DirectOpen(data) and step.waitForFrame ~= "PlayerSpellsFrame" then ClickButton(Highlight:GetTabButton(step.waitForFrame, step.tabIndex)) end + + local nextStep = steps[i + 1] + if step.waitForFrame == "AchievementFrame" and nextStep + and (nextStep.achievementCategory or nextStep.statisticsCategory) then + local tabReady = true + if Highlight and Highlight.IsTabSelected then + tabReady = Highlight:IsTabSelected("AchievementFrame", step.tabIndex) + end + if not tabReady then + local key = tostring(i) .. ":tab:" .. tostring(step.tabIndex) + tabClickAttempts[key] = (tabClickAttempts[key] or 0) + 1 + if tabClickAttempts[key] <= 6 then + local resume = i + C_Timer.After(0.05, function() executeFrom(resume) end) + return + end + end + + local resume = i + 1 + C_Timer.After(0.05, function() executeFrom(resume) end) + return + end end if step.sideTabIndex then @@ -10607,7 +10689,23 @@ function UI:DirectOpen(data) local categoryToClick = step.statisticsCategory or step.achievementCategory if categoryToClick then - self:ClickAchievementCategory(categoryToClick) + local categoryID = step.statisticsCategoryID or step.achievementCategoryID + if not self:ClickAchievementCategory(categoryToClick, categoryID) then + local key = tostring(i) .. ":" .. tostring(categoryID or categoryToClick) + categoryClickAttempts[key] = (categoryClickAttempts[key] or 0) + 1 + if categoryClickAttempts[key] <= 6 then + local resume = i + C_Timer.After(0.05, function() executeFrom(resume) end) + return + end + end + + local nextStep = steps[i + 1] + if nextStep and (nextStep.achievementCategory or nextStep.statisticsCategory) then + local resume = i + 1 + C_Timer.After(0.05, function() executeFrom(resume) end) + return + end end if step.achievementID then @@ -10918,64 +11016,84 @@ function UI:ClickCharacterSidebar(sidebarIndex) end -- Helper function to click an achievement or statistics category button -function UI:ClickAchievementCategory(categoryName) +function UI:ClickAchievementCategory(categoryName, categoryID) if not AchievementFrame or not AchievementFrame:IsShown() then return false end - local categoryNameLower = slower(categoryName) + local numericCategoryID = tonumber(categoryID) + local categoryNameLower = categoryName and slower(categoryName) or nil + local function MatchesCategory(data) + if not data then return false end + local catID = data.id + local numericCatID = tonumber(catID) + if not numericCatID then return false end + if numericCategoryID then return numericCatID == numericCategoryID end + if categoryNameLower and GetCategoryInfo then + local title = GetCategoryInfo(catID) + if title and slower(title) == categoryNameLower then return true end + end + return false + end + + local function UpdateCategories() + if AchievementFrameCategories_UpdateDataProvider then + AchievementFrameCategories_UpdateDataProvider() + end + end -- Primary: use the data provider to find the category and select it via Blizzard API local categoriesFrame = _G["AchievementFrameCategories"] if categoriesFrame and categoriesFrame.ScrollBox then local scrollBox = categoriesFrame.ScrollBox + if numericCategoryID and AchievementFrameCategories_ExpandToCategory then + AchievementFrameCategories_ExpandToCategory(numericCategoryID) + UpdateCategories() + end local dataProvider = scrollBox.GetDataProvider and scrollBox:GetDataProvider() if dataProvider then local finder = dataProvider.FindElementDataByPredicate or dataProvider.FindByPredicate if finder then - local elementData = finder(dataProvider, function(data) - if not data then return false end - local catID = data.id - if not catID or type(catID) ~= "number" then return false end - if GetCategoryInfo then - local title = GetCategoryInfo(catID) - if title and slower(title) == categoryNameLower then return true end - end - return false - end) + local elementData = finder(dataProvider, MatchesCategory) if elementData then -- Expand parent if hidden if elementData.hidden and elementData.id and AchievementFrameCategories_ExpandToCategory then - AchievementFrameCategories_ExpandToCategory(elementData.id) - if AchievementFrameCategories_UpdateDataProvider then - AchievementFrameCategories_UpdateDataProvider() - end + AchievementFrameCategories_ExpandToCategory(tonumber(elementData.id) or elementData.id) + UpdateCategories() + dataProvider = scrollBox.GetDataProvider and scrollBox:GetDataProvider() or dataProvider + finder = dataProvider and (dataProvider.FindElementDataByPredicate or dataProvider.FindByPredicate) -- Re-find after expanding - elementData = finder(dataProvider, function(data) - if not data then return false end - local catID = data.id - if not catID or type(catID) ~= "number" then return false end - if GetCategoryInfo then - local title = GetCategoryInfo(catID) - if title and slower(title) == categoryNameLower then return true end - end - return false - end) + elementData = finder and finder(dataProvider, MatchesCategory) if not elementData then return false end end -- Try Blizzard's official selection function if AchievementFrameCategories_SelectElementData then - AchievementFrameCategories_SelectElementData(elementData) - return true + if scrollBox.ScrollToElementData then + local alignCenter = ScrollBoxConstants and ScrollBoxConstants.AlignCenter + pcall(scrollBox.ScrollToElementData, scrollBox, elementData, alignCenter) + end + local ok = pcall(AchievementFrameCategories_SelectElementData, elementData) + if ok then + UpdateCategories() + return true + end end -- Fallback: scroll to it and click the visible button - scrollBox:ScrollToElementData(elementData) + if scrollBox.ScrollToElementData then + pcall(scrollBox.ScrollToElementData, scrollBox, elementData) + end local frame = scrollBox.FindFrame and scrollBox:FindFrame(elementData) - if frame and ClickButton(frame) then return true end + if frame and ClickButton(frame.Button or frame) then return true end end end end + local frame = Utils.ScrollBoxFindButton(scrollBox, function(btn) + local data = btn.GetElementData and btn:GetElementData() + return MatchesCategory(data) + end) + if frame and ClickButton(frame.Button or frame) then return true end + end return false diff --git a/Utils.lua b/Utils.lua index 5482923..e5b1169 100644 --- a/Utils.lua +++ b/Utils.lua @@ -1234,6 +1234,28 @@ local function HideOtherMenus(globalName, except) end end +function Utils.HideCursorMenus(globalName) + local closedAny = false + local function hidePool(pool) + if not pool then return end + for i = 1, #pool do + local menu = pool[i] + if menu and menu:IsShown() then + menu:Hide() + closedAny = true + end + end + end + if globalName then + hidePool(cursorMenuPool[globalName]) + else + for _, pool in pairs(cursorMenuPool) do + hidePool(pool) + end + end + return closedAny +end + function Utils.ShowCursorMenu(globalName, rows, opts) opts = opts or {} @@ -1272,12 +1294,15 @@ function Utils.ShowCursorMenu(globalName, rows, opts) self._showedAt = GetTime() self._outsideSince = nil self._hasEntered = false + Utils.SafeCallMethod(self, "EnableKeyboard", true) + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) self:RegisterEvent("GLOBAL_MOUSE_DOWN") self:RegisterEvent("GLOBAL_MOUSE_UP") end) menu:SetScript("OnHide", function(self) self._outsideSince = nil self._hasEntered = false + Utils.SafeCallMethod(self, "EnableKeyboard", false) self:UnregisterEvent("GLOBAL_MOUSE_DOWN") self:UnregisterEvent("GLOBAL_MOUSE_UP") if self.rows then @@ -1308,6 +1333,14 @@ function Utils.ShowCursorMenu(globalName, rows, opts) if self._showedAt and (GetTime() - self._showedAt) < (self.clickGrace or 0.05) then return end if not MenuHasMouse(self) then self:Hide() end end) + menu:SetScript("OnKeyDown", function(self, key) + if key == "ESCAPE" then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + self:Hide() + else + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) + end + end) cursorMenuPool[globalName] = cursorMenuPool[globalName] or {} local pool = cursorMenuPool[globalName] pool[#pool + 1] = menu From ffed9f01fe7ce53f0fcc322d32accbba49ef5908 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 10 May 2026 04:17:48 -0400 Subject: [PATCH 083/103] Improve UI search result actions --- Core.lua | 2 +- Highlight.lua | 19 ++ Options.lua | 21 +- UI.lua | 715 ++++++++++++++++++++++++++++++++++++++----- textures/alt-key.tga | Bin 0 -> 65554 bytes 5 files changed, 683 insertions(+), 74 deletions(-) create mode 100644 textures/alt-key.tga diff --git a/Core.lua b/Core.lua index c24c731..c1379de 100644 --- a/Core.lua +++ b/Core.lua @@ -64,6 +64,7 @@ local DB_DEFAULTS = { autoPinClear = true, -- Auto-clear map pin when player arrives autoTrackPins = true, -- Auto super-track newly placed map pins uiResultsAbove = false, -- Show UI search results above the search bar + showResultShortcutHints = true, -- Show Alt+number hints on UI search results panelOpacity = 0.9, -- Options panel background opacity showMinimapButton = true, -- Show toggle button on minimap minimapButtonAngle = 200, -- Position angle (degrees) around minimap edge @@ -93,7 +94,6 @@ local DB_DEFAULTS = { mapTabAutoExpand = true, -- Auto-expand a matched parent header to show all its world-hierarchy children alwaysShowRares = false, -- Persistent rare tracking: show active rares on map without searching uiSearchFilters = { -- UI search category filters (all enabled by default) - ui = true, achievements = true, currencies = true, reputations = true, diff --git a/Highlight.lua b/Highlight.lua index e4c1f0f..12fdc74 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -485,6 +485,25 @@ function Highlight:UpdateGuide() return end + -- Battle pet terminal step. The UI module owns the Pet Journal + -- reveal logic because it already handles pet IDs/species IDs and + -- direct-open behavior; Guide only needs to highlight the row it + -- returns. + if step.petID or step.speciesID then + local row = ns.UI and ns.UI.RevealPetInJournal + and ns.UI:RevealPetInJournal(step) + if row then + self:HighlightFrame(row) + if canHoverDismiss() and row:IsMouseOver() then + self:Cancel() + return + end + elseif isLastStep then + self:ShowInstruction(step.text or "Find the pet in the Pet Journal") + end + return + end + -- EJ tier + dungeon/raid tab (boss navigation). Sets the tier on -- the EJ then highlights the correct tab; advances when both -- the tab is active and the InstanceSelect ScrollBox is showing. diff --git a/Options.lua b/Options.lua index 9e77c19..d61438e 100644 --- a/Options.lua +++ b/Options.lua @@ -41,6 +41,7 @@ local UI_DEFAULTS = { lockPosition = false, staticOpacity = false, uiResultsAbove = false, + showResultShortcutHints = true, fontSize = 0.9, uiSearchScale = 1.0, uiSearchWidth = 1.54, @@ -188,6 +189,7 @@ local function SyncOptionControls() if optionsFrame.lockPositionCheckbox then optionsFrame.lockPositionCheckbox:SetChecked(EasyFind.db.lockPosition or false) end if optionsFrame.loginMessageCheckbox then optionsFrame.loginMessageCheckbox:SetChecked(EasyFind.db.showLoginMessage ~= false) end if optionsFrame.uiResultsAboveCheckbox then optionsFrame.uiResultsAboveCheckbox:SetChecked(EasyFind.db.uiResultsAbove or false) end + if optionsFrame.resultShortcutHintsCheckbox then optionsFrame.resultShortcutHintsCheckbox:SetChecked(EasyFind.db.showResultShortcutHints ~= false) end if optionsFrame.minimapBtnCheckbox then optionsFrame.minimapBtnCheckbox:SetChecked(EasyFind.db.showMinimapButton ~= false) end if optionsFrame.rareTrackCheckbox then optionsFrame.rareTrackCheckbox:SetChecked(EasyFind.db.alwaysShowRares or false) end @@ -1270,9 +1272,21 @@ function Options:Initialize() end) optionsFrame.uiResultsAboveCheckbox = uiResultsAboveCheckbox + local resultShortcutHintsCheckbox = CreateCheckbox(sec1, "ResultShortcutHints", "Show Alt+Number Hints", + "When enabled, each visible UI search result shows a small Alt+1 through Alt+8 reminder.\n\nWhen disabled, the reminders are hidden but the Alt+number shortcuts still activate the same rows.") + resultShortcutHintsCheckbox:SetPoint("TOPLEFT", uiResultsAboveCheckbox, "BOTTOMLEFT", 0, -2) + resultShortcutHintsCheckbox:SetChecked(EasyFind.db.showResultShortcutHints ~= false) + resultShortcutHintsCheckbox:SetScript("OnClick", function(self) + EasyFind.db.showResultShortcutHints = self:GetChecked() + if ns.UI and ns.UI.RefreshResults then + ns.UI:RefreshResults() + end + end) + optionsFrame.resultShortcutHintsCheckbox = resultShortcutHintsCheckbox + local uiFontSlider = CreateSlider(sec1, "UIFontSize", "Font Size|cffff3333*|r", 0.5, 2.0, 0.1, "Changing font size also affects search bar height and results window sizing.", nil, 1.0) - uiFontSlider:SetPoint("TOPLEFT", uiResultsAboveCheckbox, "BOTTOMLEFT", 4, -20) + uiFontSlider:SetPoint("TOPLEFT", resultShortcutHintsCheckbox, "BOTTOMLEFT", 4, -20) uiFontSlider:SetValue(EasyFind.db.fontSize or 1.0) uiFontSlider:HookScript("OnValueChanged", function(self, value) EasyFind.db.fontSize = value @@ -1298,7 +1312,7 @@ function Options:Initialize() StaticPopup_Show("EASYFIND_RESET_UI_POS") end) - uiControls = { resizeUIBtn, resetUIBtn, resetUIPosBtn, uiFontSlider, autoHideCheckbox, smartShowCheckbox, staticOpacityCheckbox, lockPositionCheckbox, uiResultsAboveCheckbox } + uiControls = { resizeUIBtn, resetUIBtn, resetUIPosBtn, uiFontSlider, autoHideCheckbox, smartShowCheckbox, staticOpacityCheckbox, lockPositionCheckbox, uiResultsAboveCheckbox, resultShortcutHintsCheckbox } UpdateUIToggleVisual() -- SECTION 3: Map Search @@ -1924,6 +1938,9 @@ function Options:Show() end optionsFrame.loginMessageCheckbox:SetChecked(EasyFind.db.showLoginMessage ~= false) optionsFrame.uiResultsAboveCheckbox:SetChecked(EasyFind.db.uiResultsAbove or false) + if optionsFrame.resultShortcutHintsCheckbox then + optionsFrame.resultShortcutHintsCheckbox:SetChecked(EasyFind.db.showResultShortcutHints ~= false) + end optionsFrame.minimapBtnCheckbox:SetChecked(EasyFind.db.showMinimapButton or false) if optionsFrame.mapPinGroup then optionsFrame.mapPinGroup:UpdateVisuals() end if optionsFrame.automationGroup then optionsFrame.automationGroup:UpdateVisuals() end diff --git a/UI.lua b/UI.lua index 936de39..d46b8f8 100644 --- a/UI.lua +++ b/UI.lua @@ -145,12 +145,20 @@ local resultsFrame local containerFrame local resultButtons = {} local MAX_BUTTON_POOL = 100 +local RESULT_SHORTCUT_MAX = 8 +local RESULT_SHORTCUT_ICON = "Interface\\AddOns\\EasyFind\\textures\\alt-key" +local RESULT_SHORTCUT_WIDTH = 34 +local RESULT_SHORTCUT_ICON_SIZE = 14 +local RESULT_SHORTCUT_RIGHT_PAD = 0 +local RESULT_SHORTCUT_GAP = -4 -- Hard render cap on the search-match portion of the result list. Pinned -- items are counted separately. Pre-render cap in OnSearchTextChanged -- already trims to 15 before flattening; this is a backstop for any -- other call path (alias views, container expansion) that lands here. local MAX_SEARCH_RESULT_ROWS = 15 local EnsureResultButton +local resultShortcutFrame +local petFavoriteOverrides = {} local inCombat = false local selectingResult = false -- guard: suppress OnTextChanged re-renders during SelectResult local deferredRepRefreshPending = false -- deferred re-render to let IsTruncated() settle @@ -203,6 +211,12 @@ local FLAT_CATEGORY_ICONS = { gearSet = { atlas = "equipmentmanager-spec-border" }, } +local BOSS_PORTRAIT_TEXCOORD = { 0.22, 0.78, 0, 1 } + +local function IsBossResultData(data) + return data and data.encounterID and data.category == "Boss" +end + local function ResolveGearSetIcon() local entry = FLAT_CATEGORY_ICONS.gearSet if entry and entry._resolved then return end @@ -463,7 +477,7 @@ local function GetActionHint(data) if not data then return nil end if data.titleID then return "Select to apply as your title" end if data.mountID then return "Select to summon mount" end - if data.petID then return "Select to summon pet" end + if data.petID or data.speciesID then return "Select to summon pet | Ctrl+click to show in journal" end if data.toyItemID then return data.isToyboxOnly and "Select to show in Toy Box" or "Select to use toy" end @@ -581,6 +595,34 @@ local function ShowPinPopup(_, isPinned, onPinAction, onGuide, onAddAlias, extra }, extra) end +function UI:KeepPinnedResultsOpenBriefly() + if #GetAllPins() == 0 then return false end + UI._keepPinnedResultsOpenUntil = (GetTime and GetTime() or 0) + 0.35 + if searchFrame and searchFrame.editBox + and strtrim(searchFrame.editBox:GetText() or "") == "" then + UI:ShowPinnedItems() + end + return true +end + +local function IsOptionsSurfaceMouseOver() + local frame = ns.optionsFrame + if Utils.IsFrameOrChildMouseOver(frame) then return true end + if not frame then return false end + local guards = { + frame.indicatorFlyout, + frame.colorFlyout, + frame.fontFlyout, + frame.mapTabGroup and frame.mapTabGroup.flyout, + frame.mapPinGroup and frame.mapPinGroup.flyout, + frame.automationGroup and frame.automationGroup.flyout, + } + for i = 1, #guards do + if Utils.IsFrameOrChildMouseOver(guards[i]) then return true end + end + return false +end + local unearnedTooltip local function ClearResultTooltips() @@ -740,6 +782,35 @@ local function ApplyResultRowFonts(row, theme) ScaleFont(row.amountText, "GameFontNormalSmall") ScaleFont(row.repBarText, "GameFontNormalSmall") ScaleFont(row.settingSliderValue, "GameFontNormalSmall") + ScaleFont(row.shortcutNumberText, "GameFontDisableSmall") +end + +local function IsSecureActionResult(data) + return data and (data.outfitID or data.toyItemID + or (data.spellID and not IsSpellbookOnlyAbility(data)) + or data.mountID or data.macroIndex or data.slashCommand + or (data.itemID and data.category == "Bag")) +end + +local function GetResultShortcutIndex(key) + if not resultsFrame or not resultsFrame:IsShown() then return nil end + if not IsAltKeyDown or not IsAltKeyDown() then return nil end + if (IsControlKeyDown and IsControlKeyDown()) + or (IsShiftKeyDown and IsShiftKeyDown()) then + return nil + end + local digit = tonumber(key) + if not digit then + digit = tonumber((key or ""):match("^NUMPAD([1-8])$")) + end + if digit and digit >= 1 and digit <= RESULT_SHORTCUT_MAX then + return digit + end + return nil +end + +local function ShouldShowResultShortcutHints() + return not (EasyFind and EasyFind.db and EasyFind.db.showResultShortcutHints == false) end function UI:CreateUnearnedTooltip() @@ -1036,6 +1107,7 @@ function UI:CreateSearchFrame() -- after the autocomplete strip. local onGuard = false if resultsFrame and resultsFrame:IsMouseOver() then onGuard = true end + if not onGuard and IsOptionsSurfaceMouseOver() then onGuard = true end if not onGuard then local guards = { _G["EasyFindUIFilterDropdown"], @@ -1074,6 +1146,9 @@ function UI:CreateSearchFrame() local now = GetTime and GetTime() or 0 if now <= keepUntil and #GetAllPins() > 0 then UI:ShowPinnedItems() + if searchFrame.editBox.blockFocus then + searchFrame.editBox.blockFocus = nil + end return end end @@ -1083,6 +1158,7 @@ function UI:CreateSearchFrame() if (sf and sf:IsShown()) or (ssf and ssf:IsShown()) then return end local dd = _G["EasyFindUIFilterDropdown"] if dd and dd:IsShown() then return end + if IsOptionsSurfaceMouseOver() then return end UI:HideResults() -- Now that results are hidden, let smart show fade the bar out if EasyFind.db.smartShow then @@ -1429,6 +1505,15 @@ function UI:CreateSearchFrame() -- editbox, otherwise it just types as a character and the user -- can't dismiss with the same key they used to open. editBox:SetScript("OnKeyDown", function(self, key) + local shortcutIndex = GetResultShortcutIndex(key) + if shortcutIndex then + local shortcutResult = UI:ActivateVisibleResultShortcut(shortcutIndex) + if shortcutResult then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", shortcutResult == "binding") + return + end + end + -- Match the keybind capture's combo format: ALT-CTRL-SHIFT-key. -- GetBindingAction is the correct API (GetBindingByKey doesn't -- exist) and returns the binding name for a given key combo. @@ -1782,6 +1867,15 @@ function UI:CreateSearchFrame() end navFrame:SetScript("OnKeyDown", function(self, key) + local shortcutIndex = GetResultShortcutIndex(key) + if shortcutIndex then + local shortcutResult = UI:ActivateVisibleResultShortcut(shortcutIndex) + if shortcutResult then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", shortcutResult == "binding") + return + end + end + -- Secure-action rows: let Enter propagate to the override -- binding so the secure click dispatch fires (same as a mouse -- click). Without this navFrame swallows Enter and the @@ -2054,6 +2148,7 @@ function UI:CreateSearchFrame() end if self:IsMouseOver() then return end if resultsFrame and resultsFrame:IsShown() and resultsFrame:IsMouseOver() then return end + if IsOptionsSurfaceMouseOver() then return end if activeKeybindBtn then return end local dropdown = self.filterDropdown if dropdown and dropdown:IsShown() and dropdown:IsMouseOver() then return end @@ -2168,7 +2263,6 @@ local UI_FILTER_OPTIONS = { -- Title icon from PaperDollSidebarTab2 (Titles tab) spritesheet 514608. { key = "titles", label = "Titles", iconTex = 514608, iconCoords = { 0.016, 0.531, 0.324, 0.461 } }, - { key = "ui", label = "UI Elements", iconAtlas = "common-search-magnifyingglass" }, } -- All filter keys (top-level + sub-filters in flyouts). Used by Toggle @@ -2184,9 +2278,9 @@ local function ForEachFilterKey(callback) end end --- Module-level helpers for bucketing UI search results into one of: --- "achievements" / "currencies" / "reputations" / "ui" (UI elements). --- Used both in the per-keystroke filter and category sort. +-- Module-level helpers for bucketing UI search results into optional +-- filter categories. Entries with no bucket are base UI search results +-- and are always searchable. local UI_BUCKET_BY_CATEGORY = { ["Ability"] = "abilities", ["Boss"] = "bosses", @@ -2208,15 +2302,16 @@ local UI_BUCKET_BY_CATEGORY = { } local function GetUIBucket(d) - -- Returns one of the bucket keys for non-collection / non-map UI - -- entries, or nil for entries handled by a separate dedicated filter. + -- Returns one of the bucket keys for filtered non-collection / + -- non-map UI entries, or nil for base entries / entries handled by + -- a separate dedicated filter. if not d then return nil end if d.mountID or d.toyItemID or d.petID or d.outfitID or d.heirloomItemID or d.transmogSetID or (d.itemID and d.category == "Loot") or d.mapSearchResult then return nil end - return UI_BUCKET_BY_CATEGORY[d.category] or "ui" + return UI_BUCKET_BY_CATEGORY[d.category] end -- Builds the Appearance Sets options popup: a class selector button + @@ -4299,6 +4394,201 @@ function EnsureResultButton(index) return row end +local function ReplaceRightPointToShortcut(row, frame) + local shortcut = row and row.shortcutGroup + if not shortcut or not frame or not frame.GetNumPoints then return false end + local count = frame:GetNumPoints() + if count == 0 then return false end + + local points = {} + local changed = false + for i = 1, count do + local point, relTo, relPoint, xOfs, yOfs = frame:GetPoint(i) + if point == "RIGHT" and relTo == row and relPoint == "RIGHT" then + points[i] = { point, shortcut, "LEFT", -RESULT_SHORTCUT_GAP, yOfs or 0 } + changed = true + else + points[i] = { point, relTo, relPoint, xOfs or 0, yOfs or 0 } + end + end + if not changed then return false end + + frame:ClearAllPoints() + for i = 1, #points do + local p = points[i] + if p[2] then + frame:SetPoint(p[1], p[2], p[3], p[4], p[5]) + else + frame:SetPoint(p[1], p[4], p[5]) + end + end + return true +end + +local function RestoreRightPointFromShortcut(row, frame, xOfs) + local shortcut = row and row.shortcutGroup + if not shortcut or not frame or not frame.GetNumPoints then return false end + local count = frame:GetNumPoints() + if count == 0 then return false end + + local points = {} + local changed = false + for i = 1, count do + local point, relTo, relPoint, oldX, yOfs = frame:GetPoint(i) + if point == "RIGHT" and relTo == shortcut and relPoint == "LEFT" then + points[i] = { point, row, "RIGHT", xOfs or -8, yOfs or 0 } + changed = true + else + points[i] = { point, relTo, relPoint, oldX or 0, yOfs or 0 } + end + end + if not changed then return false end + + frame:ClearAllPoints() + for i = 1, #points do + local p = points[i] + if p[2] then + frame:SetPoint(p[1], p[2], p[3], p[4], p[5]) + else + frame:SetPoint(p[1], p[4], p[5]) + end + end + return true +end + +local function RestoreResultShortcutGutter(row) + if not row or not row.shortcutGroup then return end + RestoreRightPointFromShortcut(row, row.icon, -5) + RestoreRightPointFromShortcut(row, row.amountText, -8) + RestoreRightPointFromShortcut(row, row.settingState, -8) + RestoreRightPointFromShortcut(row, row.settingSliderGroup, -6) + RestoreRightPointFromShortcut(row, row.settingKeybindGroup, -6) + RestoreRightPointFromShortcut(row, row.settingDropdownGroup, -6) + RestoreRightPointFromShortcut(row, row.repBar, -6) + RestoreRightPointFromShortcut(row, row.pinToggle, -8) + RestoreRightPointFromShortcut(row, row.text, -8) + RestoreRightPointFromShortcut(row, row.pathSubtext, -8) +end + +local function ApplyResultShortcutGutter(row) + if not row or not row.shortcutGroup then return end + row.shortcutGroup:ClearAllPoints() + row.shortcutGroup:SetPoint("RIGHT", row, "RIGHT", -RESULT_SHORTCUT_RIGHT_PAD, 0) + row.shortcutGroup:SetSize(RESULT_SHORTCUT_WIDTH, 16) + + ReplaceRightPointToShortcut(row, row.icon) + ReplaceRightPointToShortcut(row, row.amountText) + ReplaceRightPointToShortcut(row, row.settingState) + ReplaceRightPointToShortcut(row, row.settingSliderGroup) + ReplaceRightPointToShortcut(row, row.settingKeybindGroup) + ReplaceRightPointToShortcut(row, row.settingDropdownGroup) + ReplaceRightPointToShortcut(row, row.repBar) + ReplaceRightPointToShortcut(row, row.pinToggle) + ReplaceRightPointToShortcut(row, row.text) + ReplaceRightPointToShortcut(row, row.pathSubtext) +end + +local function IsShortcutEligibleRow(row) + return row and row:IsShown() and row.data + and not row.isPinHeader and not row.isSectionHeader + and not row.isUnearnedCurrency +end + +local function ClearResultShortcutBindings() + if resultShortcutFrame and not InCombatLockdown() then + ClearOverrideBindings(resultShortcutFrame) + end +end + +function UI:UpdateVisibleResultShortcuts() + ClearResultShortcutBindings() + local showShortcutHints = ShouldShowResultShortcutHints() + + for i = 1, MAX_BUTTON_POOL do + local row = resultButtons[i] + if not row then break end + row._efShortcutIndex = nil + row._efShortcutBindingReady = nil + if row.shortcutNumberText then row.shortcutNumberText:SetText("") end + if row.shortcutGroup then + row.shortcutGroup:Hide() + end + end + + if not (resultsFrame and resultsFrame:IsShown() + and resultsFrame.scrollFrame and resultShortcutFrame) then + return + end + + local scrollTop = resultsFrame.scrollFrame:GetVerticalScroll() or 0 + local viewH = resultsFrame.scrollFrame:GetHeight() or 0 + if viewH <= 0 then viewH = resultsFrame:GetHeight() or 0 end + local scrollBottom = scrollTop + viewH + local assigned = 0 + + for i = 1, MAX_BUTTON_POOL do + local row = resultButtons[i] + if not row then break end + if IsShortcutEligibleRow(row) then + local rowTop = row._efContentTop or 0 + local rowBottom = row._efContentBottom or (rowTop + (row:GetHeight() or 0)) + if rowBottom > scrollTop + 0.5 and rowTop < scrollBottom - 0.5 then + assigned = assigned + 1 + if assigned <= RESULT_SHORTCUT_MAX then + row._efShortcutIndex = assigned + if showShortcutHints and row.shortcutNumberText then + row.shortcutNumberText:SetText(tostring(assigned)) + end + if row.shortcutGroup then + row.shortcutGroup:SetShown(showShortcutHints) + end + if not InCombatLockdown() then + local targetName + if IsSecureActionResult(row.data) then + targetName = row:GetName() + row._efShortcutBindingReady = true + else + local proxy = resultShortcutFrame.shortcutButtons + and resultShortcutFrame.shortcutButtons[assigned] + if proxy then + proxy._shortcutIndex = assigned + targetName = proxy:GetName() + end + end + if targetName then + SetOverrideBindingClick(resultShortcutFrame, true, "ALT-" .. assigned, targetName, "LeftButton") + SetOverrideBindingClick(resultShortcutFrame, true, "ALT-NUMPAD" .. assigned, targetName, "LeftButton") + end + end + end + end + end + end +end + +function UI:ActivateVisibleResultShortcut(shortcutIndex) + if not shortcutIndex then return nil end + for i = 1, MAX_BUTTON_POOL do + local row = resultButtons[i] + if not row then break end + if row._efShortcutIndex == shortcutIndex and IsShortcutEligibleRow(row) then + if IsSecureActionResult(row.data) then + if row._efShortcutBindingReady then return "binding" end + if not InCombatLockdown() and ClickButton(row) then + return "handled" + end + return "binding" + end + selectedIndex = i + toggleFocused = false + self:UpdateSelectionHighlight(true) + self:ActivateResultRow(row) + return "handled" + end + end + return nil +end + function UI:CreateResultsFrame() resultsFrame = CreateFrame("Frame", "EasyFindResultsFrame", searchFrame, "BackdropTemplate") resultsFrame:SetWidth(380) -- Wide to accommodate tree indentation @@ -4339,6 +4629,7 @@ function UI:CreateResultsFrame() if event ~= "GLOBAL_MOUSE_DOWN" then return end if self:IsMouseOver() then return end if searchFrame and searchFrame:IsMouseOver() then return end + if IsOptionsSurfaceMouseOver() then return end local guards = { _G["EasyFindUIFilterDropdown"], _G["EasyFindPinPopup"], @@ -4382,6 +4673,20 @@ function UI:CreateResultsFrame() -- Minimal retail-style scrollbar (overlays right edge, no content squish) resultsFrame.scrollBar = ns.Utils.CreateMinimalScrollBar(scrollFrame, resultsFrame) + scrollFrame:HookScript("OnVerticalScroll", function() + UI:UpdateVisibleResultShortcuts() + end) + + resultShortcutFrame = CreateFrame("Frame", nil, searchFrame) + resultShortcutFrame.shortcutButtons = {} + for i = 1, RESULT_SHORTCUT_MAX do + local proxy = CreateFrame("Button", "EasyFindResultShortcutButton" .. i, resultShortcutFrame) + proxy._shortcutIndex = i + proxy:SetScript("OnClick", function(self) + UI:ActivateVisibleResultShortcut(self._shortcutIndex) + end) + resultShortcutFrame.shortcutButtons[i] = proxy + end -- Pin section separator line (golden, shown between pinned items and search results) local pinSeparator = scrollChild:CreateTexture(nil, "ARTWORK") @@ -5053,6 +5358,27 @@ function UI:CreateResultButton(index) amountText:Hide() resultRow.amountText = amountText + local shortcutGroup = CreateFrame("Frame", nil, resultRow) + shortcutGroup:SetPoint("RIGHT", resultRow, "RIGHT", -RESULT_SHORTCUT_RIGHT_PAD, 0) + shortcutGroup:SetSize(RESULT_SHORTCUT_WIDTH, 16) + shortcutGroup:Hide() + resultRow.shortcutGroup = shortcutGroup + + local shortcutAltIcon = shortcutGroup:CreateTexture(nil, "OVERLAY") + shortcutAltIcon:SetTexture(RESULT_SHORTCUT_ICON) + shortcutAltIcon:SetSize(RESULT_SHORTCUT_ICON_SIZE, RESULT_SHORTCUT_ICON_SIZE) + shortcutAltIcon:SetVertexColor(0.58, 0.58, 0.58, 0.85) + resultRow.shortcutAltIcon = shortcutAltIcon + + local shortcutNumberText = shortcutGroup:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + shortcutNumberText:SetPoint("RIGHT", shortcutGroup, "RIGHT", 1, 0) + shortcutNumberText:SetWidth(7) + shortcutNumberText:SetJustifyH("RIGHT") + shortcutNumberText:SetTextColor(0.58, 0.58, 0.58, 0.85) + shortcutNumberText:SetText("") + resultRow.shortcutNumberText = shortcutNumberText + shortcutAltIcon:SetPoint("RIGHT", shortcutNumberText, "LEFT", 2, 0) + -- Right-aligned setting state widget (checkbox + optional checkmark -- overlay for boolean settings). For dropdowns we reuse amountText -- to show the current value; this widget is the boolean visual. @@ -5906,6 +6232,7 @@ function UI:CreateResultButton(index) local isPinned = IsUIItemPinned(pinData) local hasGuide = pinData.steps or pinData.transmogSetID or (pinData.category == "Loot" and pinData.itemID) + or pinData.petID or pinData.speciesID or pinData.mapSearchResult local onGuide = hasGuide and function() UI:SelectResult(pinData, true) @@ -5920,32 +6247,54 @@ function UI:CreateResultButton(index) local isTracked = UI:IsAchievementTracked(achID) extra = { isTracked = isTracked, - onTrack = function() UI:ToggleAchievementTracked(achID) end, + onTrack = function() + UI:KeepPinnedResultsOpenBriefly() + UI:ToggleAchievementTracked(achID) + end, } elseif pinData.category == "Currency" and pinData.currencyID then local cid = pinData.currencyID extra = { isOnBackpack = UI:IsCurrencyOnBackpack(cid), - onToggleBackpack = function() UI:ToggleCurrencyBackpack(cid) end, + onToggleBackpack = function() + UI:KeepPinnedResultsOpenBriefly() + UI:ToggleCurrencyBackpack(cid) + end, } if UI:IsCurrencyTransferable(cid) then - extra.onTransfer = function() UI:RouteCurrencyTransfer(pinData) end + extra.onTransfer = function() + UI:KeepPinnedResultsOpenBriefly() + UI:RouteCurrencyTransfer(pinData) + end end elseif pinData.transmogSetID then local sid = pinData.transmogSetID extra = { isFavorite = UI:IsTransmogSetFavorite(sid), - onToggleFavorite = function() UI:ToggleTransmogSetFavorite(sid) end, + onToggleFavorite = function() + UI:KeepPinnedResultsOpenBriefly() + UI:ToggleTransmogSetFavorite(sid) + end, } elseif pinData.petID then local pid = pinData.petID local cageable = UI:IsPetCageable(pid) extra = { - onSummon = function() UI:SummonPet(pid) end, - onRename = function() UI:RenamePet(pid) end, + onSummon = function() + UI:KeepPinnedResultsOpenBriefly() + UI:SummonPet(pid) + end, + onRename = function() + UI:KeepPinnedResultsOpenBriefly() + UI:RenamePet(pid) + end, isFavorite = UI:IsPetFavorite(pid), - onToggleFavorite = function() UI:TogglePetFavorite(pid) end, + onToggleFavorite = function() + UI:KeepPinnedResultsOpenBriefly() + UI:TogglePetFavorite(pid) + end, onCageOrRelease = function() + UI:KeepPinnedResultsOpenBriefly() if cageable then UI:CagePet(pid) else UI:ReleasePet(pid) end end, isCageable = cageable, @@ -5960,8 +6309,7 @@ function UI:CreateResultButton(index) local editBox = searchFrame and searchFrame.editBox local text = editBox and editBox:GetText() or "" if text == "" then - local pinsRemain = #GetAllPins() > 0 - UI._keepPinnedResultsOpenUntil = pinsRemain and ((GetTime and GetTime() or 0) + 0.25) or nil + local pinsRemain = UI:KeepPinnedResultsOpenBriefly() UI:ShowPinnedItems() if pinsRemain and editBox and not (navFrame and navFrame:IsKeyboardEnabled()) then @@ -6565,14 +6913,14 @@ function UI:OnSearchTextChanged(text, force) end -- Bucket-aware UI filter: drop UI entries whose bucket - -- (ui / abilities / achievements / currencies / reputations / bags - -- / options) is unchecked. Options is a parent toggle: when off, - -- both gameOptions and addonOptions buckets are treated as off. + -- (abilities / achievements / currencies / reputations / bags / + -- options) is unchecked. Base UI entries have no bucket and are + -- always searchable. Options is a parent toggle: when off, both + -- gameOptions and addonOptions buckets are treated as off. -- abilityHidePassives also drops isPassive ability rows here so -- the filter applies regardless of which bucket is on. local hidePassives = EasyFind.db.abilityHidePassives - if filters and (filters.ui == false or filters.abilities == false - or filters.bosses == false + if filters and (filters.abilities == false or filters.bosses == false or filters.achievements == false or filters.statistics == false or filters.currencies == false or filters.reputations == false or filters.bags == false or filters.macros == false @@ -6654,10 +7002,10 @@ function UI:OnSearchTextChanged(text, force) local lowerQ = slower(text) local qLen = #lowerQ -- Blizzard's index returns matches across name + description - -- + criteria; we only want name matches here (the user has - -- the UI Elements filter for navigating to achievement - -- categories by their subtext). Drop anything ScoreName - -- can't rank against the name itself. + -- + criteria; we only want name matches here. Base UI entries + -- still cover direct navigation to achievement categories, so + -- drop anything ScoreName can't rank against the achievement + -- name itself. for ai = 1, #achHits do local entry = achHits[ai] local score = ns.Database:ScoreName(entry.nameLower, lowerQ, qLen) @@ -7000,6 +7348,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local yOffset = 0 local pinEndYOffset = 0 + local showShortcutHints = ShouldShowResultShortcutHints() wipe(SCRATCH.catSepYPositions) local catSepYPositions = SCRATCH.catSepYPositions local hasSideBySideRepBar = false @@ -7026,6 +7375,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- the name and below the path so neither bleeds into the rep bar. local padL = theme.resultsPadLeft or 10 local entryRowH = entry.isFlat and (rowH + flatExtraH) or rowH + local rowContentTop = yOffset resultRow:SetSize(resultsFrame:GetWidth() - padL * 2 - scrollInset, entryRowH) resultRow:ClearAllPoints() resultRow:SetPoint("TOPLEFT", resultsFrame.scrollChild, "TOPLEFT", padL, -yOffset) @@ -7125,6 +7475,13 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if resultRow.pinIcon then resultRow.pinIcon:Hide() end if resultRow.pinToggle then resultRow.pinToggle:Hide() end if resultRow.pinHeaderLine then resultRow.pinHeaderLine:Hide() end + resultRow._efShortcutIndex = nil + resultRow._efShortcutBindingReady = nil + resultRow._efContentTop = rowContentTop + resultRow._efContentBottom = rowContentTop + entryRowH + if resultRow.shortcutNumberText then resultRow.shortcutNumberText:SetText("") end + if resultRow.shortcutGroup then resultRow.shortcutGroup:Hide() end + RestoreResultShortcutGutter(resultRow) -- Tree connector drawing for d = 1, MAX_DEPTH do @@ -7612,6 +7969,9 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.icon:SetTexture(nil) resultRow.icon:SetTexCoord(0, 1, 0, 1) resultRow.icon:SetTexture(iconFileID) + if IsBossResultData(data) then + resultRow.icon:SetTexCoord(unpack(BOSS_PORTRAIT_TEXCOORD)) + end resultRow.icon:SetSize(rowIconSize, rowIconSize) resultRow.icon:ClearAllPoints() resultRow.icon:SetPoint("RIGHT", resultRow, "RIGHT", rightOffset, 0) @@ -8315,10 +8675,16 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) or (d.achievementID and d.category == "Achievement") or d.mapSearchResult) if rightSideIcon then - local rightSize = entryRowH - 20 + local rightSize = entryRowH - 18 if rightSize < (theme.iconSize or 16) then rightSize = theme.iconSize or 16 end + if IsBossResultData(d) then + rightSize = entryRowH - 14 + if rightSize < (theme.iconSize or 16) then + rightSize = theme.iconSize or 16 + end + end resultRow.icon:SetSize(rightSize, rightSize) else local flatIconSize = entryRowH - 16 @@ -8489,11 +8855,20 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if hasPendingApply and resultRow.settingApplyExtH then actualH = actualH + resultRow.settingApplyExtH + 2 end + resultRow._efContentBottom = rowContentTop + actualH + if showShortcutHints and resultRow.data and not resultRow.isPinHeader + and not resultRow.isSectionHeader and not resultRow.isUnearnedCurrency then + ApplyResultShortcutGutter(resultRow) + end yOffset = yOffset + actualH resultRow:Show() elseif resultRow then resultRow:Hide() resultRow.isPinHeader = false + resultRow._efShortcutIndex = nil + resultRow._efShortcutBindingReady = nil + resultRow._efContentTop = nil + resultRow._efContentBottom = nil if not InCombatLockdown() then resultRow:SetAttribute("type", nil) resultRow:SetAttribute("toy", nil) @@ -8508,6 +8883,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.repBar:Hide() if resultRow.flatCatIcon then resultRow.flatCatIcon:Hide() end if resultRow.pathSubtext then resultRow.pathSubtext:Hide() end + if resultRow.shortcutGroup then resultRow.shortcutGroup:Hide() end for d = 1, MAX_DEPTH do resultRow.treeVert[d]:Hide() resultRow.treeElbow[d]:Hide() @@ -8602,6 +8978,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end resultsFrame:Show() + self:UpdateVisibleResultShortcuts() -- If any rep bar row is in side-by-side mode, schedule one deferred re-render so -- IsTruncated() can reflect the layout we just set (it reads the previous frame's state). @@ -8832,15 +9209,18 @@ local function LiftPopupStrata(popup) popup._easyFindStrataLifted = true popup._easyFindOriginalStrata = popup:GetFrameStrata() popup:SetFrameStrata("TOOLTIP") - popup:HookScript("OnHide", function(self) - if self._easyFindStrataLifted then - if self._easyFindOriginalStrata then - self:SetFrameStrata(self._easyFindOriginalStrata) + if not popup._easyFindStrataRestoreHooked then + popup._easyFindStrataRestoreHooked = true + popup:HookScript("OnHide", function(self) + if self._easyFindStrataLifted then + if self._easyFindOriginalStrata then + self:SetFrameStrata(self._easyFindOriginalStrata) + end + self._easyFindStrataLifted = nil + self._easyFindOriginalStrata = nil end - self._easyFindStrataLifted = nil - self._easyFindOriginalStrata = nil - end - end) + end) + end end -- Show the unapplied-settings popup (if not already up) and lift its @@ -8874,6 +9254,7 @@ function UI:HideResults() end if searchFrame.StopKeyRepeat then searchFrame.StopKeyRepeat() end if searchFrame.ClearToolbarFocus then searchFrame.ClearToolbarFocus() end + ClearResultShortcutBindings() if not resultsFrame then return end resultsFrame:Hide() -- Collapse the combined container back to bar-only height: the @@ -8905,7 +9286,12 @@ function UI:HideResults() local row = resultButtons[i] if row then row.data = nil + row._efShortcutIndex = nil + row._efShortcutBindingReady = nil + row._efContentTop = nil + row._efContentBottom = nil row:Hide() + if row.shortcutGroup then row.shortcutGroup:Hide() end if row.icon then row.icon.mountID = nil row.icon.toyItemID = nil @@ -9132,19 +9518,22 @@ function UI:UpdateSelectionHighlight(skipRefocus) end end +function UI:ActivateResultRow(resultRow) + if not resultRow or not resultRow:IsShown() then return false end + if resultRow.isUnearnedCurrency or resultRow.isPinHeader or resultRow.isSectionHeader then + return true + end + if not resultRow.data then return false end + + if ActivateSettingResult(resultRow.data) then return true end + self:SelectResult(resultRow.data) + return true +end + function UI:ActivateSelected() if selectedIndex > 0 and selectedIndex <= MAX_BUTTON_POOL then local resultRow = resultButtons[selectedIndex] if resultRow and resultRow:IsShown() then - -- Don't allow activating unearned currencies - if resultRow.isUnearnedCurrency then - return - end - - if resultRow.isPinHeader then - return - end - if resultRow.isPathNode and toggleFocused then -- Toggle collapse when focus is on the +/- control local key = (resultRow.pathNodeName or "") .. "_" .. (resultRow.pathNodeDepth or 0) @@ -9157,9 +9546,8 @@ function UI:ActivateSelected() toggleFocused = savedToggle self:UpdateSelectionHighlight() end - elseif resultRow.data then - if ActivateSettingResult(resultRow.data) then return end - self:SelectResult(resultRow.data) + else + self:ActivateResultRow(resultRow) end return end @@ -10083,6 +10471,146 @@ function UI:OpenAbilityInSpellbook(data) C_Timer.After(0.05, function() reveal(1) end) end +local function GetPetJournalFrame() + return _G["PetJournal"] + or (CollectionsJournal and CollectionsJournal.PetJournal) + or (CollectionsJournal and CollectionsJournal.PetJournalFrame) +end + +local function BuildPetJournalGuideData(data) + return { + name = data.name, + petID = data.petID, + speciesID = data.speciesID, + steps = { + { buttonFrame = "CollectionsMicroButton" }, + { waitForFrame = "CollectionsJournal", tabIndex = 2 }, + { + waitForFrame = "CollectionsJournal", + petID = data.petID, + speciesID = data.speciesID, + petName = data.name, + }, + }, + } +end + +function UI:ResolvePetGUID(data) + if not data then return nil end + local guid = data.petID + if not (C_PetJournal and data.speciesID + and C_PetJournal.GetNumPets and C_PetJournal.GetPetInfoByIndex) then + return guid + end + + local total = C_PetJournal.GetNumPets() + for i = 1, total or 0 do + local pid, sid, owned = C_PetJournal.GetPetInfoByIndex(i) + if owned and sid == data.speciesID then + return pid + end + end + return guid +end + +local function PetElementMatches(edata, petID, speciesID, petName) + if not edata then return false end + local pid = edata.petID or edata.petGuid or edata.petGUID or edata.guid + if petID and pid == petID then return true end + local sid = edata.speciesID or edata.speciesId + if speciesID and sid == speciesID then return true end + local name = edata.name or edata.speciesName or edata.displayName + return petName and name and slower(name) == slower(petName) +end + +local function PetFrameMatches(frame, petID, speciesID, petName) + if not frame then return false end + local edata = frame.GetElementData and frame:GetElementData() + if PetElementMatches(edata, petID, speciesID, petName) then return true end + if petID and (frame.petID == petID or frame.petGuid == petID or frame.petGUID == petID) then + return true + end + if speciesID and frame.speciesID == speciesID then return true end + local text = GetButtonText(frame) + return petName and text and slower(text) == slower(petName) +end + +local function GetPetJournalScrollBox() + local petJournal = GetPetJournalFrame() + if not petJournal then return nil end + local candidates = { + petJournal.ScrollBox, + petJournal.PetList and petJournal.PetList.ScrollBox, + petJournal.List and petJournal.List.ScrollBox, + petJournal.listScroll and petJournal.listScroll.ScrollBox, + _G["PetJournalListScrollFrame"] and _G["PetJournalListScrollFrame"].ScrollBox, + } + for i = 1, #candidates do + if candidates[i] then return candidates[i] end + end + return nil +end + +function UI:RevealPetInJournal(data) + if not data or not C_PetJournal then return nil end + local petName = data.petName or data.name + + if C_PetJournal.SetFilterChecked then + local collectedFilter = _G["LE_PET_JOURNAL_FILTER_COLLECTED"] + local uncollectedFilter = _G["LE_PET_JOURNAL_FILTER_NOT_COLLECTED"] + if collectedFilter ~= nil then + pcall(C_PetJournal.SetFilterChecked, collectedFilter, true) + end + if uncollectedFilter ~= nil then + pcall(C_PetJournal.SetFilterChecked, uncollectedFilter, false) + end + end + if C_PetJournal.SetAllPetSourcesChecked then pcall(C_PetJournal.SetAllPetSourcesChecked, true) end + if C_PetJournal.SetAllPetTypesChecked then pcall(C_PetJournal.SetAllPetTypesChecked, true) end + -- Do not search for the pet by name here. Guide/direct-open should + -- leave the journal in its normal list view, scroll to the pet row, + -- and highlight it. + if C_PetJournal.SetSearchFilter then + local currentSearch = "" + if C_PetJournal.GetSearchFilter then + local ok, value = pcall(C_PetJournal.GetSearchFilter) + if ok and value then currentSearch = value end + end + if currentSearch ~= "" then + pcall(C_PetJournal.SetSearchFilter, "") + end + end + + local petID = self:ResolvePetGUID(data) + if petID and petID ~= data.petID then data.petID = petID end + + local petJournal = GetPetJournalFrame() + local selectPet = _G["PetJournal_SelectPet"] + if selectPet and petID then + pcall(selectPet, petID) + if petJournal then pcall(selectPet, petJournal, petID) end + end + if C_PetJournal.SetSelectedPet and petID then + pcall(C_PetJournal.SetSelectedPet, petID) + end + + local scrollBox = GetPetJournalScrollBox() + if scrollBox then + Utils.ScrollBoxScrollTo(scrollBox, function(edata) + return PetElementMatches(edata, petID, data.speciesID, petName) + end) + local btn = Utils.ScrollBoxFindButton(scrollBox, function(frame) + return PetFrameMatches(frame, petID, data.speciesID, petName) + end) + if btn then return btn end + end + + if petJournal and petName then + return SearchFrameTreeFuzzy(petJournal, slower(petName)) + end + return nil +end + function UI:SelectResult(data, forceGuide) if not data then return end local useFast = not forceGuide @@ -10260,25 +10788,22 @@ function UI:SelectResult(data, forceGuide) return end - -- Pet: summon/dismiss. Stored petID can go stale (released, caged, - -- traded) -- look up a fresh owned GUID by speciesID first, fall - -- back to the cached petID only as a last resort. + -- Pet: normal click summons. Guide and Ctrl+click route to the pet + -- in Collections > Pet Journal instead; Ctrl uses DirectOpen so it + -- skips the step-by-step guide and only highlights the destination. if data.petID or data.speciesID then - if C_PetJournal then - local guid = data.petID - if data.speciesID and C_PetJournal.FindPetIDByName then - -- Walk the journal to find any owned pet of this species. - if C_PetJournal.GetNumPets and C_PetJournal.GetPetInfoByIndex then - local total = C_PetJournal.GetNumPets() - for i = 1, total or 0 do - local pid, sid, owned = C_PetJournal.GetPetInfoByIndex(i) - if owned and sid == data.speciesID then - guid = pid - break - end - end - end + if forceGuide or (IsControlKeyDown and IsControlKeyDown()) then + local guideData = BuildPetJournalGuideData(data) + if useFast then + self:DirectOpen(guideData) + else + EasyFind:StartGuide(guideData) end + return + end + + if C_PetJournal then + local guid = self:ResolvePetGUID(data) if guid and C_PetJournal.SummonPetByGUID then C_PetJournal.SummonPetByGUID(guid) if guid ~= data.petID then data.petID = guid end @@ -11163,19 +11688,66 @@ function UI:SummonPet(petID) end end -function UI:IsPetFavorite(petID) - if not petID or not C_PetJournal then return false end +local function NormalizePetFavorite(value) + return value == true or value == 1 +end + +local function ReadPetFavoriteFromJournal(petID) + if not petID or not C_PetJournal then return nil end if C_PetJournal.GetPetInfoByPetID then - local ok, _, _, _, _, _, _, _, _, _, _, _, _, _, isFav = pcall(C_PetJournal.GetPetInfoByPetID, petID) - if ok and isFav then return true end + local ok, _, _, _, _, _, _, isFavorite = pcall(C_PetJournal.GetPetInfoByPetID, petID) + if ok and isFavorite ~= nil then return NormalizePetFavorite(isFavorite) end end - return false + if C_PetJournal.GetNumPets and C_PetJournal.GetPetInfoByIndex then + local total = C_PetJournal.GetNumPets() + for i = 1, total or 0 do + local pid, _, _, _, _, isFavorite = C_PetJournal.GetPetInfoByIndex(i) + if pid == petID then return NormalizePetFavorite(isFavorite) end + end + end + return nil +end + +local function ReconcilePetFavoriteOverride(petID, expected, attemptsLeft) + if not petID or petFavoriteOverrides[petID] ~= expected then return end + local actual = ReadPetFavoriteFromJournal(petID) + if actual == expected then + petFavoriteOverrides[petID] = nil + if UI and UI.RefreshResults then UI:RefreshResults() end + return + end + if attemptsLeft and attemptsLeft > 0 and C_Timer and C_Timer.After then + C_Timer.After(0.15, function() + ReconcilePetFavoriteOverride(petID, expected, attemptsLeft - 1) + end) + end +end + +function UI:IsPetFavorite(petID) + if not petID then return false end + if petFavoriteOverrides[petID] ~= nil then + return petFavoriteOverrides[petID] + end + local favorite = ReadPetFavoriteFromJournal(petID) + return favorite == true end function UI:TogglePetFavorite(petID) if not petID or not C_PetJournal or not C_PetJournal.SetFavorite then return end local fav = self:IsPetFavorite(petID) - pcall(C_PetJournal.SetFavorite, petID, (not fav) and 1 or 0) + local newFav = not fav + local ok = pcall(C_PetJournal.SetFavorite, petID, newFav and 1 or 0) + if ok then + petFavoriteOverrides[petID] = newFav + end + if self.RefreshResults and C_Timer and C_Timer.After then + C_Timer.After(0, function() + if UI and UI.RefreshResults then UI:RefreshResults() end + end) + C_Timer.After(0.15, function() + ReconcilePetFavoriteOverride(petID, newFav, 8) + end) + end end -- Returns true when the pet is cage-eligible (tradeable). Blizzard's @@ -11216,7 +11788,8 @@ StaticPopupDialogs["EASYFIND_PET_RELEASE_CONFIRM"] = { function UI:RenamePet(petID) if not petID then return end - StaticPopup_Show("EASYFIND_PET_RENAME", nil, nil, petID) + local popup = StaticPopup_Show("EASYFIND_PET_RENAME", nil, nil, petID) + LiftPopupStrata(popup or FindPopupSlot("EASYFIND_PET_RENAME")) end StaticPopupDialogs["EASYFIND_PET_RENAME"] = { diff --git a/textures/alt-key.tga b/textures/alt-key.tga new file mode 100644 index 0000000000000000000000000000000000000000..459d2100aa3a60b9f4751efdde9df1d376ba3e1b GIT binary patch literal 65554 zcmeI2QI6yw3`C z_uuRFTDb5424DaNU;qYS00v+H24DaNU;qYS00v+H24DaNU;qYS00v+H24DaNU;qYS z00v+H24DaNU;qYS00us0K=ANm51;&hts4Ie2Il?WWXs=5^m;PKXfMmpvQH&?gO780 zkg`voFfe!i#sLh>!+_PWuL}j zVD9{l17l#-i|>MQJkU5V7YxjuzX1aSV=!>}@V~tP1J7sREzY7_7*hetnGa!#mH%~YW95Z0v%uq0}&w$Jj{{J3f;3osK zoc)A2`TxwHv;WhNMM0V6n(6Fw?tgPV%h@OU-`VG!edA;4aOsxSitP6@lnjS^d6ywA zYf3NuJKXv*uh+Th6%kSX0wTYe|NIJ^jm&cRHn~_UN3LH(rTO=-G#- z?`)jx&$F{^z3T0ur|)RIx9*;2CTDN+$1BkZIcv3?<7wO*x87Q68ewhOGj8)Nf{za>5SsnnGVaGHKc?K%hoGnq-5=u@4Rc4fcjeYUTf|3-Nb|YsNN>I zQT5fzsO385oma+4OAV!Kn{ElJt+b~*-&@TVw*UYD literal 0 HcmV?d00001 From 7e355ff501088017790f67484c30749b75810d51 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 10 May 2026 06:15:04 -0400 Subject: [PATCH 084/103] Fix bag item actions and highlights --- Database.lua | 59 +++++++++++++-- Highlight.lua | 199 ++++++++++++++++++++++++++++++++++++-------------- UI.lua | 196 +++++++++++++++++++++++++++++++++++++------------ 3 files changed, 346 insertions(+), 108 deletions(-) diff --git a/Database.lua b/Database.lua index ac12085..33a1815 100644 --- a/Database.lua +++ b/Database.lua @@ -2439,9 +2439,58 @@ function Database:PopulateDynamicBags() local getNumSlots = (CONT and CONT.GetContainerNumSlots) or GetContainerNumSlots local getItemInfo = (CONT and CONT.GetContainerItemInfo) or GetContainerItemInfo local getItemInfoInstant = (C_Item and C_Item.GetItemInfoInstant) or GetItemInfoInstant - local isEquippableItem = (C_Item and C_Item.IsEquippableItem) or _G.IsEquippableItem if not getNumSlots or not getItemInfo then return false end + local nonEquipLocs = { + INVTYPE_NON_EQUIP = true, + INVTYPE_NON_EQUIP_IGNORE = true, + INVTYPE_AMMO = true, + INVTYPE_QUIVER = true, + } + local equipLocs = { + INVTYPE_HEAD = true, + INVTYPE_NECK = true, + INVTYPE_SHOULDER = true, + INVTYPE_BODY = true, + INVTYPE_CHEST = true, + INVTYPE_ROBE = true, + INVTYPE_WAIST = true, + INVTYPE_LEGS = true, + INVTYPE_FEET = true, + INVTYPE_WRIST = true, + INVTYPE_HAND = true, + INVTYPE_FINGER = true, + INVTYPE_TRINKET = true, + INVTYPE_CLOAK = true, + INVTYPE_WEAPON = true, + INVTYPE_SHIELD = true, + INVTYPE_2HWEAPON = true, + INVTYPE_WEAPONMAINHAND = true, + INVTYPE_WEAPONOFFHAND = true, + INVTYPE_HOLDABLE = true, + INVTYPE_RANGED = true, + INVTYPE_RANGEDRIGHT = true, + INVTYPE_THROWN = true, + INVTYPE_RELIC = true, + INVTYPE_TABARD = true, + INVTYPE_BAG = true, + INVTYPE_PROFESSION_TOOL = true, + INVTYPE_PROFESSION_GEAR = true, + } + local function isRealEquipLoc(equipLoc) + return type(equipLoc) == "string" + and equipLocs[equipLoc] == true + and not nonEquipLocs[equipLoc] + end + local function getEquipLoc(itemID) + if not getItemInfoInstant then return nil end + local info, _, _, equipLoc = getItemInfoInstant(itemID) + if type(info) == "table" then + return info.itemEquipLoc or info.equipLoc or info.inventoryType + end + return equipLoc + end + RemoveEntriesByCategory("Bag") if self.ResetSearchCache then self:ResetSearchCache() end @@ -2478,12 +2527,8 @@ function Database:PopulateDynamicBags() for _, itemID in ipairs(order) do local info = itemMap[itemID] local name = info.link and info.link:match("%[(.-)%]") or (GetItemInfo and GetItemInfo(itemID)) or ("Item " .. itemID) - local equipLoc - if getItemInfoInstant then - local _, _, _, itemEquipLoc = getItemInfoInstant(itemID) - equipLoc = itemEquipLoc - end - local isEquippable = (equipLoc and equipLoc ~= "") or (isEquippableItem and isEquippableItem(itemID)) or false + local equipLoc = getEquipLoc(itemID) + local isEquippable = isRealEquipLoc(equipLoc) local first = info.locations[1] local bagBtn = first.bag == 0 and "MainMenuBarBackpackButton" or ("CharacterBag" .. (first.bag - 1) .. "Slot") diff --git a/Highlight.lua b/Highlight.lua index 12fdc74..3ae4047 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -48,6 +48,142 @@ local function canHoverDismiss() return not highlightShownAt or (GetTime() - highlightShownAt) >= HOVER_MIN_DISPLAY end +local function GetContainerButtonLocation(button) + if not button then return nil end + + local bag, slot + if button.GetBagID then + local ok, value = pcall(button.GetBagID, button) + if ok then bag = value end + end + if button.GetID then + local ok, value = pcall(button.GetID, button) + if ok then slot = value end + end + if button.GetItemLocation then + local ok, itemLocation = pcall(button.GetItemLocation, button) + if ok and itemLocation and itemLocation.GetBagAndSlot then + local okLoc, locBag, locSlot = pcall(itemLocation.GetBagAndSlot, itemLocation) + if okLoc then + bag = bag or locBag + slot = slot or locSlot + end + end + end + if bag == nil and button.GetParent then + local parent = button:GetParent() + if parent and parent.GetID then + local ok, value = pcall(parent.GetID, parent) + if ok then bag = value end + end + end + + return bag, slot +end + +local function IsContainerButtonLocation(button, bag, slot) + local buttonBag, buttonSlot = GetContainerButtonLocation(button) + return buttonBag == bag and buttonSlot == slot +end + +function Highlight:FindContainerSlotButtonInFrame(frame, bag, slot) + if not frame or not frame:IsVisible() then return nil end + + if frame.EnumerateValidItems then + for _, itemButton in frame:EnumerateValidItems() do + if itemButton and itemButton:IsVisible() + and IsContainerButtonLocation(itemButton, bag, slot) then + return itemButton + end + end + end + + local frameName = frame.GetName and frame:GetName() + if frameName then + for i = 1, 200 do + local itemButton = _G[frameName .. "Item" .. i] + if itemButton and itemButton:IsVisible() + and IsContainerButtonLocation(itemButton, bag, slot) then + return itemButton + end + end + end + + return nil +end + +function Highlight:FindContainerSlotButton(bag, slot) + local button = self:FindContainerSlotButtonInFrame(_G.ContainerFrameCombinedBags, bag, slot) + if button then return button end + + for i = 1, 13 do + button = self:FindContainerSlotButtonInFrame(_G["ContainerFrame" .. i], bag, slot) + if button then return button end + end + + -- Classic fallback: old container buttons are indexed in reverse slot order. + local CONT = C_Container + local getSlots = (CONT and CONT.GetContainerNumSlots) or GetContainerNumSlots + for i = 1, 13 do + local frame = _G["ContainerFrame" .. i] + if frame and frame:IsVisible() and frame:GetID() == bag then + local ok, n = pcall(function() return getSlots and getSlots(bag) end) + local numSlots = (ok and n) or 0 + if numSlots == 0 then + for c = 1, 40 do + if not _G["ContainerFrame" .. i .. "Item" .. c] then + numSlots = c - 1 + break + end + end + end + local btnIdx = numSlots - slot + 1 + return btnIdx >= 1 and _G["ContainerFrame" .. i .. "Item" .. btnIdx] or nil + end + end + + return nil +end + +function Highlight:HighlightContainerSlotStep(step) + local locations = step.allLocations or {{ bag = step.containerBag, slot = step.containerSlot }} + + local found = false + for _, loc in ipairs(locations) do + local itemBtn = self:FindContainerSlotButton(loc.bag, loc.slot) + if itemBtn and itemBtn:IsVisible() then + if not found then + local bag, slot = loc.bag, loc.slot + step._efContainerSlotFound = true + self:HighlightFrame(itemBtn, nil, function(frame) + return frame and frame:IsVisible() + and IsContainerButtonLocation(frame, bag, slot) + end) + if canHoverDismiss() and itemBtn:IsMouseOver() then + self:Cancel() + return + end + found = true + else + local name = itemBtn:GetName() + local glow = name and _G[name .. "SearchOverlay"] + if glow then + glow:SetVertexColor(1, 1, 0, 0.5) + glow:Show() + end + end + end + end + + if not found then + if step._efContainerSlotFound then + self:Cancel() + return + end + self:HideHighlight() + end +end + function Highlight:Initialize() if highlightFrame then return end self:CreateHighlightFrame() @@ -443,6 +579,15 @@ function Highlight:UpdateGuide() return end + -- Container item highlight (standalone step, no waitForFrame): + -- highlights the first visible slot and glows duplicates through + -- the bag search overlay. Opening bags is one-shot in SelectResult; + -- this ticker must not reopen bags after the user closes them. + if step.containerBag ~= nil and step.containerSlot then + self:HighlightContainerSlotStep(step) + return + end + -- Step 2+ type: Wait for frame, then highlight tab or region if step.waitForFrame then local frame = self:GetFrameByPath(step.waitForFrame) @@ -1287,60 +1432,6 @@ function Highlight:UpdateGuide() end end - -- Bag slot highlight: open all bags containing the item, glow - -- the first matching slot, and accent secondaries with the bag - -- search overlay so duplicates across bags are still visible. - if step.containerBag ~= nil and step.containerSlot then - local locations = step.allLocations or {{ bag = step.containerBag, slot = step.containerSlot }} - local CONT = C_Container - local openBag = (CONT and CONT.OpenBag) or OpenBag - local getSlots = (CONT and CONT.GetContainerNumSlots) or GetContainerNumSlots - - if openBag then - local seen = {} - for _, loc in ipairs(locations) do - if not seen[loc.bag] then - seen[loc.bag] = true - pcall(openBag, loc.bag) - end - end - end - - local found = false - for _, loc in ipairs(locations) do - for i = 1, 13 do - local cf = _G["ContainerFrame" .. i] - if cf and cf:IsShown() and cf:GetID() == loc.bag then - local ok, n = pcall(function() return getSlots and getSlots(loc.bag) end) - local numSlots = (ok and n) or 0 - if numSlots == 0 then - for c = 1, 40 do - if not _G["ContainerFrame" .. i .. "Item" .. c] then - numSlots = c - 1; break - end - end - end - local btnIdx = numSlots - loc.slot + 1 - local itemBtn = btnIdx >= 1 and _G["ContainerFrame" .. i .. "Item" .. btnIdx] or nil - if itemBtn and itemBtn:IsShown() then - if not found then - self:HighlightFrame(itemBtn) - found = true - else - local glow = _G[itemBtn:GetName() .. "SearchOverlay"] - if glow then - glow:SetVertexColor(1, 1, 0, 0.5) - glow:Show() - end - end - end - break - end - end - end - return - 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 diff --git a/UI.lua b/UI.lua index d46b8f8..d15183a 100644 --- a/UI.lua +++ b/UI.lua @@ -473,6 +473,128 @@ local function AnchorGearTooltip(tooltip, ownerFrame) tooltip:SetOwner(ownerFrame, "ANCHOR_RIGHT") end +function UI:OpenContainerBag(bag) + if bag == nil then return false end + if bag == 0 and OpenBackpack then + local ok = pcall(OpenBackpack) + if ok then return true end + end + local openBag = OpenBag or (C_Container and C_Container.OpenBag) + if openBag then + return pcall(openBag, bag) + end + return false +end + +function UI:OpenContainerBagLocations(locations, fallbackBag) + local opened = false + if locations then + local seen = {} + for _, loc in ipairs(locations) do + if loc.bag ~= nil and not seen[loc.bag] then + seen[loc.bag] = true + opened = self:OpenContainerBag(loc.bag) or opened + end + end + elseif fallbackBag ~= nil then + opened = self:OpenContainerBag(fallbackBag) or opened + end + return opened +end + +function UI:OpenBagItemLocation(data) + if not data then return end + self:OpenContainerBagLocations(data.bagLocations, data.bagID) + if data.steps and #data.steps >= 2 and ns.Highlight and ns.Highlight.StartGuideAtStep then + data.steps[2]._efContainerSlotFound = nil + ns.Highlight:StartGuideAtStep(data, 2) + end +end + +UI.NON_EQUIP_LOCS = { + INVTYPE_NON_EQUIP = true, + INVTYPE_NON_EQUIP_IGNORE = true, + INVTYPE_AMMO = true, + INVTYPE_QUIVER = true, +} + +UI.EQUIP_LOCS = { + INVTYPE_HEAD = true, + INVTYPE_NECK = true, + INVTYPE_SHOULDER = true, + INVTYPE_BODY = true, + INVTYPE_CHEST = true, + INVTYPE_ROBE = true, + INVTYPE_WAIST = true, + INVTYPE_LEGS = true, + INVTYPE_FEET = true, + INVTYPE_WRIST = true, + INVTYPE_HAND = true, + INVTYPE_FINGER = true, + INVTYPE_TRINKET = true, + INVTYPE_CLOAK = true, + INVTYPE_WEAPON = true, + INVTYPE_SHIELD = true, + INVTYPE_2HWEAPON = true, + INVTYPE_WEAPONMAINHAND = true, + INVTYPE_WEAPONOFFHAND = true, + INVTYPE_HOLDABLE = true, + INVTYPE_RANGED = true, + INVTYPE_RANGEDRIGHT = true, + INVTYPE_THROWN = true, + INVTYPE_RELIC = true, + INVTYPE_TABARD = true, + INVTYPE_BAG = true, + INVTYPE_PROFESSION_TOOL = true, + INVTYPE_PROFESSION_GEAR = true, +} + +function UI:IsRealEquipLoc(slot) + return type(slot) == "string" + and self.EQUIP_LOCS[slot] == true + and not self.NON_EQUIP_LOCS[slot] +end + +function UI:GetItemEquipLoc(itemID) + local getItemInfoInstant = (C_Item and C_Item.GetItemInfoInstant) or GetItemInfoInstant + if not getItemInfoInstant then return nil end + + local info, _, _, equipLoc = getItemInfoInstant(itemID) + if type(info) == "table" then + return info.itemEquipLoc or info.equipLoc or info.inventoryType + end + return equipLoc +end + +function UI:GetBagItemActionKind(data) + if not data or not data.itemID or data.category ~= "Bag" then return nil end + + -- Only treat items with a real gear slot as "equippable". Empty / + -- NON_EQUIP / AMMO / QUIVER are not gear slots. + local slot = data.equipLoc + if not self:IsRealEquipLoc(slot) then + slot = self:GetItemEquipLoc(data.itemID) + end + if self:IsRealEquipLoc(slot) then + return "equip" + end + + local hasUseEffect = (C_Item and C_Item.GetItemSpell and C_Item.GetItemSpell(data.itemID)) + or (GetItemSpell and GetItemSpell(data.itemID)) + if hasUseEffect then return "use" end + + if GetItemInfo then + local itemType = select(6, GetItemInfo(data.itemID)) + if itemType == "Consumable" then + return "use" + elseif itemType == "Container" or itemType == "Quest" then + return "open" + end + end + + return "show" +end + local function GetActionHint(data) if not data then return nil end if data.titleID then return "Select to apply as your title" end @@ -490,25 +612,15 @@ local function GetActionHint(data) end if data.macroIndex then return "Select to run macro | Ctrl+click to edit" end if data.itemID and data.category == "Bag" then - -- Only treat items with a real gear slot as "equippable". Some - -- consumables flunk IsEquippableItem at the database level, so - -- key off equipLoc directly: empty / NON_EQUIP / AMMO / QUIVER - -- aren't gear slots and should still read "Select to use". - local slot = data.equipLoc - if slot and slot ~= "" and slot ~= "INVTYPE_NON_EQUIP" - and slot ~= "INVTYPE_AMMO" and slot ~= "INVTYPE_QUIVER" then - return "Select to equip item" - end - -- Containers / quest items / wrapped boxes: the right-click - -- action opens the contents, not "use". GetItemInfo returns the - -- item type as the 6th value. - if GetItemInfo then - local itemType = select(6, GetItemInfo(data.itemID)) - if itemType == "Container" or itemType == "Quest" then - return "Select to open item" - end - end - return "Select to use item" + local actionKind = UI:GetBagItemActionKind(data) + if actionKind == "equip" then + return "Select to equip item | Ctrl+click to show in bags" + elseif actionKind == "open" then + return "Select to open item | Ctrl+click to show in bags" + elseif actionKind == "use" then + return "Select to use item | Ctrl+click to show in bags" + end + return "Select to show in bags" end if data.mapSearchResult then if data.isZone then return "Select to open map to location" end @@ -789,7 +901,8 @@ local function IsSecureActionResult(data) return data and (data.outfitID or data.toyItemID or (data.spellID and not IsSpellbookOnlyAbility(data)) or data.mountID or data.macroIndex or data.slashCommand - or (data.itemID and data.category == "Bag")) + or (data.itemID and data.category == "Bag" + and UI:GetBagItemActionKind(data) ~= "show")) end local function GetResultShortcutIndex(key) @@ -5371,7 +5484,7 @@ function UI:CreateResultButton(index) resultRow.shortcutAltIcon = shortcutAltIcon local shortcutNumberText = shortcutGroup:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") - shortcutNumberText:SetPoint("RIGHT", shortcutGroup, "RIGHT", 1, 0) + shortcutNumberText:SetPoint("RIGHT", shortcutGroup, "RIGHT", 3, 0) shortcutNumberText:SetWidth(7) shortcutNumberText:SetJustifyH("RIGHT") shortcutNumberText:SetTextColor(0.58, 0.58, 0.58, 0.85) @@ -6151,6 +6264,16 @@ function UI:CreateResultButton(index) return end + -- Ctrl + bag item: suppress secure /use or equip so PostClick can + -- route to the containing bag slot instead. + if d and d.itemID and d.category == "Bag" and IsControlKeyDown() then + self:SetAttribute("type", nil) + self._lastAttrType = nil + self._lastAttrKey = nil + self._lastAttrVal = nil + return + end + -- Outfit equip: place onto a temp action slot, then the secure -- UseAction dispatch fires on the action attribute. local outfitID = d and d.outfitID @@ -7440,7 +7563,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- and highlights the node. Skip the secure cast type so -- only PostClick / SelectResult handle the talent path. newType, newKey, newVal = "spell", "spell", data.spellName or data.spellID - elseif data and data.itemID and data.category == "Bag" then + elseif data and data.itemID and data.category == "Bag" + and UI:GetBagItemActionKind(data) ~= "show" then newType, newKey, newVal = "item", "item", data.name elseif data and data.macroIndex and data.category == "Macro" and data.macroBody and data.macroBody ~= "" then @@ -10825,6 +10949,7 @@ function UI:SelectResult(data, forceGuide) -- open the bag(s) containing them and highlight the slot. if data.itemID and data.category == "Bag" then if useFast then + local showInBags = IsControlKeyDown and IsControlKeyDown() -- Skip bag-open for anything the secure click will already act -- on: explicit Use spells, equippable gear, AND broad item -- types that "use" via right-click without a Use:tooltip line @@ -10832,33 +10957,10 @@ function UI:SelectResult(data, forceGuide) -- right-click-openable containers like lockboxes still hit -- the bag-open path, so the bag visibly pops AND the -- container opens -- the user only wants the latter. - local hasUseEffect = (C_Item and C_Item.GetItemSpell and C_Item.GetItemSpell(data.itemID)) - or (GetItemSpell and GetItemSpell(data.itemID)) - local isEquippable = IsEquippableItem and IsEquippableItem(data.itemID) - local itemType - if not hasUseEffect and not isEquippable and GetItemInfo then - itemType = select(6, GetItemInfo(data.itemID)) - end - if hasUseEffect or isEquippable - or itemType == "Consumable" or itemType == "Container" - or itemType == "Quest" then + if not showInBags and self:GetBagItemActionKind(data) ~= "show" then return end - local openBag = (C_Container and C_Container.OpenBag) or OpenBag - if openBag and data.bagLocations then - local seen = {} - for _, loc in ipairs(data.bagLocations) do - if not seen[loc.bag] then - seen[loc.bag] = true - pcall(openBag, loc.bag) - end - end - elseif openBag then - pcall(openBag, data.bagID) - end - if data.steps and #data.steps >= 2 and ns.Highlight and ns.Highlight.StartGuideAtStep then - ns.Highlight:StartGuideAtStep(data, 2) - end + self:OpenBagItemLocation(data) return end if not data.steps or #data.steps == 0 then return end From 120a20c4188d31bed2245fbb76042f6c7281ac9b Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 10 May 2026 06:18:27 -0400 Subject: [PATCH 085/103] Clear spellbook ability glow on hover --- Highlight.lua | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/Highlight.lua b/Highlight.lua index 3ae4047..2326343 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -237,19 +237,25 @@ function Highlight:CreateHighlightFrame() -- ScrollBox button repurposed for a different spell after the user -- pages the spellbook. Caller passes a closure that re-checks -- identity (SpellFrameMatchesSelf etc.). - -- Repurpose watcher only -- NO hover-dismiss here, NO visibility - -- check. The per-step UpdateGuide loop already advances/rewinds - -- when the destination panel changes; a blanket visibility watcher + -- Repurpose watcher only -- no blanket visibility check. The + -- per-step UpdateGuide loop already advances/rewinds when the + -- destination panel changes; a blanket visibility watcher -- false-positives on ScrollBox-virtualized targets (achievement -- categories, currency rows, etc.) which briefly report not-shown -- during their own re-render and would kill intermediate highlights. - -- The validator path stays for explicit identity checks the caller - -- opted into (e.g. spellbook page-flip). + -- Custom glow/spyglass targets can opt into hover-dismiss through + -- _hoverDismissFrame because they bypass the normal guide loop. local watchAccum = 0 highlightFrame:HookScript("OnUpdate", function(self, elapsed) watchAccum = watchAccum + elapsed if watchAccum < 0.1 then return end watchAccum = 0 + local hoverFrame = self._hoverDismissFrame + if hoverFrame and canHoverDismiss() + and hoverFrame.IsMouseOver and hoverFrame:IsMouseOver() then + Highlight:HideHighlight() + return + end local target = self._targetFrame local validator = self._targetValidator if validator and not validator(target) then @@ -2775,6 +2781,7 @@ function Highlight:RegisterSearchIconWatch(button, validator) highlightFrame._talentSearchBtn = button highlightFrame._targetFrame = button highlightFrame._targetValidator = validator + highlightFrame._hoverDismissFrame = button -- Hide the yellow border textures so the watcher's OnUpdate ticks -- without the standard highlight visuals showing on top of the -- native spyglass. @@ -2782,6 +2789,9 @@ function Highlight:RegisterSearchIconWatch(button, validator) if highlightFrame.bottom then highlightFrame.bottom:Hide() end if highlightFrame.left then highlightFrame.left:Hide() end if highlightFrame.right then highlightFrame.right:Hide() end + if not highlightFrame:IsShown() then + highlightShownAt = GetTime() + end highlightFrame:Show() if highlightFrame.animGroup then highlightFrame.animGroup:Stop() end end @@ -2839,10 +2849,14 @@ function Highlight:HighlightSpellbookSpell(row, validator) highlightFrame._spellbookGlowBtn = iconBtn highlightFrame._targetFrame = row highlightFrame._targetValidator = validator + highlightFrame._hoverDismissFrame = row if highlightFrame.top then highlightFrame.top:Hide() end if highlightFrame.bottom then highlightFrame.bottom:Hide() end if highlightFrame.left then highlightFrame.left:Hide() end if highlightFrame.right then highlightFrame.right:Hide() end + if not highlightFrame:IsShown() then + highlightShownAt = GetTime() + end highlightFrame:Show() if highlightFrame.animGroup then highlightFrame.animGroup:Stop() end end @@ -2864,6 +2878,7 @@ function Highlight:HideHighlight() highlightFrame._spellbookGlowBtn = nil highlightFrame._targetFrame = nil highlightFrame._targetValidator = nil + highlightFrame._hoverDismissFrame = nil highlightFrame:Hide() if highlightFrame.animGroup then highlightFrame.animGroup:Stop() end -- Restore yellow border textures hidden by HighlightTalentSearch. From 49d7b299b21091549b00a8bd07cbc9281b6c528b Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 10 May 2026 19:39:38 -0400 Subject: [PATCH 086/103] Revamp EasyFind options and calculator copy --- Aliases.lua | 5 + BlizzOptionsSearch.lua | 12 +- Core.lua | 56 +- EasyFind.toc | 2 +- Highlight.lua | 43 +- MapTab.lua | 28 +- Options.lua | 1847 ++++++++++++++++++++++------------------ README.md | 5 +- Rescaler.lua | 672 ++++++++------- UI.lua | 1503 ++++++++++++++++++++++++++++++-- Wizard.lua | 15 +- 11 files changed, 2931 insertions(+), 1257 deletions(-) diff --git a/Aliases.lua b/Aliases.lua index d731846..30e4c60 100644 --- a/Aliases.lua +++ b/Aliases.lua @@ -119,6 +119,11 @@ function Aliases:Remove(aliasText) EasyFind.db.aliases[slower(strtrim(aliasText or ""))] = nil end +function Aliases:ClearAll() + if not EasyFind or not EasyFind.db then return end + EasyFind.db.aliases = {} +end + -- Walk all stored aliases. cb(aliasText, info) for each entry. function Aliases:ForEach(cb) if not EasyFind or not EasyFind.db or not EasyFind.db.aliases then return end diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index 88a710c..32a97dc 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -1175,7 +1175,13 @@ local function HighlightFoundElement(scrollBox, elementData) if not ns.Highlight or not ns.Highlight.HighlightFrame then return end SafeAfter(0.05, function() local frame = scrollBox.FindFrame and scrollBox:FindFrame(elementData) - if frame then ns.Highlight:HighlightFrame(frame) end + if frame then + ns.Highlight:HighlightFrame(frame, nil, function(target) + if not target or not target:IsVisible() then return false end + if not scrollBox.FindFrame then return true end + return scrollBox:FindFrame(elementData) == target + end) + end end) end @@ -1305,7 +1311,9 @@ local function ScrollToBindingAction(action, headerName) local bindingFrame = FindBindingFrameInSection(sf, bindingIdx) if bindingFrame and bindingFrame:IsShown() and ns.Highlight and ns.Highlight.HighlightFrame then - ns.Highlight:HighlightFrame(bindingFrame) + ns.Highlight:HighlightFrame(bindingFrame, nil, function(target) + return target and target:IsVisible() + end) end end) end diff --git a/Core.lua b/Core.lua index c1379de..df970f2 100644 --- a/Core.lua +++ b/Core.lua @@ -23,13 +23,12 @@ EasyFind.db = {} -- SavedVariables version. Increment when changing DB schema. -- Each migration runs once: if saved dbVersion < DB_VERSION, run all steps in order. -local DB_VERSION = 8 +local DB_VERSION = 11 -- SavedVariables defaults - new keys are auto-merged for existing users local DB_DEFAULTS = { dbVersion = DB_VERSION, visible = true, - enableUISearch = true, enableMapSearch = true, iconScale = 0.8, nativePinScale = 1.5, -- Multiplier applied to a Blizzard map pin while EasyFind is glowing it @@ -37,6 +36,7 @@ local DB_DEFAULTS = { uiSearchWidth = 1.54, -- 0.88 * 1.75: results dropdown matches bar width now uiResultsScale = 1.0, uiResultsWidth = 350, + uiSearchBarHeight = ns.SEARCHBAR_HEIGHT or 30, searchBarOpacity = 0.75, -- ns.DEFAULT_OPACITY fontSize = 0.9, -- UI search font size multiplier (0.5-2.0) uiSearchPosition = nil, -- {point, relPoint, x, y} @@ -65,7 +65,6 @@ local DB_DEFAULTS = { autoTrackPins = true, -- Auto super-track newly placed map pins uiResultsAbove = false, -- Show UI search results above the search bar showResultShortcutHints = true, -- Show Alt+number hints on UI search results - panelOpacity = 0.9, -- Options panel background opacity showMinimapButton = true, -- Show toggle button on minimap minimapButtonAngle = 200, -- Position angle (degrees) around minimap edge globalSearchFilters = { -- Global search category filters (all enabled by default) @@ -206,6 +205,22 @@ local DB_MIGRATIONS = { db.resultsTheme = "Modern" end end, + -- [9] = Remove options menu opacity setting. The redesigned options + -- panel uses a fixed alpha to keep the Raycast-style shell consistent. + [9] = function(db) + db.panelOpacity = nil + end, + -- [10] = Persist search bar height separately from font size so + -- window resizing can scale the bar strip without changing text size. + [10] = function(db) + if not db.uiSearchBarHeight then db.uiSearchBarHeight = ns.SEARCHBAR_HEIGHT or 30 end + end, + -- [11] = 2.0.0 is a full onboarding reset. Existing users should + -- see the tutorial once instead of the legacy What's New popup. + [11] = function(db) + db.tutorialDone = false + db.lastSeenVersion = "2.0.0" + end, } -- Fields that are runtime-only and must not persist in SavedVariables @@ -405,11 +420,15 @@ local function OnInitialize() end end elseif msg == "whatsnew" then - if ns.UI then ns.UI:ShowWhatsNew(ns.version) end + if ns.version == "2.0.0" and ns.Wizard and ns.Wizard.Show then + EasyFind.db.tutorialDone = false + ns.Wizard:Show() + elseif ns.UI then + ns.UI:ShowWhatsNew(ns.version) + end elseif msg == "help" or msg == "h" or msg == "?" then EasyFind:Print("Commands:") EasyFind:Print(" /ef: open options panel") - EasyFind:Print(" /ef toggle: show/hide search bar") EasyFind:Print(" /ef clear: dismiss highlights, pins, breadcrumbs") EasyFind:Print(" /ef reset: reset all settings") EasyFind:Print(" /ef bug: report a bug") @@ -472,7 +491,6 @@ end -- providers that are loaded-and-clean, so re-entry only refreshes dirty ones. function EasyFind:EnsureDynamicLoaded() if not ns.Database then return end - if EasyFind.db.enableUISearch == false then return end if ns.Database.LoadDeferredSyncProvidersStaggered then ns.Database:LoadDeferredSyncProvidersStaggered() end @@ -480,14 +498,12 @@ function EasyFind:EnsureDynamicLoaded() self._dynamicLoadTriggered = true if ns.Database.LoadHeavyDynamicSearchDataSync then SafeAfter(0.5, function() - if EasyFind.db.enableUISearch == false then return end ns.Database:LoadHeavyDynamicSearchDataSync() end) end -- Late-arriving APIs (Wardrobe, Heirlooms) sometimes aren't ready in -- the first pass. Re-trigger after they've had time to populate. SafeAfter(3.0, function() - if EasyFind.db.enableUISearch == false then return end if ns.Database.LoadDeferredSyncProvidersStaggered then ns.Database:LoadDeferredSyncProvidersStaggered() end @@ -499,15 +515,13 @@ local function OnPlayerLogin() -- Kick off the time-sliced statistics enumeration immediately -- (2ms-per-tick budget, no load-screen block). Run it through the -- provider manager so later heavy-load requests don't restart it. - if ns.Database and ns.Database.EnsureDynamicProviderLoaded - and EasyFind.db.enableUISearch ~= false then + if ns.Database and ns.Database.EnsureDynamicProviderLoaded then ns.Database:EnsureDynamicProviderLoaded("statistics", function(changed) if changed and ns.Database.MarkDynamicProviderLoaded then ns.Database:MarkDynamicProviderLoaded("statistics") end end) - elseif ns.Database and ns.Database.PopulateDynamicStatisticsAsync - and EasyFind.db.enableUISearch ~= false then + elseif ns.Database and ns.Database.PopulateDynamicStatisticsAsync then ns.Database:PopulateDynamicStatisticsAsync(function(changed) if changed and ns.Database.MarkDynamicProviderLoaded then ns.Database:MarkDynamicProviderLoaded("statistics") @@ -525,10 +539,8 @@ local function OnPlayerLogin() if EasyFind.db.enableMapSearch ~= false then SafeInit(ns.MapSearch, "MapSearch") end - if EasyFind.db.enableUISearch ~= false then - SafeInit(ns.UI, "UI") - SafeInit(ns.Highlight, "Highlight") - end + SafeInit(ns.UI, "UI") + SafeInit(ns.Highlight, "Highlight") if EasyFind.db.enableMapSearch ~= false and ns.MapSearch and ns.MapSearch.WarmUISearchCaches then ns.MapSearch:WarmUISearchCaches() end @@ -557,7 +569,6 @@ local function OnPlayerLogin() -- on the first search. Do not route this through the generic heavy -- chain; boss results should not sit behind Statistics/Loot. SafeAfter(1.0, function() - if EasyFind.db.enableUISearch == false then return end if ns.Database and ns.Database.EnsureDynamicProviderLoaded then ns.Database:EnsureDynamicProviderLoaded("bosses", function() end) end @@ -580,12 +591,14 @@ local function OnPlayerLogin() end end) - -- What's New popup: show once per version for returning users + -- What's New popup: show once per version for returning users. + -- 2.0.0 uses the tutorial instead because the whole experience was rebuilt. local currentVersion = ns.version local lastSeen = EasyFind.db.lastSeenVersion if currentVersion and currentVersion ~= lastSeen then - -- Skip for brand-new installs (they get the first-time setup instead) - if lastSeen ~= nil or EasyFind.db.setupComplete then + if currentVersion == "2.0.0" then + EasyFind.db.tutorialDone = false + elseif lastSeen ~= nil or EasyFind.db.setupComplete then SafeAfter(1.5, function() if ns.UI then ns.UI:ShowWhatsNew(currentVersion) end end) @@ -682,7 +695,7 @@ end function EasyFind:ToggleFocusSearchUI() self:EnsureDynamicLoaded() - if WorldMapFrame and WorldMapFrame:IsShown() and ns.MapTab then + if EasyFind.db.enableMapSearch ~= false and WorldMapFrame and WorldMapFrame:IsShown() and ns.MapTab then ns.MapTab:Focus() elseif ns.UI then ns.UI:ToggleFocus() @@ -690,6 +703,7 @@ function EasyFind:ToggleFocusSearchUI() end function EasyFind:FocusMapSearch() + if EasyFind.db.enableMapSearch == false then return end self:EnsureDynamicLoaded() if ns.MapTab then ns.MapTab:Focus() end end diff --git a/EasyFind.toc b/EasyFind.toc index c9d881a..ec80646 100644 --- a/EasyFind.toc +++ b/EasyFind.toc @@ -2,7 +2,7 @@ ## Title: EasyFind ## Notes: Search and locate UI elements and map locations with ease ## Author: justawower -## Version: 1.5.0 +## Version: 2.0.0 ## IconTexture: Interface\AddOns\EasyFind\Textures\SpyglassMinimap ## Category: UI ## AddonCompartmentFunc: EasyFind_OnAddonCompartmentClick diff --git a/Highlight.lua b/Highlight.lua index 2326343..890bbbc 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -48,6 +48,17 @@ local function canHoverDismiss() return not highlightShownAt or (GetTime() - highlightShownAt) >= HOVER_MIN_DISPLAY end +local function isTerminalHighlight() + if not currentGuide or not currentGuide.steps then return true end + return currentStepIndex and currentStepIndex >= #currentGuide.steps +end + +local function clearTerminalHighlight() + -- Use Cancel, not HideHighlight, so a guide ticker cannot recreate + -- the final highlight after hover, scroll virtualization, or panel close. + Highlight:Cancel() +end + local function GetContainerButtonLocation(button) if not button then return nil end @@ -237,14 +248,11 @@ function Highlight:CreateHighlightFrame() -- ScrollBox button repurposed for a different spell after the user -- pages the spellbook. Caller passes a closure that re-checks -- identity (SpellFrameMatchesSelf etc.). - -- Repurpose watcher only -- no blanket visibility check. The - -- per-step UpdateGuide loop already advances/rewinds when the - -- destination panel changes; a blanket visibility watcher - -- false-positives on ScrollBox-virtualized targets (achievement - -- categories, currency rows, etc.) which briefly report not-shown - -- during their own re-render and would kill intermediate highlights. - -- Custom glow/spyglass targets can opt into hover-dismiss through - -- _hoverDismissFrame because they bypass the normal guide loop. + -- Visibility clears only run for terminal/no-guide highlights. The + -- per-step UpdateGuide loop still owns intermediate breadcrumb + -- behavior, avoiding false clears while ScrollBox categories rebuild. + -- Custom glow/spyglass targets opt into hover-dismiss through + -- _hoverDismissFrame because they bypass the regular border path. local watchAccum = 0 highlightFrame:HookScript("OnUpdate", function(self, elapsed) watchAccum = watchAccum + elapsed @@ -253,13 +261,23 @@ function Highlight:CreateHighlightFrame() local hoverFrame = self._hoverDismissFrame if hoverFrame and canHoverDismiss() and hoverFrame.IsMouseOver and hoverFrame:IsMouseOver() then - Highlight:HideHighlight() + clearTerminalHighlight() return end local target = self._targetFrame + local terminal = isTerminalHighlight() + if terminal and self._clearWhenTargetHidden and target + and target.IsVisible and not target:IsVisible() then + clearTerminalHighlight() + return + end local validator = self._targetValidator if validator and not validator(target) then - Highlight:HideHighlight() + if terminal then + clearTerminalHighlight() + else + Highlight:HideHighlight() + end return end end) @@ -2690,6 +2708,8 @@ function Highlight:HighlightFrame(frame, instructionText, validator) -- (a ScrollBox button repurposed for a different spell after paging). highlightFrame._targetFrame = frame highlightFrame._targetValidator = validator + highlightFrame._hoverDismissFrame = frame + highlightFrame._clearWhenTargetHidden = true local bs = highlightFrame.borderSize local pad = 4 @@ -2782,6 +2802,7 @@ function Highlight:RegisterSearchIconWatch(button, validator) highlightFrame._targetFrame = button highlightFrame._targetValidator = validator highlightFrame._hoverDismissFrame = button + highlightFrame._clearWhenTargetHidden = true -- Hide the yellow border textures so the watcher's OnUpdate ticks -- without the standard highlight visuals showing on top of the -- native spyglass. @@ -2850,6 +2871,7 @@ function Highlight:HighlightSpellbookSpell(row, validator) highlightFrame._targetFrame = row highlightFrame._targetValidator = validator highlightFrame._hoverDismissFrame = row + highlightFrame._clearWhenTargetHidden = true if highlightFrame.top then highlightFrame.top:Hide() end if highlightFrame.bottom then highlightFrame.bottom:Hide() end if highlightFrame.left then highlightFrame.left:Hide() end @@ -2879,6 +2901,7 @@ function Highlight:HideHighlight() highlightFrame._targetFrame = nil highlightFrame._targetValidator = nil highlightFrame._hoverDismissFrame = nil + highlightFrame._clearWhenTargetHidden = nil highlightFrame:Hide() if highlightFrame.animGroup then highlightFrame.animGroup:Stop() end -- Restore yellow border textures hidden by HighlightTalentSearch. diff --git a/MapTab.lua b/MapTab.lua index d000533..a34180a 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -112,6 +112,12 @@ local RefreshCurrentSearch local ReleaseMapTabMemory local panel local selectedIsOurs = false +local function IsMapSearchEnabled() + return not (EasyFind and EasyFind.db and EasyFind.db.enableMapSearch == false) +end +function MapTab:IsEnabled() + return IsMapSearchEnabled() +end -- The Blizzard QuestMapFrame display mode that was active when our panel -- took over. SetDisplayMode(nil) clears qmf.displayMode, so when the map -- closes + reopens, Blizzard has no mode to restore and the sidebar @@ -237,6 +243,7 @@ end -- OnMouseUp Show()'s the right panel; we just hide ours. -- --------------------------------------------------------------------------- local function ShowOurPanel() + if not IsMapSearchEnabled() then return end local qmf = _G["QuestMapFrame"] if not qmf or not panel then return end selectedIsOurs = true @@ -2475,6 +2482,7 @@ end -- Focus entry for keybind (/ef map search focus) -- --------------------------------------------------------------------------- function MapTab:Focus() + if not IsMapSearchEnabled() then return false end if not initialized then self:Initialize() end -- ToggleWorldMap is a global available before Blizzard_WorldMap -- loads; calling it loads the addon and shows the map. After it @@ -2489,7 +2497,7 @@ function MapTab:Focus() -- file consumes _pendingFocus to retry once the panel exists. if not panel or not tabFrame then MapTab._pendingFocus = true - return + return false end -- Synchronous tab swap: same path the user's tab click takes. -- The OnMouseUp handler invokes ShowOurPanel(). @@ -2507,9 +2515,11 @@ function MapTab:Focus() panel.searchBox:SetFocus() end end) + return true end function MapTab:OpenWithQuery(query) + if not IsMapSearchEnabled() then return false end if not initialized then self:Initialize() end if not WorldMapFrame or not WorldMapFrame:IsShown() then if ToggleWorldMap then ToggleWorldMap() end @@ -2520,7 +2530,7 @@ function MapTab:OpenWithQuery(query) -- exists. Mirrors _pendingFocus. if not panel or not tabFrame then MapTab._pendingQuery = query - return + return false end -- Order matters: set the search text BEFORE invoking the tab's -- OnMouseUp. ShowOurPanel reads the current editbox text and runs @@ -2550,6 +2560,7 @@ function MapTab:OpenWithQuery(query) panel.searchBox:ClearFocus() end end) + return true end -- --------------------------------------------------------------------------- @@ -2583,6 +2594,13 @@ local function PlaceTab() end function MapTab:Initialize() + if not IsMapSearchEnabled() then + if initialized and tabFrame then tabFrame:Hide() end + if initialized and panel then + if panel.outer then panel.outer:Hide() else panel:Hide() end + end + return + end if initialized then return end local qmf = _G["QuestMapFrame"] if not qmf or not qmf.MapLegendTab then return end @@ -2668,6 +2686,11 @@ function MapTab:Initialize() if WorldMapFrame and WorldMapFrame.IsMaximized then local function UpdateTabVisibility() if not tabFrame then return end + if not IsMapSearchEnabled() then + HideOurPanel() + tabFrame:Hide() + return + end if WorldMapFrame:IsMaximized() then HideOurPanel() tabFrame:Hide() @@ -2712,6 +2735,7 @@ local eventFrame = CreateFrame("Frame") eventFrame:RegisterEvent("ADDON_LOADED") eventFrame:RegisterEvent("PLAYER_LOGIN") eventFrame:SetScript("OnEvent", function(_, event, addonName) + if not IsMapSearchEnabled() then return end if event == "ADDON_LOADED" and addonName == "Blizzard_WorldMap" then SafeAfter(0, function() MapTab:Initialize() end) elseif event == "PLAYER_LOGIN" then diff --git a/Options.lua b/Options.lua index d61438e..b4772fa 100644 --- a/Options.lua +++ b/Options.lua @@ -11,19 +11,12 @@ local tinsert, tconcat = Utils.tinsert, Utils.tconcat local IsMouseButtonDown = IsMouseButtonDown local DEFAULT_OPACITY = ns.DEFAULT_OPACITY -local TOOLTIP_BORDER = ns.TOOLTIP_BORDER -local DARK_PANEL_BG = ns.DARK_PANEL_BG +local OPTIONS_PANEL_ALPHA = 0.9 local optionsFrame local isInitialized = false local blizzardRegistered = false -local FRAME_BACKDROP = { - edgeFile = TOOLTIP_BORDER, - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 }, -} - local NIL = {} local DEFAULT_UI_FILTERS = { ui = true, achievements = true, statistics = true, currencies = true, @@ -45,6 +38,7 @@ local UI_DEFAULTS = { fontSize = 0.9, uiSearchScale = 1.0, uiSearchWidth = 1.54, + uiSearchBarHeight = ns.SEARCHBAR_HEIGHT or 30, uiResultsScale = 1.0, uiResultsWidth = 350, uiSearchPosition = NIL, @@ -84,9 +78,7 @@ local GENERAL_DEFAULTS = { showLoginMessage = true, showMinimapButton = true, minimapButtonAngle = 200, - panelOpacity = 0.9, visible = true, - enableUISearch = true, enableMapSearch = true, nativePinScale = 1.5, pinnedUIItems = {}, @@ -145,6 +137,7 @@ local function RefreshUIRuntime(resetPosition) if ns.UI.UpdateScale then ns.UI:UpdateScale() end if ns.UI.UpdateWidth then ns.UI:UpdateWidth() end if ns.UI.UpdateOpacity then ns.UI:UpdateOpacity() end + if ns.UI.UpdateSearchBarHeight then ns.UI:UpdateSearchBarHeight() end if ns.UI.UpdateSmartShow then ns.UI:UpdateSmartShow() end if ns.UI.UpdateFontSize then ns.UI:UpdateFontSize() end if ns.UI.RefreshResults then ns.UI:RefreshResults() end @@ -169,21 +162,16 @@ end local function SyncOptionControls() if not optionsFrame then return end - if optionsFrame.panelOpacitySlider then optionsFrame.panelOpacitySlider:SetValue(EasyFind.db.panelOpacity or 0.9) end - if optionsFrame.opacitySlider then optionsFrame.opacitySlider:SetValue(EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) end - if optionsFrame.uiFontSlider then optionsFrame.uiFontSlider:SetValue(EasyFind.db.fontSize or 0.9) end - if optionsFrame.mapIconSlider then optionsFrame.mapIconSlider:SetValue(EasyFind.db.iconScale or 0.8) end + if optionsFrame.opacityPresetRow then optionsFrame.opacityPresetRow:SetValue(EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) end + if optionsFrame.uiFontPresetRow then optionsFrame.uiFontPresetRow:SetValue(EasyFind.db.fontSize or 0.9) end + if optionsFrame.mapIconPresetRow then optionsFrame.mapIconPresetRow:SetValue(EasyFind.db.iconScale or 0.8) end + if optionsFrame.recentCountStepper then optionsFrame.recentCountStepper:SetValue(EasyFind.db.mapTabRecentCount or 3) end if optionsFrame.autoHideCheckbox then optionsFrame.autoHideCheckbox:SetChecked(EasyFind.db.autoHide ~= false) end if optionsFrame.smartShowCheckbox then - local autoOn = EasyFind.db.autoHide ~= false optionsFrame.smartShowCheckbox:SetChecked(EasyFind.db.smartShow or false) - optionsFrame.smartShowCheckbox:SetEnabled(not autoOn) local lbl = optionsFrame.smartShowCheckbox:GetFontString() - if lbl then - local g = autoOn and 0.5 or 1 - lbl:SetTextColor(g, g, g) - end + if lbl then lbl:SetTextColor(1, 1, 1) end end if optionsFrame.staticOpacityCheckbox then optionsFrame.staticOpacityCheckbox:SetChecked(EasyFind.db.staticOpacity or false) end if optionsFrame.lockPositionCheckbox then optionsFrame.lockPositionCheckbox:SetChecked(EasyFind.db.lockPosition or false) end @@ -193,9 +181,12 @@ local function SyncOptionControls() if optionsFrame.minimapBtnCheckbox then optionsFrame.minimapBtnCheckbox:SetChecked(EasyFind.db.showMinimapButton ~= false) end if optionsFrame.rareTrackCheckbox then optionsFrame.rareTrackCheckbox:SetChecked(EasyFind.db.alwaysShowRares or false) end - if optionsFrame.mapPinGroup then optionsFrame.mapPinGroup:UpdateVisuals() end - if optionsFrame.automationGroup then optionsFrame.automationGroup:UpdateVisuals() end - if optionsFrame.UpdateUIToggleVisual then optionsFrame.UpdateUIToggleVisual() end + if optionsFrame.mapTabShowRecentCheckbox then optionsFrame.mapTabShowRecentCheckbox:SetChecked(EasyFind.db.mapTabShowRecent ~= false) end + if optionsFrame.UpdateRecentCountEnabled then optionsFrame.UpdateRecentCountEnabled() end + if optionsFrame.mapPinHighlightCheckbox then optionsFrame.mapPinHighlightCheckbox:SetChecked(EasyFind.db.mapPinHighlight ~= false) end + if optionsFrame.blinkingPinsCheckbox then optionsFrame.blinkingPinsCheckbox:SetChecked(EasyFind.db.blinkingPins or false) end + if optionsFrame.autoTrackPinsCheckbox then optionsFrame.autoTrackPinsCheckbox:SetChecked(EasyFind.db.autoTrackPins ~= false) end + if optionsFrame.autoPinClearCheckbox then optionsFrame.autoPinClearCheckbox:SetChecked(EasyFind.db.autoPinClear ~= false) end if optionsFrame.UpdateMapToggleVisual then optionsFrame.UpdateMapToggleVisual() end if optionsFrame.indicatorBtnText then optionsFrame.indicatorBtnText:SetText(EasyFind.db.indicatorStyle or "EasyFind Arrow") end @@ -207,35 +198,57 @@ local function SyncOptionControls() optionsFrame.colorBtnText:SetText(clr) optionsFrame.colorBtnText:SetTextColor(rgb[1], rgb[2], rgb[3]) end - if optionsFrame.colorSwatch then optionsFrame.colorSwatch:SetColorTexture(rgb[1], rgb[2], rgb[3], 1) end - if optionsFrame.toggleFocusBtn then optionsFrame.toggleFocusBtn:SetText(GetBindingKey("EASYFIND_TOGGLE_FOCUS") or "Not Bound") end if optionsFrame.mapFocusBtn then optionsFrame.mapFocusBtn:SetText(GetBindingKey("EASYFIND_MAP_FOCUS") or "Not Bound") end if optionsFrame.clearBtn then optionsFrame.clearBtn:SetText(GetBindingKey("EASYFIND_CLEAR") or "Not Bound") end end --- Shared backdrop for selector buttons and flyout panels -local SELECTOR_BACKDROP = { - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", - edgeFile = TOOLTIP_BORDER, - tile = true, tileSize = 16, edgeSize = 12, - insets = { left = 2, right = 2, top = 2, bottom = 2 } -} +local function PaintRoundedFill(frame, r, g, b, a) + if not (frame and frame.combinedBorder and frame.combinedBorder.fill) then return end + for _, tex in pairs(frame.combinedBorder.fill) do + tex:SetVertexColor(r, g, b, a or 1) + tex:SetAlpha(a or 1) + end +end + +local function HideRoundedFrameBorder(frame) + if not (frame and frame.combinedBorder and frame.combinedBorder.border) then return end + for _, tex in pairs(frame.combinedBorder.border) do tex:Hide() end +end + +local function StyleSelectorButton(btnFrame, height) + btnFrame:SetBackdrop(nil) + ns.CreateRoundedRectBorder(btnFrame) + ns.SetRoundedRectBarHeight(btnFrame, mmin(height or 22, 10)) + HideRoundedFrameBorder(btnFrame) + PaintRoundedFill(btnFrame, 0.095, 0.095, 0.108, 1) + btnFrame:HookScript("OnEnter", function(self) + if self:IsEnabled() then PaintRoundedFill(self, 0.155, 0.155, 0.172, 1) end + end) + btnFrame:HookScript("OnLeave", function(self) + if self:IsEnabled() then PaintRoundedFill(self, 0.095, 0.095, 0.108, 1) end + end) +end -- Helper to create a flyout selector (button + dropdown panel + toggle + click-away) -- Returns: btnFrame, btnText, flyout local function CreateFlyoutSelector(parent, globalPrefix, width, anchor, initialText) local btnFrame = CreateFrame("Button", globalPrefix .. "Button", parent, "BackdropTemplate") btnFrame:SetSize(width, 22) - btnFrame:SetPoint("LEFT", anchor, "RIGHT", 8, 0) - btnFrame:SetBackdrop(SELECTOR_BACKDROP) - btnFrame:SetBackdropColor(0.1, 0.1, 0.1, 0.8) - btnFrame:SetBackdropBorderColor(0.6, 0.6, 0.6, 0.8) + btnFrame:SetPoint("RIGHT", parent, "RIGHT", -8, 0) + StyleSelectorButton(btnFrame, 22) local btnText = btnFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") - btnText:SetPoint("CENTER") + btnText:SetPoint("LEFT", btnFrame, "LEFT", 8, 0) + btnText:SetPoint("RIGHT", btnFrame, "RIGHT", -18, 0) + btnText:SetJustifyH("CENTER") btnText:SetText(initialText) + local arrow = btnFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + arrow:SetPoint("RIGHT", btnFrame, "RIGHT", -6, 0) + arrow:SetText("v") + btnFrame.arrow = arrow + return btnFrame, btnText end @@ -244,14 +257,19 @@ end local function CreateFlyoutPanel(btnFrame, globalPrefix, width, numChoices) local flyout = CreateFrame("Frame", globalPrefix .. "Flyout", btnFrame, "BackdropTemplate") flyout:SetSize(width, numChoices * 20 + 6) - flyout:SetPoint("TOP", btnFrame, "BOTTOM", 0, -2) + flyout:SetPoint("TOPRIGHT", btnFrame, "BOTTOMRIGHT", 0, -2) flyout:SetFrameStrata("FULLSCREEN_DIALOG") - flyout:SetBackdrop(SELECTOR_BACKDROP) - flyout:SetBackdropColor(DARK_PANEL_BG[1], DARK_PANEL_BG[2], DARK_PANEL_BG[3], DARK_PANEL_BG[4]) + flyout:SetBackdrop(nil) + ns.CreateRoundedRectBorder(flyout) + ns.SetRoundedRectBarHeight(flyout, 10) + HideRoundedFrameBorder(flyout) + PaintRoundedFill(flyout, 0.08, 0.08, 0.09, 1) flyout:Hide() btnFrame:SetScript("OnClick", function() - flyout:SetShown(not flyout:IsShown()) + local opening = not flyout:IsShown() + flyout:SetShown(opening) + if btnFrame.arrow then btnFrame.arrow:SetText(opening and "^" or "v") end end) flyout:SetScript("OnShow", function(self) @@ -265,6 +283,7 @@ local function CreateFlyoutPanel(btnFrame, globalPrefix, width, numChoices) end) flyout:SetScript("OnHide", function(self) self:SetScript("OnUpdate", nil) + if btnFrame.arrow then btnFrame.arrow:SetText("v") end end) return flyout @@ -276,9 +295,24 @@ local function AddFlyoutOptions(flyout, choices, itemWidth, onSelect) local flyoutBtn = CreateFrame("Button", nil, flyout) flyoutBtn:SetSize(itemWidth, 18) flyoutBtn:SetPoint("TOPLEFT", flyout, "TOPLEFT", 3, -3 - (i - 1) * 20) - flyoutBtn:SetNormalFontObject("GameFontHighlightSmall") - flyoutBtn:SetHighlightFontObject("GameFontNormalSmall") - flyoutBtn:SetText(name) + local rowBg = CreateFrame("Frame", nil, flyoutBtn) + rowBg:SetAllPoints() + rowBg:EnableMouse(false) + ns.CreateRoundedRectBorder(rowBg) + ns.SetRoundedRectBarHeight(rowBg, 9) + HideRoundedFrameBorder(rowBg) + PaintRoundedFill(rowBg, 1, 1, 1, 0) + local label = flyoutBtn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + label:SetPoint("LEFT", flyoutBtn, "LEFT", 6, 0) + label:SetPoint("RIGHT", flyoutBtn, "RIGHT", -6, 0) + label:SetJustifyH("LEFT") + label:SetText(name) + flyoutBtn:SetScript("OnEnter", function() + PaintRoundedFill(rowBg, 1, 1, 1, 0.06) + end) + flyoutBtn:SetScript("OnLeave", function() + PaintRoundedFill(rowBg, 1, 1, 1, 0) + end) flyoutBtn:SetScript("OnClick", function() onSelect(name) flyout:Hide() @@ -286,369 +320,460 @@ local function AddFlyoutOptions(flyout, choices, itemWidth, onSelect) end end --- Helper to create a slider (anchored manually by caller) -local function CreateSlider(parent, name, label, minVal, maxVal, step, tooltipText, formatFunc, defaultValue, unitSuffix) - local slider = CreateFrame("Slider", "EasyFindOptions" .. name .. "Slider", parent, "OptionsSliderTemplate") - slider:SetWidth(160) - slider:SetMinMaxValues(minVal, maxVal) - slider:SetValueStep(step) - slider:SetObeyStepOnDrag(true) - - slider.Text = slider:CreateFontString(nil, "ARTWORK", "GameFontNormal") - slider.Text:SetPoint("BOTTOM", slider, "TOP", 0, 5) - slider.Text:SetText(label) - - -- Use custom format function or default to percentage - local isPercentage = not formatFunc -- Track if using percentage format - local defaultFormat = function(val) return sformat("%.0f%%", val * 100) end - formatFunc = formatFunc or defaultFormat - - slider.Low:SetText(formatFunc(minVal)) - slider.High:SetText(formatFunc(maxVal)) - - slider.valueText = slider:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") - slider.valueText:SetPoint("TOP", slider, "BOTTOM", 0, -2) - - -- Input box for precise value entry (shows just the number, no %) - local inputBox = CreateFrame("EditBox", nil, slider, "InputBoxTemplate") - inputBox:SetSize(30, 20) -- Sized to fit 3 digits comfortably - inputBox:SetPoint("LEFT", slider, "RIGHT", 10, 0) - inputBox:SetAutoFocus(false) - inputBox:SetMaxLetters(3) - inputBox:SetTextInsets(3, 3, 0, 0) -- Equal padding for centering - inputBox:SetJustifyH("CENTER") - -- Also set the font string justification directly - if inputBox.GetFontString then - local fs = inputBox:GetFontString() - if fs then fs:SetJustifyH("CENTER") end - end - - local suffixText = isPercentage and "%" or unitSuffix - if suffixText then - local suffixLabel = slider:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") - suffixLabel:SetPoint("LEFT", inputBox, "RIGHT", 2, 0) - suffixLabel:SetText(suffixText) - slider.suffixLabel = suffixLabel - end - - -- Helper to get display value (for percentage: multiply by 100) - local function getDisplayValue(sliderValue) - if isPercentage then - return mfloor(sliderValue * 100 + 0.5) +-- Helper to create a Raycast-style toggle row (anchored manually by caller). +-- This remains a CheckButton so existing option code can keep using +-- SetChecked/GetChecked and OnClick callbacks. +local function CreateCheckbox(parent, name, label, tooltipText, compact, width) + local frameName = name and ("EasyFindOptions" .. name .. "Checkbox") or nil + local rowH = compact and 22 or 28 + local toggleW = compact and 28 or 32 + local toggleH = compact and 14 or 16 + local knobSize = toggleH - 2 + local checkbox = CreateFrame("CheckButton", frameName, parent) + checkbox:SetSize(width or (compact and 220 or 330), rowH) + checkbox:RegisterForClicks("LeftButtonUp") + + local rowBg = CreateFrame("Frame", nil, checkbox) + rowBg:SetAllPoints() + rowBg:EnableMouse(false) + ns.CreateRoundedRectBorder(rowBg) + ns.SetRoundedRectBarHeight(rowBg, 8) + HideRoundedFrameBorder(rowBg) + PaintRoundedFill(rowBg, 1, 1, 1, 0) + checkbox.rowBg = rowBg + + local text = checkbox:CreateFontString(nil, "OVERLAY", compact and "GameFontHighlightSmall" or "GameFontNormalSmall") + text:SetPoint("LEFT", checkbox, "LEFT", compact and 6 or 8, 0) + text:SetJustifyH("LEFT") + text:SetText(label or "") + checkbox.Text = text + + local track = CreateFrame("Frame", nil, checkbox) + track:SetSize(toggleW, toggleH) + track:SetPoint("RIGHT", checkbox, "RIGHT", compact and -4 or -8, 0) + track:EnableMouse(false) + ns.CreateRoundedRectBorder(track) + ns.SetRoundedRectBarHeight(track, toggleH) + HideRoundedFrameBorder(track) + checkbox.track = track + + text:SetPoint("RIGHT", track, "LEFT", -8, 0) + + local knob = CreateFrame("Frame", nil, track) + knob:SetSize(knobSize, knobSize) + knob:EnableMouse(false) + ns.CreateRoundedRectBorder(knob) + ns.SetRoundedRectBarHeight(knob, knobSize) + HideRoundedFrameBorder(knob) + PaintRoundedFill(knob, 0.96, 0.96, 0.96, 1) + checkbox.knob = knob + + local function UpdateVisual(self) + local enabled = self:IsEnabled() + local checked = self:GetChecked() + local rowAlpha = self:IsMouseOver() and enabled and 0.055 or 0 + PaintRoundedFill(self.rowBg, 1, 1, 1, rowAlpha) + if checked and enabled then + PaintRoundedFill(track, 0.17, 0.48, 0.72, 1) + text:SetTextColor(1, 1, 1, 1) + elseif checked then + PaintRoundedFill(track, 0.13, 0.25, 0.34, 1) + text:SetTextColor(0.55, 0.55, 0.55, 1) + elseif enabled then + PaintRoundedFill(track, 0.23, 0.23, 0.25, 1) + text:SetTextColor(0.88, 0.88, 0.88, 1) else - return mfloor(sliderValue + 0.5) + PaintRoundedFill(track, 0.13, 0.13, 0.14, 1) + text:SetTextColor(0.50, 0.50, 0.50, 1) end - end - -- Helper to convert display value to slider value - local function getSliderValue(displayValue) - if isPercentage then - return displayValue / 100 + knob:ClearAllPoints() + if checked then + knob:SetPoint("RIGHT", track, "RIGHT", -1, 0) else - return displayValue + knob:SetPoint("LEFT", track, "LEFT", 1, 0) end end - inputBox:SetScript("OnEnterPressed", function(self) - local val = tonumber(self:GetText()) - if val then - -- Valid number: clamp to bounds and update slider - local sliderVal = getSliderValue(val) - sliderVal = mmax(minVal, mmin(maxVal, sliderVal)) - slider:SetValue(sliderVal) - -- Update input box to show the actual clamped value - self:SetText(tostring(getDisplayValue(sliderVal))) - else - -- Invalid input: revert to current slider value - self:SetText(tostring(getDisplayValue(slider:GetValue()))) + local rawSetChecked = checkbox.SetChecked + checkbox.SetChecked = function(self, checked) + rawSetChecked(self, checked) + UpdateVisual(self) + end + checkbox.RefreshVisual = UpdateVisual + checkbox.GetFontString = function(self) return self.Text end + checkbox.SetText = function(self, value) + self.Text:SetText(value or "") + end + + checkbox:HookScript("OnClick", UpdateVisual) + checkbox:HookScript("OnEnter", function(self) + UpdateVisual(self) + if tooltipText then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(label) + GameTooltip:AddLine(tooltipText, 1, 1, 1, true) + GameTooltip:Show() end - self:ClearFocus() end) - - inputBox:SetScript("OnEscapePressed", function(self) - self:SetText(tostring(getDisplayValue(slider:GetValue()))) - self:ClearFocus() + checkbox:HookScript("OnLeave", function(self) + UpdateVisual(self) + if tooltipText then GameTooltip_Hide() end end) + checkbox:HookScript("OnEnable", UpdateVisual) + checkbox:HookScript("OnDisable", UpdateVisual) - -- Update both valueText and input box when slider changes - slider:SetScript("OnValueChanged", function(self, value) - self.valueText:SetText(formatFunc(value)) - if not inputBox:HasFocus() then - inputBox:SetText(tostring(getDisplayValue(value))) - end - end) + UpdateVisual(checkbox) + return checkbox +end - -- Set initial value - inputBox:SetText(tostring(getDisplayValue(slider:GetValue()))) +local DISABLED_TEXT = { 0.5, 0.5, 0.5 } +local NORMAL_TEXT = ns.GOLD_COLOR +local OPTIONS_PANEL_SCALE = 0.88 +local TEXT_PRIMARY = { 1.00, 0.97, 0.86 } +local TEXT_BODY = { 0.78, 0.78, 0.80 } +local TEXT_DIM = { 0.55, 0.55, 0.58 } +local NAV_SELECTED = { 0.15, 0.15, 0.17, 0.95 } +local NAV_HOVER = { 0.11, 0.11, 0.13, 0.85 } +local NAV_CLEAR = { 0, 0, 0, 0 } + +local function TintRoundedFill(frame, r, g, b) + if not (frame and frame.combinedBorder and frame.combinedBorder.fill) then return end + for _, tex in pairs(frame.combinedBorder.fill) do + tex:SetVertexColor(r, g, b, 1) + if tex.SetSnapToPixelGrid then tex:SetSnapToPixelGrid(false) end + if tex.SetTexelSnappingBias then tex:SetTexelSnappingBias(0) end + end +end - slider.inputBox = inputBox +local function HideRoundedBorder(frame) + if not (frame and frame.combinedBorder and frame.combinedBorder.border) then return end + for _, tex in pairs(frame.combinedBorder.border) do tex:Hide() end +end - if defaultValue then - local resetBtn = CreateFrame("Button", nil, slider) - resetBtn:SetSize(30, 12) - resetBtn:SetPoint("TOP", inputBox, "BOTTOM", 0, -1) - local resetText = resetBtn:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") - resetText:SetPoint("CENTER") - resetText:SetText("Reset") - resetBtn:SetScript("OnEnter", function() - resetText:SetTextColor(1, 1, 1) - end) - resetBtn:SetScript("OnLeave", function() - resetText:SetTextColor(0.5, 0.5, 0.5) - end) - resetBtn:SetScript("OnClick", function() - slider:SetValue(defaultValue) - end) - slider.resetBtn = resetBtn +local function ApplyWizardPanelGloss(frame) + local fill = frame.combinedBorder and frame.combinedBorder.fill + if not fill then return end + local H = frame:GetHeight() + if not H or H <= 0 then return end + local corner = (frame.cbBarHeight or 32) / 2 + local darkFrac = 0.90 + local function smoothstep(t) + if t <= 0 then return 0 end + if t >= 1 then return 1 end + return t * t * (3 - 2 * t) end - - if tooltipText then - slider:SetScript("OnEnter", function(self) - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - GameTooltip:SetText(label) - GameTooltip:AddLine(tooltipText, 1, 1, 1, true) - GameTooltip:Show() - end) - slider:SetScript("OnLeave", GameTooltip_Hide) + local function lerp(a, b, t) return a + (b - a) * t end + local function colorAtY(y) + local t = y / H + if t < darkFrac then t = 0 else t = smoothstep((t - darkFrac) / (1 - darkFrac)) end + return lerp(0.022, 0.20, t), lerp(0.022, 0.20, t), lerp(0.030, 0.22, t) end - - return slider + local function ramp(cell, yTop, yBot) + if not cell then return end + local r1, g1, b1 = colorAtY(yTop) + local r2, g2, b2 = colorAtY(yBot) + cell:SetGradient("VERTICAL", CreateColor(r2, g2, b2, 1), CreateColor(r1, g1, b1, 1)) + end + ramp(fill.tl, 0, corner); ramp(fill.tm, 0, corner); ramp(fill.tr, 0, corner) + ramp(fill.ml, corner, H - corner); ramp(fill.mm, corner, H - corner); ramp(fill.mr, corner, H - corner) + ramp(fill.bl, H - corner, H); ramp(fill.bm, H - corner, H); ramp(fill.br, H - corner, H) end --- Helper to create a checkbox (anchored manually by caller) -local function CreateCheckbox(parent, name, label, tooltipText) - local checkbox = CreateFrame("CheckButton", "EasyFindOptions" .. name .. "Checkbox", parent, "InterfaceOptionsCheckButtonTemplate") - checkbox.Text:SetText(label) +local function StyleWizardBackground(frame) + ns.CreateRoundedRectBorder(frame) + ns.SetRoundedRectBarHeight(frame, 16) + ns.SetRoundedRectBorderBgAlpha(frame, OPTIONS_PANEL_ALPHA) + HideRoundedBorder(frame) + TintRoundedFill(frame, 0.04, 0.04, 0.05) + ApplyWizardPanelGloss(frame) + frame:HookScript("OnSizeChanged", ApplyWizardPanelGloss) +end - if tooltipText then - checkbox:SetScript("OnEnter", function(self) - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - GameTooltip:SetText(label) - GameTooltip:AddLine(tooltipText, 1, 1, 1, true) - GameTooltip:Show() - end) - checkbox:SetScript("OnLeave", GameTooltip_Hide) +local function CreateModernCloseButton(parent) + local closeBtn = CreateFrame("Button", nil, parent) + closeBtn:SetSize(18, 18) + closeBtn:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -10, -10) + local function MakeStroke() + local tex = closeBtn:CreateTexture(nil, "OVERLAY") + tex:SetTexture("Interface\\Buttons\\WHITE8x8") + tex:SetSize(16, 1.5) + tex:SetPoint("CENTER") + return tex end - - return checkbox + local stroke1 = MakeStroke(); stroke1:SetRotation(math.pi / 4) + local stroke2 = MakeStroke(); stroke2:SetRotation(-math.pi / 4) + local function SetColor(r, g, b) + stroke1:SetVertexColor(r, g, b, 1) + stroke2:SetVertexColor(r, g, b, 1) + end + SetColor(TEXT_DIM[1], TEXT_DIM[2], TEXT_DIM[3]) + closeBtn:SetScript("OnEnter", function() SetColor(1, 1, 1) end) + closeBtn:SetScript("OnLeave", function() SetColor(TEXT_DIM[1], TEXT_DIM[2], TEXT_DIM[3]) end) + closeBtn:SetScript("OnClick", function() parent:Hide() end) + return closeBtn end --- Multi-select dropdown: a label + button that opens a flyout with checkboxes. --- optionDefs: array of { label, dbKey, default, tooltip, callback } --- Returns a wrapper frame with :UpdateVisuals(), :SetGroupEnabled(bool), --- :AddSlider(...), and .flyout / .checkRows / .sliders fields. -local function CreateMultiSelectDropdown(parent, groupLabel, optionDefs, btnWidth, flyoutWidth) - btnWidth = btnWidth or 160 - flyoutWidth = flyoutWidth or btnWidth - - local wrapper = CreateFrame("Frame", nil, parent) - wrapper:SetHeight(22) - - local label = wrapper:CreateFontString(nil, "OVERLAY", "GameFontNormal") - label:SetPoint("LEFT", 4, 0) - label:SetText(groupLabel .. ":") - wrapper.label = label - - local btnFrame = CreateFrame("Button", nil, wrapper) - btnFrame:SetSize(btnWidth, 22) - wrapper.button = btnFrame - - -- WoW-style dropdown button - local btnBg = btnFrame:CreateTexture(nil, "BACKGROUND") - btnBg:SetAllPoints() - btnBg:SetAtlas("common-dropdown-b-button-hover") - btnFrame.bg = btnBg - - -- Summary text: comma-separated short names of active options - local function GetSummaryText() - local parts = {} - for _, def in ipairs(optionDefs) do - if not def.hiddenFromSummary then - local val = EasyFind.db[def.dbKey] - if val == nil then val = def.default end - if val then tinsert(parts, def.shortLabel or def.label) end - end - end - if #parts == 0 then return "None" end - return tconcat(parts, ", ") +local function SetModernButtonFill(btn, r, g, b) + if not (btn and btn.combinedBorder and btn.combinedBorder.fill) then return end + for _, tex in pairs(btn.combinedBorder.fill) do + tex:SetVertexColor(r, g, b, 1) end +end - local btnText = btnFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") - btnText:SetPoint("LEFT", 4, 0) - btnText:SetPoint("RIGHT", -16, 0) - btnText:SetJustifyH("CENTER") - btnText:SetText(GetSummaryText()) - wrapper.btnText = btnText +local function SetModernButtonAlpha(btn, alpha) + if not (btn and btn.combinedBorder and btn.combinedBorder.fill) then return end + for _, tex in pairs(btn.combinedBorder.fill) do tex:SetAlpha(alpha) end +end - local arrow = btnFrame:CreateTexture(nil, "OVERLAY") - arrow:SetSize(16, 16) - arrow:SetPoint("RIGHT", -2, 0) - arrow:SetAtlas("common-dropdown-b-arrow-closed") - btnFrame.arrow = arrow +local function SetNavButtonBg(btn, color) + SetModernButtonFill(btn, color[1], color[2], color[3]) + SetModernButtonAlpha(btn, color[4] or 1) +end - -- Flyout panel with checkboxes (and optional sliders via AddSlider). - -- Parented to UIParent so it isn't clipped by the options frame. - local ROW_H = 22 - local flyout = CreateFrame("Frame", nil, UIParent, "BackdropTemplate") - flyout:SetSize(flyoutWidth, #optionDefs * ROW_H + 8) - flyout:SetFrameStrata("FULLSCREEN_DIALOG") - flyout:SetFrameLevel(900) - flyout:SetBackdrop({ - bgFile = "Interface\\BUTTONS\\WHITE8X8", - edgeFile = TOOLTIP_BORDER, - tile = true, tileSize = 16, edgeSize = 12, - insets = { left = 2, right = 2, top = 2, bottom = 2 }, - }) - flyout:SetBackdropColor(0.08, 0.08, 0.08, 1) - flyout:Hide() - wrapper.flyout = flyout +local function CreateModernButton(parent, text, width, height) + local btn = CreateFrame("Button", nil, parent) + local rawSetSize = btn.SetSize + rawSetSize(btn, width or 120, height or 22) + + ns.CreateRoundedRectBorder(btn) + ns.SetRoundedRectBarHeight(btn, mmin(height or 22, 10)) + ns.SetRoundedRectBorderBgAlpha(btn, 1) + HideRoundedBorder(btn) + SetModernButtonFill(btn, 0.095, 0.095, 0.108) + + local label = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + label:SetPoint("CENTER") + label:SetText(text or "") + label:SetTextColor(1, 1, 1, 1) + btn._label = label + + btn.SetText = function(self, value) + if self._label then self._label:SetText(value or "") end + end + btn.GetText = function(self) + return self._label and self._label:GetText() or "" + end + btn.SetSize = function(self, w, h) + rawSetSize(self, w, h) + ns.SetRoundedRectBarHeight(self, mmin(h or self:GetHeight() or 22, 10)) + end - -- Track vertical cursor for adding content (checkboxes, then sliders) - local contentH = #optionDefs * ROW_H + 4 + local rawSetNormalFont = btn.SetNormalFontObject + btn.SetNormalFontObject = function(self, fontObject) + if rawSetNormalFont then rawSetNormalFont(self, fontObject) end + if self._label then self._label:SetFontObject(fontObject) end + end + local rawSetHighlightFont = btn.SetHighlightFontObject + btn.SetHighlightFontObject = function(self, fontObject) + if rawSetHighlightFont then rawSetHighlightFont(self, fontObject) end + end - btnFrame:SetScript("OnClick", function() - flyout:ClearAllPoints() - flyout:SetPoint("TOP", btnFrame, "BOTTOM", 0, -2) - local opening = not flyout:IsShown() - flyout:SetShown(opening) - arrow:SetAtlas(opening and "common-dropdown-b-arrow-open" or "common-dropdown-b-arrow-closed") + btn:SetScript("OnEnter", function(self) + if self:IsEnabled() then SetModernButtonFill(self, 0.155, 0.155, 0.172) end end) - - -- Click-away to close - flyout:SetScript("OnShow", function(self) - self:SetScript("OnUpdate", function(self) - if not self:IsMouseOver() and not btnFrame:IsMouseOver() then - if IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton") then - self:Hide() - end - end - end) + btn:SetScript("OnLeave", function(self) + if self:IsEnabled() then SetModernButtonFill(self, 0.095, 0.095, 0.108) end end) - flyout:SetScript("OnHide", function(self) - self:SetScript("OnUpdate", nil) - arrow:SetAtlas("common-dropdown-b-arrow-closed") + btn:SetScript("OnMouseDown", function(self) + if self:IsEnabled() then SetModernButtonFill(self, 0.065, 0.065, 0.078) end + end) + btn:SetScript("OnMouseUp", function(self) + if self:IsEnabled() then + if self:IsMouseOver() then SetModernButtonFill(self, 0.155, 0.155, 0.172) + else SetModernButtonFill(self, 0.095, 0.095, 0.108) end + end + end) + btn:SetScript("OnDisable", function(self) + SetModernButtonFill(self, 0.070, 0.070, 0.080) + if self._label then self._label:SetTextColor(TEXT_DIM[1], TEXT_DIM[2], TEXT_DIM[3], 1) end + end) + btn:SetScript("OnEnable", function(self) + SetModernButtonFill(self, 0.095, 0.095, 0.108) + if self._label then self._label:SetTextColor(1, 1, 1, 1) end end) - wrapper.checkRows = {} - for i, def in ipairs(optionDefs) do - local row = CreateFrame("CheckButton", nil, flyout, "InterfaceOptionsCheckButtonTemplate") - local indent = def.indent and 16 or 0 - row:SetPoint("TOPLEFT", flyout, "TOPLEFT", 4 + indent, -4 - (i - 1) * ROW_H) - row.Text:SetText(def.label) - row.Text:SetFontObject("GameFontHighlightSmall") - - local val = EasyFind.db[def.dbKey] - if val == nil then val = def.default end - row:SetChecked(val) - - row:SetScript("OnClick", function(self) - EasyFind.db[def.dbKey] = self:GetChecked() - btnText:SetText(GetSummaryText()) - if def.callback then def.callback() end - end) + if text then btn:SetText(text) end + return btn +end - if def.tooltip then - row:SetScript("OnEnter", function(self) - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - GameTooltip:SetText(def.label) - GameTooltip:AddLine(def.tooltip, 1, 1, 1, true) - GameTooltip:Show() - end) - row:SetScript("OnLeave", GameTooltip_Hide) +local function CreateSettingsGroup(parent, width, height) + local group = CreateFrame("Frame", nil, parent) + group:SetSize(width, height) + ns.CreateRoundedRectBorder(group) + ns.SetRoundedRectBarHeight(group, 8) + HideRoundedBorder(group) + PaintRoundedFill(group, 0.060, 0.060, 0.070, 0.94) + group.controls = {} + group.AddControl = function(self, control) + if control then tinsert(self.controls, control) end + return control + end + group.SetGroupEnabled = function(self, enabled) + self:SetAlpha(enabled and 1.0 or 0.35) + for _, control in ipairs(self.controls) do + if control.SetGroupEnabled then + control:SetGroupEnabled(enabled) + elseif control.Enable and control.Disable then + if enabled then control:Enable() else control:Disable() end + end end + end + return group +end - row.dbKey = def.dbKey - row.defaultVal = def.default - row.parentDbKey = def.parentDbKey - tinsert(wrapper.checkRows, row) - end - - wrapper.sliders = {} - - -- Update enabled state of child options based on parent - local function UpdateChildStates() - for _, row in ipairs(wrapper.checkRows) do - if row.parentDbKey then - local parentVal = EasyFind.db[row.parentDbKey] - if parentVal == nil then parentVal = true end - if parentVal then - row:Enable() - if row.Text then row.Text:SetTextColor(1, 1, 1) end - else - row:Disable() - if row.Text then row.Text:SetTextColor(0.5, 0.5, 0.5) end - end - end +local function FindNearestChoice(choices, value) + local best = choices[1] + local bestDelta + for _, choice in ipairs(choices) do + local delta = math.abs((value or choice.value) - choice.value) + if not bestDelta or delta < bestDelta then + best = choice + bestDelta = delta end - for _, slider in ipairs(wrapper.sliders) do - if slider.parentDbKey then - local parentVal = EasyFind.db[slider.parentDbKey] - if parentVal == nil then parentVal = true end - if parentVal then - slider:Enable() - if slider.Text then slider.Text:SetTextColor(1.0, 0.82, 0.0) end - if slider.Low then slider.Low:SetTextColor(0.7, 0.7, 0.7) end - if slider.High then slider.High:SetTextColor(0.7, 0.7, 0.7) end - if slider.valueText then slider.valueText:SetTextColor(1, 1, 1) end - if slider.inputBox then slider.inputBox:Enable(); slider.inputBox:SetTextColor(1, 1, 1) end - if slider.suffixLabel then slider.suffixLabel:SetTextColor(1, 1, 1) end - if slider.resetBtn then slider.resetBtn:Enable() end - else - slider:Disable() - if slider.Text then slider.Text:SetTextColor(0.5, 0.5, 0.5) end - if slider.Low then slider.Low:SetTextColor(0.4, 0.4, 0.4) end - if slider.High then slider.High:SetTextColor(0.4, 0.4, 0.4) end - if slider.valueText then slider.valueText:SetTextColor(0.4, 0.4, 0.4) end - if slider.inputBox then slider.inputBox:Disable(); slider.inputBox:SetTextColor(0.4, 0.4, 0.4) end - if slider.suffixLabel then slider.suffixLabel:SetTextColor(0.4, 0.4, 0.4) end - if slider.resetBtn then slider.resetBtn:Disable() end - end + end + return best +end + +local function PaintPresetButton(btn, active, hover, enabled) + if not enabled then + SetModernButtonFill(btn, 0.070, 0.070, 0.080) + if btn._label then btn._label:SetTextColor(TEXT_DIM[1], TEXT_DIM[2], TEXT_DIM[3], 1) end + elseif active then + SetModernButtonFill(btn, 0.17, 0.48, 0.72) + if btn._label then btn._label:SetTextColor(1, 1, 1, 1) end + elseif hover then + SetModernButtonFill(btn, 0.155, 0.155, 0.172) + if btn._label then btn._label:SetTextColor(1, 1, 1, 1) end + else + SetModernButtonFill(btn, 0.095, 0.095, 0.108) + if btn._label then btn._label:SetTextColor(TEXT_BODY[1], TEXT_BODY[2], TEXT_BODY[3], 1) end + end +end + +local function CreatePresetRow(parent, labelText, choices, getter, setter, tooltipText, width) + local row = CreateFrame("Frame", nil, parent) + row:SetSize(width or 330, 28) + row.enabled = true + + local label = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + label:SetPoint("LEFT", row, "LEFT", 8, 0) + local controlW = (#choices == 3) and 174 or 184 + label:SetPoint("RIGHT", row, "RIGHT", -controlW - 18, 0) + label:SetJustifyH("LEFT") + label:SetText(labelText) + row.label = label + + row.buttons = {} + local btnW = math.floor(controlW / #choices) + local right = -8 + for i = #choices, 1, -1 do + local choice = choices[i] + local btn = CreateModernButton(row, choice.label, btnW, 20) + ns.SetRoundedRectBarHeight(btn, 16) + btn.choice = choice + btn:SetPoint("RIGHT", row, "RIGHT", right, 0) + right = right - btnW - 2 + btn:SetScript("OnClick", function(self) + if not row.enabled then return end + setter(self.choice.value) + row:SetValue(self.choice.value) + end) + btn:SetScript("OnEnter", function(self) + PaintPresetButton(self, row.activeValue == self.choice.value, true, row.enabled) + if tooltipText then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(labelText) + GameTooltip:AddLine(tooltipText, 1, 1, 1, true) + GameTooltip:Show() end - end + end) + btn:SetScript("OnLeave", function(self) + PaintPresetButton(self, row.activeValue == self.choice.value, false, row.enabled) + if tooltipText then GameTooltip_Hide() end + end) + row.buttons[i] = btn end - -- Hook OnClick to refresh child states after any toggle - for _, row in ipairs(wrapper.checkRows) do - row:HookScript("OnClick", function() UpdateChildStates() end) - end - UpdateChildStates() - - -- Add a slider inside the flyout, below existing content. - -- Returns the slider frame. Uses the same API as CreateSlider. - function wrapper:AddSlider(name, sliderLabel, minVal, maxVal, step, tooltipText, formatFunc, defaultValue, unitSuffix, parentDbKey) - contentH = contentH + 18 - local slider = CreateSlider(flyout, name, sliderLabel, minVal, maxVal, step, tooltipText, formatFunc, defaultValue, unitSuffix) - local sliderIndent = parentDbKey and 30 or 14 - slider:SetWidth(flyoutWidth - 110 - (sliderIndent - 14)) - slider:SetPoint("TOPLEFT", flyout, "TOPLEFT", sliderIndent, -contentH) - contentH = contentH + 36 - flyout:SetHeight(contentH + 4) - slider.parentDbKey = parentDbKey - tinsert(self.sliders, slider) - if parentDbKey then UpdateChildStates() end - return slider - end - - function wrapper:UpdateVisuals() - for _, row in ipairs(self.checkRows) do - local val = EasyFind.db[row.dbKey] - if val == nil then val = row.defaultVal end - row:SetChecked(val) + row.SetValue = function(self, value) + local choice = FindNearestChoice(choices, value) + self.activeValue = choice and choice.value + for _, btn in ipairs(self.buttons) do + PaintPresetButton(btn, self.activeValue == btn.choice.value, false, self.enabled) end - btnText:SetText(GetSummaryText()) end - - function wrapper:SetGroupEnabled(enabled) - if enabled then btnFrame:Enable() else btnFrame:Disable() end + row.SetGroupEnabled = function(self, enabled) + self.enabled = enabled self:SetAlpha(enabled and 1.0 or 0.35) - if not enabled and flyout:IsShown() then flyout:Hide() end + for _, btn in ipairs(self.buttons) do + if enabled then btn:Enable() else btn:Disable() end + PaintPresetButton(btn, self.activeValue == btn.choice.value, false, enabled) + end end - return wrapper + row:SetValue(getter()) + return row end -local DISABLED_TEXT = { 0.5, 0.5, 0.5 } -local NORMAL_TEXT = ns.GOLD_COLOR +local function CreateStepperRow(parent, labelText, minVal, maxVal, getter, setter, tooltipText, width) + local row = CreateFrame("Frame", nil, parent) + row:SetSize(width or 330, 28) + row.enabled = true + + local label = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + label:SetPoint("LEFT", row, "LEFT", 8, 0) + label:SetPoint("RIGHT", row, "RIGHT", -112, 0) + label:SetJustifyH("LEFT") + label:SetText(labelText) + + local plusBtn = CreateModernButton(row, "+", 24, 20) + plusBtn:SetPoint("RIGHT", row, "RIGHT", -8, 0) + local valueText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + valueText:SetSize(32, 20) + valueText:SetPoint("RIGHT", plusBtn, "LEFT", -4, 0) + valueText:SetJustifyH("CENTER") + local minusBtn = CreateModernButton(row, "-", 24, 20) + minusBtn:SetPoint("RIGHT", valueText, "LEFT", -4, 0) + + local function SetValue(value) + value = mmax(minVal, mmin(maxVal, mfloor((value or minVal) + 0.5))) + setter(value) + valueText:SetText(tostring(value)) + end + local function Step(delta) + if row.enabled then SetValue((getter() or minVal) + delta) end + end + minusBtn:SetScript("OnClick", function() Step(-1) end) + plusBtn:SetScript("OnClick", function() Step(1) end) + + if tooltipText then + row:EnableMouse(true) + row:SetScript("OnEnter", function(self) + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(labelText) + GameTooltip:AddLine(tooltipText, 1, 1, 1, true) + GameTooltip:Show() + end) + row:SetScript("OnLeave", GameTooltip_Hide) + end + + row.SetValue = function(_, value) + valueText:SetText(tostring(mmax(minVal, mmin(maxVal, mfloor((value or minVal) + 0.5))))) + end + row.SetGroupEnabled = function(self, enabled) + self.enabled = enabled + self:SetAlpha(enabled and 1.0 or 0.35) + if enabled then + minusBtn:Enable() + plusBtn:Enable() + else + minusBtn:Disable() + plusBtn:Disable() + end + end + row:SetValue(getter()) + return row +end local function SetControlsEnabled(controls, enabled) for _, ctrl in ipairs(controls) do @@ -659,6 +784,7 @@ local function SetControlsEnabled(controls, enabled) local r, g, b = enabled and 1.0 or DISABLED_TEXT[1], enabled and 1.0 or DISABLED_TEXT[2], enabled and 1.0 or DISABLED_TEXT[3] ctrl.Text:SetTextColor(r, g, b) end + if ctrl.RefreshVisual then ctrl:RefreshVisual() end elseif objType == "Slider" then if enabled then ctrl:Enable() else ctrl:Disable() end if ctrl.Text then @@ -690,17 +816,17 @@ end function Options:Initialize() if isInitialized then return end - local FRAME_W = 380 - local FRAME_H = 380 + local WINDOW_W = 544 + local WINDOW_H = 408 + local SIDEBAR_W = 132 + local FRAME_W = WINDOW_W - SIDEBAR_W - 46 local COL_LEFT = 4 -- Left column offset within content frames - local BTN_OFFSET = 105 -- Label LEFT to button LEFT (aligns selectors/keybinds) - local CONTENT_Y = -68 -- Y where tab content starts (below tab bar) - local CONTENT_H = 294 -- Fixed content area height - -- Create the main options frame (fixed size) + -- Create the main options frame (same footprint as the onboarding wizard) optionsFrame = CreateFrame("Frame", "EasyFindOptionsFrame", UIParent, "BackdropTemplate") ns.optionsFrame = optionsFrame - optionsFrame:SetSize(FRAME_W, FRAME_H) + optionsFrame:SetSize(WINDOW_W, WINDOW_H) + optionsFrame:SetScale(OPTIONS_PANEL_SCALE) if EasyFind.db.optionsPosition then local pos = EasyFind.db.optionsPosition optionsFrame:SetPoint(pos[1], UIParent, pos[2], pos[3], pos[4]) @@ -719,60 +845,61 @@ function Options:Initialize() EasyFind.db.optionsPosition = {point, relPoint, x, y} end) - optionsFrame:SetBackdrop(FRAME_BACKDROP) - optionsFrame:SetBackdropBorderColor(0.50, 0.48, 0.45, 1.0) + optionsFrame:SetBackdrop(nil) - local bgTex = optionsFrame:CreateTexture(nil, "BACKGROUND", nil, -1) - bgTex:SetPoint("TOPLEFT", 4, -4) - bgTex:SetPoint("BOTTOMRIGHT", -4, 4) - bgTex:SetAtlas("QuestLog-main-background", false) - bgTex:SetAlpha(EasyFind.db.panelOpacity or 0.9) + local bgTex = CreateFrame("Frame", nil, optionsFrame) + bgTex:SetAllPoints(optionsFrame) + bgTex:EnableMouse(false) + StyleWizardBackground(bgTex) + bgTex:SetAlpha(OPTIONS_PANEL_ALPHA) optionsFrame.bgTex = bgTex - -- Title - local title = optionsFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") - title:SetPoint("TOP", optionsFrame, "TOP", 0, -16) - title:SetText("EasyFind Options") + local sidebar = CreateFrame("Frame", nil, optionsFrame) + sidebar:SetPoint("TOPLEFT", optionsFrame, "TOPLEFT", 10, -10) + sidebar:SetPoint("BOTTOMLEFT", optionsFrame, "BOTTOMLEFT", 10, 10) + sidebar:SetWidth(SIDEBAR_W) + optionsFrame.sidebar = sidebar + ns.CreateRoundedRectBorder(sidebar) + ns.SetRoundedRectBarHeight(sidebar, 14) + ns.SetRoundedRectBorderBgAlpha(sidebar, 0.72) + HideRoundedBorder(sidebar) + TintRoundedFill(sidebar, 0.035, 0.035, 0.042) + + local divider = optionsFrame:CreateTexture(nil, "ARTWORK") + divider:SetPoint("TOPLEFT", sidebar, "TOPRIGHT", 8, -2) + divider:SetPoint("BOTTOMLEFT", sidebar, "BOTTOMRIGHT", 8, 2) + divider:SetWidth(1) + divider:SetColorTexture(1, 1, 1, 0.08) + + -- Sidebar title + local title = optionsFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + title:SetPoint("TOPLEFT", sidebar, "TOPLEFT", 4, -6) + title:SetText("Settings") + title:SetTextColor(TEXT_BODY[1], TEXT_BODY[2], TEXT_BODY[3], 1) optionsFrame.titleText = title -- Close button - local closeBtn = CreateFrame("Button", nil, optionsFrame, "UIPanelCloseButton") - closeBtn:SetPoint("TOPRIGHT", optionsFrame, "TOPRIGHT", -5, -5) + local closeBtn = CreateModernCloseButton(optionsFrame) optionsFrame.closeBtn = closeBtn - -- Content border (all tabs render inside this) - local contentBorder = CreateFrame("Frame", nil, optionsFrame, "BackdropTemplate") - contentBorder:SetPoint("TOPLEFT", optionsFrame, "TOPLEFT", 12, CONTENT_Y) - contentBorder:SetPoint("TOPRIGHT", optionsFrame, "TOPRIGHT", -12, CONTENT_Y) - contentBorder:SetHeight(CONTENT_H) - contentBorder:SetBackdrop({ - edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", - edgeSize = 14, - insets = { left = 3, right = 3, top = 3, bottom = 3 }, - }) - contentBorder:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.6) - - -- Tab system using WoW standard tab atlases (flipped for top attachment) + -- Content host (all tabs render inside this) + local contentBorder = CreateFrame("Frame", nil, optionsFrame) + contentBorder:SetPoint("TOPLEFT", optionsFrame, "TOPLEFT", SIDEBAR_W + 32, -46) + contentBorder:SetPoint("BOTTOMRIGHT", optionsFrame, "BOTTOMRIGHT", -14, 14) + optionsFrame.contentBorder = contentBorder + + -- Left-sidebar tab system local tabFrames = {} local tabButtons = {} - -- Flip a texture vertically for top-attached tabs - local function FlipV(tex) - tex:SetTexCoord(0, 1, 1, 0) - end - local function SetTabActive(btn, active) btn.isActive = active if active then - btn.leftActive:Show(); btn.midActive:Show(); btn.rightActive:Show() - btn.leftInactive:Hide(); btn.midInactive:Hide(); btn.rightInactive:Hide() - btn:SetHeight(32) - btn:GetFontString():SetTextColor(1, 1, 1) + SetNavButtonBg(btn, NAV_SELECTED) + btn.label:SetTextColor(TEXT_PRIMARY[1], TEXT_PRIMARY[2], TEXT_PRIMARY[3], 1) else - btn.leftActive:Hide(); btn.midActive:Hide(); btn.rightActive:Hide() - btn.leftInactive:Show(); btn.midInactive:Show(); btn.rightInactive:Show() - btn:SetHeight(28) - btn:GetFontString():SetTextColor(1.0, 0.82, 0.0) + SetNavButtonBg(btn, NAV_CLEAR) + btn.label:SetTextColor(TEXT_BODY[1], TEXT_BODY[2], TEXT_BODY[3], 1) end end @@ -787,91 +914,41 @@ function Options:Initialize() local function CreateTab(tabName) local index = #tabFrames + 1 - local btn = CreateFrame("Button", nil, optionsFrame) - btn:SetNormalFontObject("GameFontHighlightSmall") - btn:SetText(tabName) - - local textW = btn:GetFontString():GetStringWidth() - local tabW = mmax(textW + 28, 48) - btn:SetSize(tabW, 28) - - local fs = btn:GetFontString() - fs:ClearAllPoints() - fs:SetPoint("CENTER", btn, "CENTER", -2, 0) - fs:SetJustifyH("CENTER") - - -- Active state textures (flipped vertically for top attachment) - local la = btn:CreateTexture(nil, "BACKGROUND") - la:SetAtlas("uiframe-activetab-left"); la:SetSize(35, 32) - la:SetPoint("BOTTOMLEFT", 0, 0); FlipV(la); la:Hide() - btn.leftActive = la - - local ma = btn:CreateTexture(nil, "BACKGROUND") - ma:SetAtlas("_uiframe-activetab-center") - ma:SetPoint("BOTTOMLEFT", la, "BOTTOMRIGHT", 0, 0) - ma:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -35, 0) - ma:SetHeight(32); FlipV(ma); ma:Hide() - btn.midActive = ma - - local ra = btn:CreateTexture(nil, "BACKGROUND") - ra:SetAtlas("uiframe-activetab-right"); ra:SetSize(37, 32) - ra:SetPoint("BOTTOMRIGHT", 0, 0); FlipV(ra); ra:Hide() - btn.rightActive = ra - - -- Inactive state textures (flipped) - local li = btn:CreateTexture(nil, "BACKGROUND") - li:SetAtlas("uiframe-tab-left"); li:SetSize(35, 28) - li:SetPoint("BOTTOMLEFT", 0, 0); FlipV(li) - btn.leftInactive = li - - local mi = btn:CreateTexture(nil, "BACKGROUND") - mi:SetAtlas("_uiframe-tab-center") - mi:SetPoint("BOTTOMLEFT", li, "BOTTOMRIGHT", 0, 0) - mi:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -35, 0) - mi:SetHeight(28); FlipV(mi) - btn.midInactive = mi - - local ri = btn:CreateTexture(nil, "BACKGROUND") - ri:SetAtlas("uiframe-tab-right"); ri:SetSize(37, 28) - ri:SetPoint("BOTTOMRIGHT", 0, 0); FlipV(ri) - btn.rightInactive = ri - - -- Hover: brighten inactive tabs and switch text to white + local btn = CreateFrame("Button", nil, sidebar) + btn:SetSize(SIDEBAR_W - 16, 28) + btn:SetPoint("TOPLEFT", sidebar, "TOPLEFT", 8, -34 - (index - 1) * 32) + ns.CreateRoundedRectBorder(btn) + ns.SetRoundedRectBarHeight(btn, 10) + ns.SetRoundedRectBorderBgAlpha(btn, 0) + HideRoundedBorder(btn) + SetNavButtonBg(btn, NAV_CLEAR) + + local label = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + label:SetPoint("LEFT", btn, "LEFT", 10, 0) + label:SetPoint("RIGHT", btn, "RIGHT", -10, 0) + label:SetJustifyH("LEFT") + label:SetText(tabName) + label:SetTextColor(TEXT_BODY[1], TEXT_BODY[2], TEXT_BODY[3], 1) + btn.label = label + btn:SetScript("OnEnter", function(self) if not self.isActive then - li:SetVertexColor(1.0, 1.0, 1.0) - mi:SetVertexColor(1.0, 1.0, 1.0) - ri:SetVertexColor(1.0, 1.0, 1.0) - self:GetFontString():SetTextColor(1, 1, 1) + SetNavButtonBg(self, NAV_HOVER) + self.label:SetTextColor(TEXT_PRIMARY[1], TEXT_PRIMARY[2], TEXT_PRIMARY[3], 1) end end) btn:SetScript("OnLeave", function(self) if not self.isActive then - li:SetVertexColor(0.75, 0.75, 0.75) - mi:SetVertexColor(0.75, 0.75, 0.75) - ri:SetVertexColor(0.75, 0.75, 0.75) - self:GetFontString():SetTextColor(1.0, 0.82, 0.0) + SetNavButtonBg(self, NAV_CLEAR) + self.label:SetTextColor(TEXT_BODY[1], TEXT_BODY[2], TEXT_BODY[3], 1) end end) - - -- Start inactive tabs slightly darker so hover contrast is visible - li:SetVertexColor(0.75, 0.75, 0.75) - mi:SetVertexColor(0.75, 0.75, 0.75) - ri:SetVertexColor(0.75, 0.75, 0.75) - - -- Position: tabs sit above the content border, bottom edges touching it - if index == 1 then - btn:SetPoint("BOTTOMLEFT", contentBorder, "TOPLEFT", 5, -2) - else - btn:SetPoint("BOTTOMLEFT", tabButtons[index - 1], "BOTTOMRIGHT", -8, 0) - end btn:SetScript("OnClick", function() SwitchToTab(index) end) tinsert(tabButtons, btn) - -- Content frame inside the border + -- Content frame in the main pane local content = CreateFrame("Frame", nil, contentBorder) - content:SetPoint("TOPLEFT", 6, -6) - content:SetPoint("BOTTOMRIGHT", -6, 6) + content:SetAllPoints(contentBorder) content:Hide() tinsert(tabFrames, content) @@ -938,14 +1015,14 @@ function Options:Initialize() end local function MakeKeybindTooltip(keybindBtn, titleText, line1) - keybindBtn:SetScript("OnEnter", function(self) + keybindBtn:HookScript("OnEnter", function(self) GameTooltip:SetOwner(self, "ANCHOR_RIGHT") GameTooltip:SetText(titleText) GameTooltip:AddLine(line1, 1, 1, 1, true) GameTooltip:AddLine("Right-click to clear. Escape to cancel.", 0.7, 0.7, 0.7, true) GameTooltip:Show() end) - keybindBtn:SetScript("OnLeave", GameTooltip_Hide) + keybindBtn:HookScript("OnLeave", GameTooltip_Hide) end -- SECTION 1: General @@ -954,7 +1031,7 @@ function Options:Initialize() local homeIcon = homeTab:CreateTexture(nil, "ARTWORK") homeIcon:SetSize(48, 48) homeIcon:SetPoint("TOPLEFT", homeTab, "TOPLEFT", 12, -4) - homeIcon:SetTexture("Interface\\MINIMAP\\Tracking\\None") + homeIcon:SetTexture("Interface\\AddOns\\EasyFind\\Textures\\Spyglass") local homeTitle = homeTab:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") homeTitle:SetPoint("LEFT", homeIcon, "RIGHT", 12, 6) @@ -974,12 +1051,8 @@ function Options:Initialize() "|cFFFFD100Quick start:|r Type in the search bar to find UI panels, map locations, " .. "and more. Click a result to navigate there.\n\n" .. "Use the |cFFFFD100filter button|r on the search bar to add mounts, toys, pets, and " - .. "map results to your searches. The world map also has its own search bars " - .. "for finding zones, dungeons, and points of interest directly on the map.\n\n" - .. "|cFFFFD100Slash commands:|r\n" - .. " |cFF00FF00/ef|r Open this options panel\n" - .. " |cFF00FF00/ef c|r Dismiss all highlights and pins\n" - .. " |cFF00FF00/ef toggle|r Show/hide search bar (try Smart Show, the minimap button, or a keybind in the |cFFFFD100Shortcuts|r tab instead)\n\n" + .. "map results to your searches. The world map's |cFFFFD100EasyFind|r tab handles " + .. "zones, dungeons, and points of interest directly on the map.\n\n" .. "For a full walkthrough, see the CurseForge page:" ) @@ -1021,7 +1094,7 @@ function Options:Initialize() local minimapBtnCheckbox = CreateCheckbox(sec3, "MinimapBtn", "Show Minimap Button", "When enabled, adds a small search icon button to the minimap edge.\n\nLeft-click the button to toggle the search bar.\nRight-click to open options.\nDrag to reposition it around the minimap.") - minimapBtnCheckbox:SetPoint("LEFT", loginMessageCheckbox, "RIGHT", 120, 0) + minimapBtnCheckbox:SetPoint("TOPLEFT", loginMessageCheckbox, "BOTTOMLEFT", 0, -2) minimapBtnCheckbox:SetChecked(EasyFind.db.showMinimapButton ~= false) minimapBtnCheckbox:SetScript("OnClick", function(self) EasyFind.db.showMinimapButton = self:GetChecked() @@ -1029,19 +1102,29 @@ function Options:Initialize() end) optionsFrame.minimapBtnCheckbox = minimapBtnCheckbox - local indicatorLabel = sec3:CreateFontString(nil, "OVERLAY", "GameFontNormal") - indicatorLabel:SetPoint("TOPLEFT", loginMessageCheckbox, "BOTTOMLEFT", 4, -10) - indicatorLabel:SetText("Indicator Style:") + local SELECTOR_ROW_W = FRAME_W - 16 + local SELECTOR_BTN_W = 170 + local function CreateSelectorRow(anchor, labelText) + local row = CreateFrame("Frame", nil, sec3) + row:SetSize(SELECTOR_ROW_W, 24) + row:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, -8) + local label = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + label:SetPoint("LEFT", row, "LEFT", 8, 0) + label:SetPoint("RIGHT", row, "RIGHT", -SELECTOR_BTN_W - 18, 0) + label:SetJustifyH("LEFT") + label:SetText(labelText) + return row, label + end + + local indicatorRow, indicatorLabel = CreateSelectorRow(minimapBtnCheckbox, "Indicator Style") local indicatorChoices = {"EasyFind Arrow", "Classic Quest Arrow", "Minimap Player Arrow", "Low-res Gauntlet", "HD Gauntlet"} local indicatorBtnFrame, indicatorBtnText = CreateFlyoutSelector( - sec3, "EasyFindIndicator", 120, indicatorLabel, EasyFind.db.indicatorStyle or "EasyFind Arrow" + indicatorRow, "EasyFindIndicator", SELECTOR_BTN_W, indicatorLabel, EasyFind.db.indicatorStyle or "EasyFind Arrow" ) - indicatorBtnFrame:ClearAllPoints() - indicatorBtnFrame:SetPoint("LEFT", indicatorLabel, "LEFT", BTN_OFFSET, 0) - local indicatorFlyout = CreateFlyoutPanel(indicatorBtnFrame, "EasyFindIndicator", 120, #indicatorChoices) - AddFlyoutOptions(indicatorFlyout, indicatorChoices, 114, function(name) + local indicatorFlyout = CreateFlyoutPanel(indicatorBtnFrame, "EasyFindIndicator", SELECTOR_BTN_W, #indicatorChoices) + AddFlyoutOptions(indicatorFlyout, indicatorChoices, SELECTOR_BTN_W - 6, function(name) EasyFind.db.indicatorStyle = name indicatorBtnText:SetText(name) if ns.MapSearch then @@ -1051,56 +1134,52 @@ function Options:Initialize() optionsFrame.indicatorBtnText = indicatorBtnText optionsFrame.indicatorFlyout = indicatorFlyout - local colorLabel = sec3:CreateFontString(nil, "OVERLAY", "GameFontNormal") - colorLabel:SetPoint("TOPLEFT", indicatorLabel, "BOTTOMLEFT", 0, -20) - colorLabel:SetText("Indicator Color:") + local colorRow, colorLabel = CreateSelectorRow(indicatorRow, "Indicator Color") local colorChoices = {"Yellow", "Gold", "Orange", "Red", "Green", "Blue", "Purple", "White"} local colorRGB = ns.INDICATOR_COLORS local colorBtnFrame, colorBtnText = CreateFlyoutSelector( - sec3, "EasyFindColor", 120, colorLabel, EasyFind.db.indicatorColor or "Yellow" + colorRow, "EasyFindColor", SELECTOR_BTN_W, colorLabel, EasyFind.db.indicatorColor or "Yellow" ) - colorBtnFrame:ClearAllPoints() - colorBtnFrame:SetPoint("LEFT", colorLabel, "LEFT", BTN_OFFSET, 0) local currentColor = EasyFind.db.indicatorColor or "Yellow" local currentRGB = colorRGB[currentColor] or colorRGB.Yellow colorBtnText:SetTextColor(currentRGB[1], currentRGB[2], currentRGB[3]) - local colorSwatch = colorBtnFrame:CreateTexture(nil, "ARTWORK") - colorSwatch:SetSize(14, 14) - colorSwatch:SetPoint("LEFT", colorBtnFrame, "LEFT", 6, 0) - colorSwatch:SetColorTexture(currentRGB[1], currentRGB[2], currentRGB[3], 1) - - local colorFlyout = CreateFlyoutPanel(colorBtnFrame, "EasyFindColor", 120, #colorChoices) + local colorFlyout = CreateFlyoutPanel(colorBtnFrame, "EasyFindColor", SELECTOR_BTN_W, #colorChoices) for i, name in ipairs(colorChoices) do local rgb = colorRGB[name] local colorBtn = CreateFrame("Button", nil, colorFlyout) - colorBtn:SetSize(114, 18) + colorBtn:SetSize(SELECTOR_BTN_W - 6, 18) colorBtn:SetPoint("TOPLEFT", colorFlyout, "TOPLEFT", 3, -3 - (i - 1) * 20) - - local swatch = colorBtn:CreateTexture(nil, "ARTWORK") - swatch:SetSize(12, 12) - swatch:SetPoint("LEFT", 2, 0) - swatch:SetColorTexture(rgb[1], rgb[2], rgb[3], 1) + local rowBg = CreateFrame("Frame", nil, colorBtn) + rowBg:SetAllPoints() + rowBg:EnableMouse(false) + ns.CreateRoundedRectBorder(rowBg) + ns.SetRoundedRectBarHeight(rowBg, 9) + HideRoundedFrameBorder(rowBg) + PaintRoundedFill(rowBg, 1, 1, 1, 0) local label = colorBtn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") - label:SetPoint("LEFT", swatch, "RIGHT", 4, 0) + label:SetPoint("LEFT", colorBtn, "LEFT", 6, 0) + label:SetPoint("RIGHT", colorBtn, "RIGHT", -6, 0) + label:SetJustifyH("CENTER") label:SetText(name) label:SetTextColor(rgb[1], rgb[2], rgb[3]) colorBtn:SetScript("OnEnter", function(self) + PaintRoundedFill(rowBg, 1, 1, 1, 0.06) label:SetTextColor(1, 1, 1) end) colorBtn:SetScript("OnLeave", function(self) + PaintRoundedFill(rowBg, 1, 1, 1, 0) label:SetTextColor(rgb[1], rgb[2], rgb[3]) end) colorBtn:SetScript("OnClick", function() EasyFind.db.indicatorColor = name colorBtnText:SetText(name) colorBtnText:SetTextColor(rgb[1], rgb[2], rgb[3]) - colorSwatch:SetColorTexture(rgb[1], rgb[2], rgb[3], 1) colorFlyout:Hide() if ns.MapSearch then ns.MapSearch:RefreshIndicators() @@ -1109,21 +1188,16 @@ function Options:Initialize() end optionsFrame.colorBtnText = colorBtnText - optionsFrame.colorSwatch = colorSwatch optionsFrame.colorFlyout = colorFlyout - local fontLabel = sec3:CreateFontString(nil, "OVERLAY", "GameFontNormal") - fontLabel:SetPoint("TOPLEFT", colorLabel, "BOTTOMLEFT", 0, -20) - fontLabel:SetText("Font:") + local fontRow, fontLabel = CreateSelectorRow(colorRow, "Font") local fontChoices = ns.FONT_CHOICES or {"Default", "Inter"} local fontBtnFrame, fontBtnText = CreateFlyoutSelector( - sec3, "EasyFindFont", 120, fontLabel, EasyFind.db.font or "Default" + fontRow, "EasyFindFont", SELECTOR_BTN_W, fontLabel, EasyFind.db.font or "Default" ) - fontBtnFrame:ClearAllPoints() - fontBtnFrame:SetPoint("LEFT", fontLabel, "LEFT", BTN_OFFSET, 0) - local fontFlyout = CreateFlyoutPanel(fontBtnFrame, "EasyFindFont", 120, #fontChoices) - AddFlyoutOptions(fontFlyout, fontChoices, 114, function(name) + local fontFlyout = CreateFlyoutPanel(fontBtnFrame, "EasyFindFont", SELECTOR_BTN_W, #fontChoices) + AddFlyoutOptions(fontFlyout, fontChoices, SELECTOR_BTN_W - 6, function(name) EasyFind.db.font = name fontBtnText:SetText(name) if ns.RefreshAddonFont then ns.RefreshAddonFont() end @@ -1131,119 +1205,63 @@ function Options:Initialize() optionsFrame.fontBtnText = fontBtnText optionsFrame.fontFlyout = fontFlyout - local panelOpacitySlider = CreateSlider(sec3, "PanelOpacity", "Options Menu Opacity", 0.3, 1.0, 0.05, - "Adjusts the opacity of the options panel background.", nil, 0.9) - panelOpacitySlider:SetPoint("TOPLEFT", fontLabel, "BOTTOMLEFT", 0, -30) - panelOpacitySlider:SetValue(EasyFind.db.panelOpacity or 0.9) - panelOpacitySlider:HookScript("OnValueChanged", function(self, value) - EasyFind.db.panelOpacity = value - if optionsFrame.bgTex then - optionsFrame.bgTex:SetAlpha(value) - end - end) - optionsFrame.panelOpacitySlider = panelOpacitySlider - - local opacitySlider = CreateSlider(sec3, "Opacity", "Background Opacity", 0.0, 1.0, 0.05, - "Adjusts the background opacity of all search bars. Text and icons remain fully visible.", nil, DEFAULT_OPACITY) - opacitySlider:SetPoint("TOPLEFT", panelOpacitySlider, "BOTTOMLEFT", 0, -38) - opacitySlider:SetValue(EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) - opacitySlider:HookScript("OnValueChanged", function(self, value) - EasyFind.db.searchBarOpacity = value - if ns.UI and ns.UI.UpdateOpacity then - ns.UI:UpdateOpacity() - end - end) - optionsFrame.opacitySlider = opacitySlider - local RESET_BTN_W = 120 -- SECTION 2: UI Search local sec1 = CreateTab("UI") - local uiEnableCheckbox = CreateCheckbox(sec1, "EnableUI", "Enable UI Search Module", - "Uncheck to disable the main UI search bar.\n\nRequires a UI reload to take effect.") - uiEnableCheckbox:SetPoint("TOPLEFT", sec1, "TOPLEFT", COL_LEFT, -6) - uiEnableCheckbox:SetChecked(EasyFind.db.enableUISearch ~= false) - - local uiControls = {} - local function UpdateUIToggleVisual() - local enabled = EasyFind.db.enableUISearch ~= false - uiEnableCheckbox:SetChecked(enabled) - SetControlsEnabled(uiControls, enabled) - end - optionsFrame.UpdateUIToggleVisual = UpdateUIToggleVisual - - uiEnableCheckbox:SetScript("OnClick", function(self) - if self:GetChecked() then - EasyFind.db.enableUISearch = true - UpdateUIToggleVisual() - StaticPopup_Show("EASYFIND_RELOAD_PROMPT") - else - StaticPopup_Show("EASYFIND_DISABLE_UI_SEARCH") - self:SetChecked(true) - end - end) - - -- Gold separator under enable checkbox - local uiSep = sec1:CreateTexture(nil, "ARTWORK") - uiSep:SetPoint("TOPLEFT", sec1, "TOPLEFT", 6, -30) - uiSep:SetPoint("RIGHT", sec1, "RIGHT", -6, 0) - uiSep:SetHeight(1) - uiSep:SetColorTexture(0.8, 0.65, 0.0, 0.6) - - local resizeUIBtn = CreateFrame("Button", nil, sec1, "UIPanelButtonTemplate") + local resizeUIBtn = CreateModernButton(sec1) resizeUIBtn:SetSize(160, 22) resizeUIBtn:SetPoint("BOTTOMLEFT", sec1, "BOTTOMLEFT", 8, 32) resizeUIBtn:SetText("Resize UI Search") resizeUIBtn:SetScript("OnClick", function() if ns.Rescaler then ns.Rescaler:Enter("ui") end end) - resizeUIBtn:SetScript("OnEnter", function(self) + resizeUIBtn:HookScript("OnEnter", function(self) GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - GameTooltip:SetText("Visually resize the UI search bar and its results dropdown.\nDrag edges for width, corners for scale.") + GameTooltip:SetText("Visually resize the unified UI search window.\nDrag side edges for width and the top/bottom edge for results height.") GameTooltip:Show() end) - resizeUIBtn:SetScript("OnLeave", GameTooltip_Hide) + resizeUIBtn:HookScript("OnLeave", GameTooltip_Hide) local autoHideCheckbox = CreateCheckbox(sec1, "AutoHide", "Auto-Hide |cFF888888(Recommended)|r", - "Raycast-style: the search bar starts hidden and only appears when you press the keybind. Clicking outside the bar, results, or filter menu hides it again, as does selecting a result.\n\nWhen enabled, Smart Show is ignored.") - autoHideCheckbox:SetPoint("TOPLEFT", sec1, "TOPLEFT", 16, -38) - autoHideCheckbox:SetChecked(EasyFind.db.autoHide ~= false) - autoHideCheckbox:SetScript("OnClick", function(self) - local on = self:GetChecked() - EasyFind.db.autoHide = on - if on then - if ns.UI and ns.UI.Hide then ns.UI:Hide() end - else - if ns.UI and ns.UI.Show then ns.UI:Show(false) end - end - if optionsFrame.smartShowCheckbox then - optionsFrame.smartShowCheckbox:SetEnabled(not on) - local lbl = optionsFrame.smartShowCheckbox:GetFontString() - if lbl then lbl:SetTextColor(on and 0.5 or 1, on and 0.5 or 1, on and 0.5 or 1) end - end - end) + "Raycast-style: the search bar starts hidden and only appears when you press the keybind. Clicking outside the bar, results, or filter menu hides it again, as does selecting a result.") + autoHideCheckbox:SetPoint("TOPLEFT", sec1, "TOPLEFT", 16, -8) optionsFrame.autoHideCheckbox = autoHideCheckbox local smartShowCheckbox = CreateCheckbox(sec1, "SmartShow", "Smart Show", - "When enabled, the UI search bar hides itself until you move your mouse near its position.\n\nThe bar reappears when your mouse enters the area and fades away when you move away.\n\nIgnored when Auto-Hide is enabled.") + "The UI search bar hides itself until you move your mouse near its position.\n\nThe bar reappears when your mouse enters the area and fades away when you move away.") smartShowCheckbox:SetPoint("TOPLEFT", autoHideCheckbox, "BOTTOMLEFT", 0, -2) - smartShowCheckbox:SetChecked(EasyFind.db.smartShow or false) - smartShowCheckbox:SetScript("OnClick", function(self) - EasyFind.db.smartShow = self:GetChecked() - if ns.UI and ns.UI.UpdateSmartShow then - ns.UI:UpdateSmartShow() + optionsFrame.smartShowCheckbox = smartShowCheckbox + + local function SetVisibilityMode(mode) + local smart = mode == "smart" + EasyFind.db.smartShow = smart + EasyFind.db.autoHide = not smart + autoHideCheckbox:SetChecked(not smart) + smartShowCheckbox:SetChecked(smart) + if smart then + if ns.UI and ns.UI.Show then ns.UI:Show(false) end + if ns.UI and ns.UI.UpdateSmartShow then ns.UI:UpdateSmartShow() end + else + if ns.UI and ns.UI.UpdateSmartShow then ns.UI:UpdateSmartShow() end + if ns.UI and ns.UI.Hide then ns.UI:Hide() end end - end) - if EasyFind.db.autoHide ~= false then - smartShowCheckbox:SetEnabled(false) - local lbl = smartShowCheckbox:GetFontString() - if lbl then lbl:SetTextColor(0.5, 0.5, 0.5) end end - optionsFrame.smartShowCheckbox = smartShowCheckbox + autoHideCheckbox:SetScript("OnClick", function() + SetVisibilityMode("auto") + end) + smartShowCheckbox:SetScript("OnClick", function() + SetVisibilityMode("smart") + end) + local smartInitial = EasyFind.db.smartShow and EasyFind.db.autoHide == false + EasyFind.db.smartShow = smartInitial + EasyFind.db.autoHide = not smartInitial + autoHideCheckbox:SetChecked(not smartInitial) + smartShowCheckbox:SetChecked(smartInitial) local staticOpacityCheckbox = CreateCheckbox(sec1, "StaticOpacity", "Static Opacity", - "When enabled, the search bar keeps the same opacity at all times.\n\nWhen disabled (default), opacity is reduced while your character is moving so you can see the game world better, similar to how the World Map behaves.\n\nThis only applies to the main search bar. Map search bars follow the World Map's built-in fade behavior.") + "When enabled, the UI search bar keeps the same opacity at all times.\n\nWhen disabled (default), opacity is reduced while your character is moving so you can see the game world better, similar to how the World Map behaves.") staticOpacityCheckbox:SetPoint("TOPLEFT", smartShowCheckbox, "BOTTOMLEFT", 0, -2) staticOpacityCheckbox:SetChecked(EasyFind.db.staticOpacity or false) staticOpacityCheckbox:SetScript("OnClick", function(self) @@ -1269,6 +1287,7 @@ function Options:Initialize() uiResultsAboveCheckbox:SetChecked(EasyFind.db.uiResultsAbove or false) uiResultsAboveCheckbox:SetScript("OnClick", function(self) EasyFind.db.uiResultsAbove = self:GetChecked() + if ns.UI and ns.UI.RefreshResults then ns.UI:RefreshResults() end end) optionsFrame.uiResultsAboveCheckbox = uiResultsAboveCheckbox @@ -1284,19 +1303,52 @@ function Options:Initialize() end) optionsFrame.resultShortcutHintsCheckbox = resultShortcutHintsCheckbox - local uiFontSlider = CreateSlider(sec1, "UIFontSize", "Font Size|cffff3333*|r", 0.5, 2.0, 0.1, - "Changing font size also affects search bar height and results window sizing.", nil, 1.0) - uiFontSlider:SetPoint("TOPLEFT", resultShortcutHintsCheckbox, "BOTTOMLEFT", 4, -20) - uiFontSlider:SetValue(EasyFind.db.fontSize or 1.0) - uiFontSlider:HookScript("OnValueChanged", function(self, value) - EasyFind.db.fontSize = value - if ns.UI and ns.UI.UpdateFontSize then - ns.UI:UpdateFontSize() + local opacityChoices = { + { label = "Light", value = 0.45 }, + { label = "Balanced", value = DEFAULT_OPACITY }, + { label = "Solid", value = 0.95 }, + } + local opacityPresetRow = CreatePresetRow(sec1, "Background Opacity", opacityChoices, + function() return EasyFind.db.searchBarOpacity or DEFAULT_OPACITY end, + function(value) + EasyFind.db.searchBarOpacity = value + if ns.UI and ns.UI.UpdateOpacity then + ns.UI:UpdateOpacity() + end + end, + "Controls the UI search window background density. Text and icons remain fully visible.") + opacityPresetRow:SetPoint("TOPLEFT", resultShortcutHintsCheckbox, "BOTTOMLEFT", 0, -8) + optionsFrame.opacityPresetRow = opacityPresetRow + + local fontSizeChoices = { + { label = "Small", value = 0.80 }, + { label = "Med", value = 0.90 }, + { label = "Large", value = 1.15 }, + { label = "XL", value = 1.35 }, + } + local uiFontPresetRow = CreatePresetRow(sec1, "Font Size", fontSizeChoices, + function() return EasyFind.db.fontSize or 0.9 end, + function(value) + EasyFind.db.fontSize = value + if ns.UI and ns.UI.UpdateFontSize then + ns.UI:UpdateFontSize() + end + end, + "Adjusts UI search text size without resizing the search window.") + uiFontPresetRow:SetPoint("TOPLEFT", opacityPresetRow, "BOTTOMLEFT", 0, -2) + optionsFrame.uiFontPresetRow = uiFontPresetRow + + local function RefreshUIPresetRows() + if optionsFrame.opacityPresetRow then + optionsFrame.opacityPresetRow:SetValue(EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) end - end) - optionsFrame.uiFontSlider = uiFontSlider + if optionsFrame.uiFontPresetRow then + optionsFrame.uiFontPresetRow:SetValue(EasyFind.db.fontSize or 0.9) + end + end + optionsFrame.RefreshUIPresetRows = RefreshUIPresetRows - local resetUIBtn = CreateFrame("Button", nil, sec1, "UIPanelButtonTemplate") + local resetUIBtn = CreateModernButton(sec1) resetUIBtn:SetSize(RESET_BTN_W, 20) resetUIBtn:SetPoint("BOTTOMLEFT", sec1, "BOTTOMLEFT", 8, 8) resetUIBtn:SetText("Reset Settings") @@ -1304,7 +1356,7 @@ function Options:Initialize() StaticPopup_Show("EASYFIND_RESET_UI") end) - local resetUIPosBtn = CreateFrame("Button", nil, sec1, "UIPanelButtonTemplate") + local resetUIPosBtn = CreateModernButton(sec1) resetUIPosBtn:SetSize(RESET_BTN_W, 20) resetUIPosBtn:SetPoint("LEFT", resetUIBtn, "RIGHT", 8, 0) resetUIPosBtn:SetText("Reset Positions") @@ -1312,14 +1364,12 @@ function Options:Initialize() StaticPopup_Show("EASYFIND_RESET_UI_POS") end) - uiControls = { resizeUIBtn, resetUIBtn, resetUIPosBtn, uiFontSlider, autoHideCheckbox, smartShowCheckbox, staticOpacityCheckbox, lockPositionCheckbox, uiResultsAboveCheckbox, resultShortcutHintsCheckbox } - UpdateUIToggleVisual() - -- SECTION 3: Map Search local sec2 = CreateTab("Map") local mapEnableCheckbox = CreateCheckbox(sec2, "EnableMap", "Enable Map Search Module", - "Uncheck to disable map search, pins, and map overlay features.\n\nRequires a UI reload to take effect.") + "Uncheck to disable map search, pins, map overlay features, and the EasyFind tab on the world map.\n\nRequires a UI reload to take effect.", + false, 222) mapEnableCheckbox:SetPoint("TOPLEFT", sec2, "TOPLEFT", COL_LEFT, -6) mapEnableCheckbox:SetChecked(EasyFind.db.enableMapSearch ~= false) @@ -1328,6 +1378,7 @@ function Options:Initialize() local enabled = EasyFind.db.enableMapSearch ~= false mapEnableCheckbox:SetChecked(enabled) SetControlsEnabled(mapControls, enabled) + if optionsFrame.UpdateRecentCountEnabled then optionsFrame.UpdateRecentCountEnabled() end end optionsFrame.UpdateMapToggleVisual = UpdateMapToggleVisual @@ -1344,14 +1395,121 @@ function Options:Initialize() -- Gold separator under enable checkbox local mapSep = sec2:CreateTexture(nil, "ARTWORK") - mapSep:SetPoint("TOPLEFT", sec2, "TOPLEFT", 6, -30) + mapSep:SetPoint("TOPLEFT", sec2, "TOPLEFT", 6, -40) mapSep:SetPoint("RIGHT", sec2, "RIGHT", -6, 0) mapSep:SetHeight(1) mapSep:SetColorTexture(0.8, 0.65, 0.0, 0.6) - local rareTrackCheckbox = CreateCheckbox(sec2, "RareTrack", "Auto-track Rares", - "When enabled, active rare mobs are always displayed as pins on the world map without needing to search.\n\nThis can also be toggled from the filter dropdown on the zone search bar.") - rareTrackCheckbox:SetPoint("TOPLEFT", sec2, "TOPLEFT", 16, -38) + local GROUP_W = FRAME_W - 16 + local ROW_H = 28 + + local mapTabLabel = sec2:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + mapTabLabel:SetPoint("TOPLEFT", sec2, "TOPLEFT", 8, -48) + mapTabLabel:SetText("Map Tab") + + local mapTabSettings = CreateSettingsGroup(sec2, GROUP_W, ROW_H * 2 + 8) + mapTabSettings:SetPoint("TOPLEFT", mapTabLabel, "BOTTOMLEFT", 0, -3) + optionsFrame.mapTabSettings = mapTabSettings + + local mapTabShowRecentCheckbox = CreateCheckbox(mapTabSettings, "MapTabShowRecent", "Show Recent Searches", + "Show your recent search queries in the Map Search tab when the search box is empty.", false, GROUP_W) + mapTabShowRecentCheckbox:SetPoint("TOPLEFT", mapTabSettings, "TOPLEFT", 0, -4) + mapTabShowRecentCheckbox:SetChecked(EasyFind.db.mapTabShowRecent ~= false) + optionsFrame.mapTabShowRecentCheckbox = mapTabShowRecentCheckbox + + local recentCountStepper = CreateStepperRow(mapTabSettings, "Recent Count", 1, 10, + function() return EasyFind.db.mapTabRecentCount or 3 end, + function(value) + EasyFind.db.mapTabRecentCount = value + local list = EasyFind.db.mapTabRecentSearches + if list then + while #list > value do table.remove(list) end + end + if ns.MapTab and ns.MapTab.RefreshIfOpen then ns.MapTab:RefreshIfOpen() end + end, + "How many recent searches to keep and display in the Map Search tab.", GROUP_W) + recentCountStepper:SetPoint("TOPLEFT", mapTabShowRecentCheckbox, "BOTTOMLEFT", 0, 0) + optionsFrame.recentCountStepper = recentCountStepper + + local function UpdateRecentCountEnabled() + recentCountStepper:SetGroupEnabled(EasyFind.db.enableMapSearch ~= false and EasyFind.db.mapTabShowRecent ~= false) + end + optionsFrame.UpdateRecentCountEnabled = UpdateRecentCountEnabled + mapTabShowRecentCheckbox:SetScript("OnClick", function(self) + EasyFind.db.mapTabShowRecent = self:GetChecked() + UpdateRecentCountEnabled() + if ns.MapTab and ns.MapTab.RefreshIfOpen then ns.MapTab:RefreshIfOpen() end + end) + UpdateRecentCountEnabled() + + mapTabSettings:AddControl(mapTabShowRecentCheckbox) + mapTabSettings:AddControl(recentCountStepper) + + local mapIconsLabel = sec2:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + mapIconsLabel:SetPoint("TOPLEFT", mapTabSettings, "BOTTOMLEFT", 0, -6) + mapIconsLabel:SetText("EF Map Icons") + + local mapIconSettings = CreateSettingsGroup(sec2, GROUP_W, ROW_H * 3 + 8) + mapIconSettings:SetPoint("TOPLEFT", mapIconsLabel, "BOTTOMLEFT", 0, -3) + optionsFrame.mapIconSettings = mapIconSettings + + local mapPinHighlightCheckbox = CreateCheckbox(mapIconSettings, "MapPinHighlight", "Highlight Box", + "A yellow highlight box is drawn around map search pins. Disable to show only the pin icon and indicator arrow.", false, GROUP_W) + mapPinHighlightCheckbox:SetPoint("TOPLEFT", mapIconSettings, "TOPLEFT", 0, -4) + mapPinHighlightCheckbox:SetChecked(EasyFind.db.mapPinHighlight ~= false) + mapPinHighlightCheckbox:SetScript("OnClick", function(self) + EasyFind.db.mapPinHighlight = self:GetChecked() + if ns.MapSearch and ns.MapSearch.UpdatePinHighlight then ns.MapSearch:UpdatePinHighlight() end + end) + optionsFrame.mapPinHighlightCheckbox = mapPinHighlightCheckbox + + local blinkingPinsCheckbox = CreateCheckbox(mapIconSettings, "BlinkingPins", "Blinking", + "Map search pins and highlight boxes pulse in sync with the indicator arrow. The indicator arrow always bobs.", false, GROUP_W) + blinkingPinsCheckbox:SetPoint("TOPLEFT", mapPinHighlightCheckbox, "BOTTOMLEFT", 0, 0) + blinkingPinsCheckbox:SetChecked(EasyFind.db.blinkingPins or false) + blinkingPinsCheckbox:SetScript("OnClick", function(self) + EasyFind.db.blinkingPins = self:GetChecked() + if ns.MapSearch and ns.MapSearch.UpdateBlinkingPins then ns.MapSearch:UpdateBlinkingPins() end + end) + optionsFrame.blinkingPinsCheckbox = blinkingPinsCheckbox + + local iconSizeChoices = { + { label = "Small", value = 0.65 }, + { label = "Med", value = 0.80 }, + { label = "Large", value = 1.05 }, + { label = "XL", value = 1.30 }, + } + local mapIconPresetRow = CreatePresetRow(mapIconSettings, "Icon Size", iconSizeChoices, + function() return EasyFind.db.iconScale or 0.8 end, + function(value) + EasyFind.db.iconScale = value + if ns.MapSearch and ns.MapSearch.UpdateIconScales then + ns.MapSearch:UpdateIconScales() + end + local uiInd = _G["EasyFindIndicatorFrame"] + if uiInd then + uiInd:SetScale(EasyFind.db.iconScale or 0.8) + end + end, + "Adjusts the size of map search result icons on the world map.", GROUP_W) + mapIconPresetRow:SetPoint("TOPLEFT", blinkingPinsCheckbox, "BOTTOMLEFT", 0, 0) + optionsFrame.mapIconPresetRow = mapIconPresetRow + + mapIconSettings:AddControl(mapPinHighlightCheckbox) + mapIconSettings:AddControl(blinkingPinsCheckbox) + mapIconSettings:AddControl(mapIconPresetRow) + + local mapPinsLabel = sec2:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + mapPinsLabel:SetPoint("TOPLEFT", mapIconSettings, "BOTTOMLEFT", 0, -6) + mapPinsLabel:SetText("Map Pins") + + local mapPinSettings = CreateSettingsGroup(sec2, GROUP_W, ROW_H * 3 + 8) + mapPinSettings:SetPoint("TOPLEFT", mapPinsLabel, "BOTTOMLEFT", 0, -3) + optionsFrame.mapPinSettings = mapPinSettings + + local rareTrackCheckbox = CreateCheckbox(mapPinSettings, "RareTrack", "Auto-track Rares", + "When enabled, active rare mobs are always displayed as pins on the world map without needing to search.", false, GROUP_W) + rareTrackCheckbox:SetPoint("TOPLEFT", mapPinSettings, "TOPLEFT", 0, -4) rareTrackCheckbox:SetChecked(EasyFind.db.alwaysShowRares or false) rareTrackCheckbox:SetScript("OnClick", function(self) EasyFind.db.alwaysShowRares = self:GetChecked() @@ -1364,90 +1522,38 @@ function Options:Initialize() end) optionsFrame.rareTrackCheckbox = rareTrackCheckbox - local FLYOUT_W = 260 - - local mapTabGroup = CreateMultiSelectDropdown(sec2, "Map Tab", { - { label = "Show Recent Searches", shortLabel = "Recent", dbKey = "mapTabShowRecent", default = true, - tooltip = "Show your recent search queries in the Map Search tab when the search box is empty.\nClick any recent entry to rerun that search.", - callback = function() if ns.MapTab and ns.MapTab.RefreshIfOpen then ns.MapTab:RefreshIfOpen() end end }, - }, nil, FLYOUT_W) - mapTabGroup:SetPoint("TOPLEFT", rareTrackCheckbox, "BOTTOMLEFT", 0, -6) - mapTabGroup:SetPoint("RIGHT", sec2, "RIGHT", -8, 0) - mapTabGroup.label:SetWidth(85) - mapTabGroup.label:SetJustifyH("LEFT") - mapTabGroup.button:SetPoint("LEFT", mapTabGroup.label, "RIGHT", 6, 0) - optionsFrame.mapTabGroup = mapTabGroup - - local recentCountSlider = mapTabGroup:AddSlider("RecentCount", "Recent Count", 1, 10, 1, - "How many recent searches to keep and display in the Map Search tab.", - function(val) return tostring(mfloor(val + 0.5)) end, 3, "", "mapTabShowRecent") - recentCountSlider:SetValue(EasyFind.db.mapTabRecentCount or 3) - recentCountSlider:HookScript("OnValueChanged", function(_, value) - value = mfloor(value + 0.5) - EasyFind.db.mapTabRecentCount = value - -- Trim the stored list immediately if user shrank the cap. - local list = EasyFind.db.mapTabRecentSearches - if list then - while #list > value do table.remove(list) end - end - if ns.MapTab and ns.MapTab.RefreshIfOpen then ns.MapTab:RefreshIfOpen() end + local autoTrackPinsCheckbox = CreateCheckbox(mapPinSettings, "AutoTrackPins", "Auto Track Map Pins", + "Placing a map pin (Ctrl+Click) automatically starts Blizzard waypoint tracking.", false, GROUP_W) + autoTrackPinsCheckbox:SetPoint("TOPLEFT", rareTrackCheckbox, "BOTTOMLEFT", 0, 0) + autoTrackPinsCheckbox:SetChecked(EasyFind.db.autoTrackPins ~= false) + autoTrackPinsCheckbox:SetScript("OnClick", function(self) + EasyFind.db.autoTrackPins = self:GetChecked() end) - optionsFrame.recentCountSlider = recentCountSlider - - local mapPinGroup = CreateMultiSelectDropdown(sec2, "EF Map Icons", { - { label = "Highlight Box", shortLabel = "Highlight", dbKey = "mapPinHighlight", default = true, - tooltip = "A yellow highlight box is drawn around map search pins.\nDisable to show only the pin icon and indicator arrow.", - callback = function() if ns.MapSearch and ns.MapSearch.UpdatePinHighlight then ns.MapSearch:UpdatePinHighlight() end end }, - { label = "Blinking", shortLabel = "Blink", dbKey = "blinkingPins", default = false, - tooltip = "Map search pins and highlight boxes pulse in sync with the indicator arrow.\nWhen disabled, pins and highlights are steady. The indicator arrow always bobs.", - callback = function() if ns.MapSearch and ns.MapSearch.UpdateBlinkingPins then ns.MapSearch:UpdateBlinkingPins() end end }, - }, nil, FLYOUT_W) - mapPinGroup:SetPoint("TOPLEFT", mapTabGroup, "BOTTOMLEFT", 0, -6) - mapPinGroup:SetPoint("RIGHT", sec2, "RIGHT", -8, 0) - mapPinGroup.label:SetWidth(85) - mapPinGroup.label:SetJustifyH("LEFT") - mapPinGroup.button:SetPoint("LEFT", mapPinGroup.label, "RIGHT", 6, 0) - optionsFrame.mapPinGroup = mapPinGroup - - local mapIconSlider = mapPinGroup:AddSlider("MapIcon", "Icon Size", 0.5, 2.0, 0.1, - "Adjusts the size of map search result icons on the world map.", nil, 0.8) - mapIconSlider:SetValue(EasyFind.db.iconScale or 0.8) - mapIconSlider:HookScript("OnValueChanged", function(self, value) - EasyFind.db.iconScale = value - if ns.MapSearch and ns.MapSearch.UpdateIconScales then - ns.MapSearch:UpdateIconScales() - end - local uiInd = _G["EasyFindIndicatorFrame"] - if uiInd then - uiInd:SetScale(EasyFind.db.iconScale or 0.8) - end + optionsFrame.autoTrackPinsCheckbox = autoTrackPinsCheckbox + + local autoPinClearCheckbox = CreateCheckbox(mapPinSettings, "AutoPinClear", "Auto Clear Map Pins", + "Your map pin is automatically cleared when Blizzard reports arrival. Disable if you prefer to clear pins manually.", false, GROUP_W) + autoPinClearCheckbox:SetPoint("TOPLEFT", autoTrackPinsCheckbox, "BOTTOMLEFT", 0, 0) + autoPinClearCheckbox:SetChecked(EasyFind.db.autoPinClear ~= false) + autoPinClearCheckbox:SetScript("OnClick", function(self) + EasyFind.db.autoPinClear = self:GetChecked() end) - optionsFrame.mapIconSlider = mapIconSlider - - local automationGroup = CreateMultiSelectDropdown(sec2, "Map Pins", { - { label = "Auto Track Map Pins", shortLabel = "Track", dbKey = "autoTrackPins", default = true, - tooltip = "Placing a map pin (Ctrl+Click) automatically starts Blizzard waypoint tracking.\nWhen disabled, you must click the pin to start tracking." }, - { label = "Auto Clear Map Pins", shortLabel = "Clear", dbKey = "autoPinClear", default = true, - tooltip = "Your map pin is automatically cleared when Blizzard reports arrival.\nDisable if you prefer to clear pins manually." }, - }, nil, FLYOUT_W) - automationGroup:SetPoint("TOPLEFT", mapPinGroup, "BOTTOMLEFT", 0, -6) - automationGroup:SetPoint("RIGHT", sec2, "RIGHT", -8, 0) - automationGroup.label:SetWidth(85) - automationGroup.label:SetJustifyH("LEFT") - automationGroup.button:SetPoint("LEFT", automationGroup.label, "RIGHT", 6, 0) - optionsFrame.automationGroup = automationGroup - - local resetMapBtn = CreateFrame("Button", nil, sec2, "UIPanelButtonTemplate") + optionsFrame.autoPinClearCheckbox = autoPinClearCheckbox + + mapPinSettings:AddControl(rareTrackCheckbox) + mapPinSettings:AddControl(autoTrackPinsCheckbox) + mapPinSettings:AddControl(autoPinClearCheckbox) + + local resetMapBtn = CreateModernButton(sec2) resetMapBtn:SetSize(RESET_BTN_W, 20) - resetMapBtn:SetPoint("BOTTOMLEFT", sec2, "BOTTOMLEFT", 8, 8) + resetMapBtn:SetPoint("TOPRIGHT", sec2, "TOPRIGHT", -8, -8) resetMapBtn:SetText("Reset Settings") resetMapBtn:SetScript("OnClick", function() StaticPopup_Show("EASYFIND_RESET_MAP") end) mapControls = { - resetMapBtn, rareTrackCheckbox, - mapPinGroup, automationGroup + resetMapBtn, mapTabSettings, mapIconSettings, mapPinSettings } UpdateMapToggleVisual() @@ -1471,12 +1577,13 @@ function Options:Initialize() .. "|cFF00FF00PgUp/PgDn|r jump 5 |cFF00FF00Home/End|r first/last\n" .. "|cFF00FF00Shift+Up/Down|r or |cFF00FF00Ctrl+Shift+K/J|r jump section\n\n" .. "|cFFFFD100Other:|r\n" - .. "|cFF00FF00Shift+Drag|r reposition |cFF00FF00Right-click|r pin/unpin\n" - .. "|cFF00FF00/ef toggle|r toggle the search bar\n" + .. "|cFF00FF00Shift+Drag|r reposition |cFF00FF00Right-click|r pin/unpin\n\n" + .. "|cFFFFD100Slash commands:|r\n" + .. "|cFF00FF00/ef|r open options |cFF00FF00/ef c|r clear highlights and pins\n" ) -- Keybind buttons - local KEYBIND_ROW_H = 18 - local KEYBIND_BTN_W = 80 + local KEYBIND_ROW_H = 24 + local KEYBIND_BTN_W = 116 local keybindDefs = { { label = "Toggle Search Bar", action = "EASYFIND_TOGGLE_FOCUS" }, @@ -1495,7 +1602,7 @@ function Options:Initialize() -- "Toggle Search Bar:" exceed the small per-column allowance and -- bleed into the button area of the same row. One column with a -- wide label slot keeps every label clear of its button. - local KEYBIND_LABEL_W = 130 + local KEYBIND_LABEL_W = 168 for i, def in ipairs(keybindDefs) do local row = i - 1 @@ -1503,10 +1610,10 @@ function Options:Initialize() rowLabel:SetPoint("TOPLEFT", shortcutText, "BOTTOMLEFT", 0, -12 - row * KEYBIND_ROW_H) rowLabel:SetText(def.label .. ":") - local keybindBtn = CreateFrame("Button", nil, sec4, "UIPanelButtonTemplate") + local keybindBtn = CreateModernButton(sec4) keybindBtn:SetNormalFontObject("GameFontHighlightSmall") keybindBtn:SetHighlightFontObject("GameFontHighlightSmall") - keybindBtn:SetSize(KEYBIND_BTN_W, 18) + keybindBtn:SetSize(KEYBIND_BTN_W, 20) keybindBtn:SetPoint("LEFT", rowLabel, "LEFT", KEYBIND_LABEL_W, 0) keybindBtn:SetText(GetCurrentKeybindText(def.action)) keybindBtn:SetScript("OnClick", function(self, button) @@ -1534,67 +1641,211 @@ function Options:Initialize() optionsFrame.mapFocusBtn = keybindButtons["EASYFIND_MAP_FOCUS"] optionsFrame.clearBtn = keybindButtons["EASYFIND_CLEAR"] - -- Aliases panel: scrollable list with one row per saved alias. - -- Each row shows "alias -> entry name" and a small X button to - -- remove that alias. Entries that no longer resolve (mount - -- relearned with a different ID, etc.) still show so the user can - -- prune them. - local aliasHeader = sec4:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - aliasHeader:SetPoint("TOPLEFT", shortcutText, "BOTTOMLEFT", 0, -12 - #keybindDefs * KEYBIND_ROW_H - 18) - aliasHeader:SetText("|cFFFFD100Saved Aliases:|r right-click any search result and choose Add Alias") + -- SECTION 5: Aliases + local aliasesTab = CreateTab("Aliases") + + local aliasTitle = aliasesTab:CreateFontString(nil, "OVERLAY", "GameFontNormal") + aliasTitle:SetPoint("TOPLEFT", aliasesTab, "TOPLEFT", 8, -8) + aliasTitle:SetText("Saved Aliases") + + local aliasHeader = aliasesTab:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + aliasHeader:SetPoint("TOPLEFT", aliasTitle, "BOTTOMLEFT", 0, -6) + aliasHeader:SetPoint("RIGHT", aliasesTab, "RIGHT", -10, 0) + aliasHeader:SetJustifyH("LEFT") + aliasHeader:SetText("Right-click any search result and choose Add Alias.") + + local RefreshAliasList + local aliasTools = CreateFrame("Frame", nil, aliasesTab) + aliasTools:SetPoint("TOPLEFT", aliasHeader, "BOTTOMLEFT", 0, -8) + aliasTools:SetPoint("RIGHT", aliasesTab, "RIGHT", -8, 0) + aliasTools:SetHeight(24) + + local clearAliasesBtn = CreateModernButton(aliasTools, "Clear All", 78, 22) + clearAliasesBtn:SetPoint("RIGHT", aliasTools, "RIGHT", 0, 0) + + local aliasSearchShell = CreateFrame("Frame", nil, aliasTools) + aliasSearchShell:SetPoint("LEFT", aliasTools, "LEFT", 0, 0) + aliasSearchShell:SetPoint("RIGHT", clearAliasesBtn, "LEFT", -8, 0) + aliasSearchShell:SetHeight(22) + ns.CreateRoundedRectBorder(aliasSearchShell) + ns.SetRoundedRectBarHeight(aliasSearchShell, 10) + HideRoundedFrameBorder(aliasSearchShell) + PaintRoundedFill(aliasSearchShell, 0.075, 0.075, 0.085, 1) + + local aliasSearchIcon = aliasSearchShell:CreateTexture(nil, "OVERLAY") + aliasSearchIcon:SetSize(13, 13) + aliasSearchIcon:SetPoint("LEFT", aliasSearchShell, "LEFT", 7, 0) + if aliasSearchIcon.SetAtlas then + aliasSearchIcon:SetAtlas("common-search-magnifyingglass") + else + aliasSearchIcon:SetTexture("Interface\\Common\\UI-Searchbox-Icon") + end + aliasSearchIcon:SetAlpha(0.65) + + local aliasSearchBox = CreateFrame("EditBox", nil, aliasSearchShell) + aliasSearchBox:SetPoint("LEFT", aliasSearchIcon, "RIGHT", 6, 0) + aliasSearchBox:SetPoint("RIGHT", aliasSearchShell, "RIGHT", -8, 0) + aliasSearchBox:SetHeight(18) + aliasSearchBox:SetAutoFocus(false) + aliasSearchBox:SetFontObject("GameFontHighlightSmall") + aliasSearchBox:SetMaxLetters(64) + + local aliasSearchPlaceholder = aliasSearchBox:CreateFontString(nil, "ARTWORK", "GameFontDisableSmall") + aliasSearchPlaceholder:SetPoint("LEFT", aliasSearchBox, "LEFT", 0, 0) + aliasSearchPlaceholder:SetText("Search aliases") + aliasSearchPlaceholder:SetTextColor(0.55, 0.55, 0.58, 1) + aliasSearchBox.placeholder = aliasSearchPlaceholder + aliasSearchBox:SetScript("OnTextChanged", function(self) + aliasSearchPlaceholder:SetShown((self:GetText() or "") == "") + if RefreshAliasList then RefreshAliasList() end + end) + aliasSearchBox:SetScript("OnEscapePressed", function(self) + if (self:GetText() or "") ~= "" then + self:SetText("") + else + self:ClearFocus() + end + end) + + local aliasList = CreateFrame("Frame", nil, aliasesTab) + aliasList:SetPoint("TOPLEFT", aliasTools, "BOTTOMLEFT", 0, -8) + aliasList:SetPoint("BOTTOMRIGHT", aliasesTab, "BOTTOMRIGHT", -8, 8) + ns.CreateRoundedRectBorder(aliasList) + ns.SetRoundedRectBarHeight(aliasList, 8) + HideRoundedFrameBorder(aliasList) + PaintRoundedFill(aliasList, 0.075, 0.075, 0.085, 0.92) - local aliasScroll = CreateFrame("ScrollFrame", nil, sec4, "UIPanelScrollFrameTemplate") - aliasScroll:SetPoint("TOPLEFT", aliasHeader, "BOTTOMLEFT", 0, -6) - aliasScroll:SetSize(FRAME_W - 80, 110) + local aliasScroll = CreateFrame("ScrollFrame", nil, aliasList) + aliasScroll:SetPoint("TOPLEFT", aliasList, "TOPLEFT", 6, -6) + aliasScroll:SetPoint("BOTTOMRIGHT", aliasList, "BOTTOMRIGHT", -10, 6) local aliasContent = CreateFrame("Frame", nil, aliasScroll) - aliasContent:SetSize(FRAME_W - 100, 1) + aliasContent:SetSize(FRAME_W - 42, 1) aliasScroll:SetScrollChild(aliasContent) + local aliasScrollBar = ns.Utils and ns.Utils.CreateMinimalScrollBar and ns.Utils.CreateMinimalScrollBar(aliasScroll, aliasList) + local aliasEmpty = aliasContent:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + aliasEmpty:SetPoint("TOPLEFT", aliasContent, "TOPLEFT", 8, -8) + aliasEmpty:SetText("No saved aliases.") + local aliasRowPool = {} local function ReleaseAliasRows() for i = 1, #aliasRowPool do aliasRowPool[i]:Hide() end end + local function UpdateAliasScrollBar() + if not aliasScrollBar then return end + local contentH = aliasContent:GetHeight() or 0 + local viewH = aliasScroll:GetHeight() or 0 + if contentH > viewH + 1 then + aliasScrollBar:Show() + aliasScrollBar:UpdateThumb(contentH, viewH) + else + aliasScroll:SetVerticalScroll(0) + aliasScrollBar:Hide() + end + end local function AcquireAliasRow(idx) local row = aliasRowPool[idx] if row then row:Show(); return row end row = CreateFrame("Frame", nil, aliasContent) - row:SetSize(FRAME_W - 100, 18) + row:SetSize(FRAME_W - 42, 26) + row:EnableMouse(true) + row.bg = CreateFrame("Frame", nil, row) + row.bg:SetAllPoints() + row.bg:EnableMouse(false) + ns.CreateRoundedRectBorder(row.bg) + ns.SetRoundedRectBarHeight(row.bg, 8) + HideRoundedFrameBorder(row.bg) + PaintRoundedFill(row.bg, 1, 1, 1, 0) row.text = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") - row.text:SetPoint("LEFT", row, "LEFT", 4, 0) + row.text:SetPoint("LEFT", row, "LEFT", 8, 0) row.text:SetJustifyH("LEFT") - row.removeBtn = CreateFrame("Button", nil, row, "UIPanelCloseButton") - row.removeBtn:SetSize(20, 20) - row.removeBtn:SetPoint("RIGHT", row, "RIGHT", 0, 0) + row.removeBtn = CreateModernButton(row, "x", 20, 18) + row.removeBtn:SetPoint("RIGHT", row, "RIGHT", -4, 0) + row.text:SetPoint("RIGHT", row.removeBtn, "LEFT", -8, 0) + row:SetScript("OnEnter", function(self) + PaintRoundedFill(self.bg, 1, 1, 1, 0.055) + end) + row:SetScript("OnLeave", function(self) + PaintRoundedFill(self.bg, 1, 1, 1, 0) + end) aliasRowPool[idx] = row return row end - local function RefreshAliasList() + RefreshAliasList = function() ReleaseAliasRows() - if not ns.Aliases then return end + if not ns.Aliases then + clearAliasesBtn:Disable() + aliasEmpty:SetText("No saved aliases.") + aliasEmpty:Show() + return + end + local query = aliasSearchBox and aliasSearchBox:GetText() or "" + query = query:lower() local entries = {} + local total = 0 ns.Aliases:ForEach(function(text, info) - entries[#entries + 1] = info + total = total + 1 + local aliasText = info.text or text or "" + local targetName = info.name or "" + local haystack = (aliasText .. " " .. targetName):lower() + if query == "" or string.find(haystack, query, 1, true) then + entries[#entries + 1] = info + end end) table.sort(entries, function(a, b) return (a.text or ""):lower() < (b.text or ""):lower() end) - local y = -2 + clearAliasesBtn:SetEnabled(total > 0) + clearAliasesBtn:SetAlpha(total > 0 and 1 or 0.45) + aliasEmpty:SetText(total == 0 and "No saved aliases." or "No aliases match.") + aliasEmpty:SetShown(#entries == 0) + local rowH = 28 + local y = -4 for i, info in ipairs(entries) do local row = AcquireAliasRow(i) row:ClearAllPoints() - row:SetPoint("TOPLEFT", aliasContent, "TOPLEFT", 0, y) + row:SetPoint("TOPLEFT", aliasContent, "TOPLEFT", 4, y) row.text:SetText(("|cFFFFD100%s|r -> %s"):format(info.text or "?", info.name or "?")) row.removeBtn:SetScript("OnClick", function() if ns.Aliases then ns.Aliases:Remove(info.text) end RefreshAliasList() end) - y = y - 18 + y = y - rowH end - aliasContent:SetHeight(math.max(1, -y + 4)) + aliasContent:SetHeight(math.max(32, -y + 4)) + UpdateAliasScrollBar() + C_Timer.After(0, UpdateAliasScrollBar) end - sec4:HookScript("OnShow", RefreshAliasList) + aliasScroll:SetScript("OnSizeChanged", UpdateAliasScrollBar) + aliasContent:HookScript("OnSizeChanged", UpdateAliasScrollBar) + aliasesTab:HookScript("OnShow", RefreshAliasList) optionsFrame.RefreshAliasList = RefreshAliasList + StaticPopupDialogs["EASYFIND_CLEAR_ALIASES"] = { + text = "Clear all saved aliases?", + button1 = "Clear", + button2 = CANCEL or "Cancel", + OnAccept = function() + if ns.Aliases then ns.Aliases:ClearAll() end + RefreshAliasList() + end, + OnShow = function(self) + self:SetFrameStrata("TOOLTIP") + self:SetFrameLevel(1000) + end, + timeout = 0, + whileDead = true, + hideOnEscape = true, + preferredIndex = 3, + } + clearAliasesBtn:SetScript("OnClick", function() + local dialog = StaticPopup_Show("EASYFIND_CLEAR_ALIASES") + if dialog then + dialog:SetFrameStrata("TOOLTIP") + dialog:SetFrameLevel(1000) + end + end) + -- Reset buttons (tips on Home tab) StaticPopupDialogs["EASYFIND_RESET_ALL"] = { @@ -1619,23 +1870,8 @@ function Options:Initialize() preferredIndex = 3, } - StaticPopupDialogs["EASYFIND_DISABLE_UI_SEARCH"] = { - text = "Disable UI Search?\n\nThis will remove the main search bar. You can re-enable it later from options.", - button1 = "Disable", - button2 = "Cancel", - OnAccept = function() - EasyFind.db.enableUISearch = false - UpdateUIToggleVisual() - StaticPopup_Show("EASYFIND_RELOAD_PROMPT") - end, - timeout = 0, - whileDead = true, - hideOnEscape = true, - preferredIndex = 3, - } - StaticPopupDialogs["EASYFIND_DISABLE_MAP_SEARCH"] = { - text = "Disable Map Search?\n\nThis will remove map search, pins, and map overlay features. You can re-enable it later from options.", + text = "Disable Map Search?\n\nThis will remove map search, pins, map overlay features, and the EasyFind tab on the world map. You can re-enable it later from options.", button1 = "Disable", button2 = "Cancel", OnAccept = function() @@ -1698,23 +1934,23 @@ function Options:Initialize() } -- Reset buttons inside General tab (below last slider) - local resetAllBtn = CreateFrame("Button", nil, sec3, "UIPanelButtonTemplate") + local resetAllBtn = CreateModernButton(sec3) resetAllBtn:SetSize(RESET_BTN_W, 20) resetAllBtn:SetPoint("BOTTOMLEFT", sec3, "BOTTOMLEFT", 8, 8) resetAllBtn:SetText("Reset All Settings") resetAllBtn:SetScript("OnClick", function() StaticPopup_Show("EASYFIND_RESET_ALL") end) - resetAllBtn:SetScript("OnEnter", function(self) + resetAllBtn:HookScript("OnEnter", function(self) GameTooltip:SetOwner(self, "ANCHOR_TOP") GameTooltip:AddLine("Reset All Settings") GameTooltip:AddLine("Restores all options to their default values.", 1, 1, 1, true) GameTooltip:AddLine("Slash command: /ef reset", 0.7, 0.7, 0.7) GameTooltip:Show() end) - resetAllBtn:SetScript("OnLeave", GameTooltip_Hide) + resetAllBtn:HookScript("OnLeave", GameTooltip_Hide) - local resetPosBtn = CreateFrame("Button", nil, sec3, "UIPanelButtonTemplate") + local resetPosBtn = CreateModernButton(sec3) resetPosBtn:SetSize(RESET_BTN_W, 20) resetPosBtn:SetPoint("LEFT", resetAllBtn, "RIGHT", 8, 0) resetPosBtn:SetText("Reset All Positions") @@ -1737,52 +1973,38 @@ function Options:Initialize() .. "and any error messages you saw. Screenshots are great!" ) - local bugBtn = CreateFrame("Button", nil, feedbackTab, "UIPanelButtonTemplate") + local bugBtn = CreateModernButton(feedbackTab) bugBtn:SetSize(RESET_BTN_W, 20) bugBtn:SetPoint("TOPLEFT", feedbackDesc, "BOTTOMLEFT", 0, -12) bugBtn:SetText("Report Bug") bugBtn:SetScript("OnClick", function() EasyFind:OpenBugReport() end) - bugBtn:SetScript("OnEnter", function(self) + bugBtn:HookScript("OnEnter", function(self) GameTooltip:SetOwner(self, "ANCHOR_TOP") GameTooltip:AddLine("Report Bug") GameTooltip:AddLine("Opens a link to submit a bug report on GitHub.", 1, 1, 1, true) GameTooltip:AddLine("Slash command: /ef bug", 0.7, 0.7, 0.7) GameTooltip:Show() end) - bugBtn:SetScript("OnLeave", GameTooltip_Hide) + bugBtn:HookScript("OnLeave", GameTooltip_Hide) - local featureBtn = CreateFrame("Button", nil, feedbackTab, "UIPanelButtonTemplate") + local featureBtn = CreateModernButton(feedbackTab) featureBtn:SetSize(RESET_BTN_W, 20) featureBtn:SetPoint("LEFT", bugBtn, "RIGHT", 12, 0) featureBtn:SetText("Request Feature") featureBtn:SetScript("OnClick", function() EasyFind:OpenFeatureRequest() end) - featureBtn:SetScript("OnEnter", function(self) + featureBtn:HookScript("OnEnter", function(self) GameTooltip:SetOwner(self, "ANCHOR_TOP") GameTooltip:AddLine("Request Feature") - GameTooltip:AddLine("Opens a link to suggest a feature on GitHub.", 1, 1, 1, true) + GameTooltip:AddLine("Opens a link to submit a feature request on GitHub.", 1, 1, 1, true) GameTooltip:AddLine("Slash command: /ef feature", 0.7, 0.7, 0.7) GameTooltip:Show() end) - featureBtn:SetScript("OnLeave", GameTooltip_Hide) - - local enjoyDesc = feedbackTab:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") - enjoyDesc:SetPoint("TOPLEFT", bugBtn, "BOTTOMLEFT", 0, -18) - enjoyDesc:SetPoint("RIGHT", feedbackTab, "RIGHT", -12, 0) - enjoyDesc:SetJustifyH("LEFT") - enjoyDesc:SetSpacing(3) - enjoyDesc:SetText( - "If you're enjoying EasyFind, spreading the word or leaving a friendly " - .. "comment on the CurseForge page is always appreciated. " - .. "Copy the link below to share directly with friends:" - ) - - CreateURLBox(feedbackTab, "https://www.curseforge.com/wow/addons/easyfind", enjoyDesc, -6) + featureBtn:HookScript("OnLeave", GameTooltip_Hide) - -- Show Home tab by default SwitchToTab(1) optionsFrame:Hide() @@ -1798,7 +2020,7 @@ function Options:DoResetPositions() end function Options:DoResetAll() - local needsReload = (EasyFind.db.enableUISearch == false) or (EasyFind.db.enableMapSearch == false) + local needsReload = EasyFind.db.enableMapSearch == false ApplyDefaults(UI_DEFAULTS) ApplyDefaults(MAP_DEFAULTS) ApplyDefaults(GENERAL_DEFAULTS) @@ -1857,6 +2079,7 @@ function Options:RegisterWithBlizzardOptions() optionsFrame.closeBtn:Hide() optionsFrame.bgTex:Hide() optionsFrame:SetBackdrop(nil) + optionsFrame:SetScale(1) -- Reparent into Blizzard panel optionsFrame:SetParent(self) @@ -1915,54 +2138,10 @@ function Options:Show() end -- Refresh values from saved vars (nil-safe in case init partially failed) - if optionsFrame.panelOpacitySlider then optionsFrame.panelOpacitySlider:SetValue(EasyFind.db.panelOpacity or 0.9) end - if optionsFrame.opacitySlider then optionsFrame.opacitySlider:SetValue(EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) end - if optionsFrame.uiFontSlider then optionsFrame.uiFontSlider:SetValue(EasyFind.db.fontSize or 0.9) end - if optionsFrame.mapIconSlider then optionsFrame.mapIconSlider:SetValue(EasyFind.db.iconScale or 0.8) end - if optionsFrame.autoHideCheckbox then - optionsFrame.autoHideCheckbox:SetChecked(EasyFind.db.autoHide ~= false) - end - optionsFrame.smartShowCheckbox:SetChecked(EasyFind.db.smartShow or false) - do - local autoOn = EasyFind.db.autoHide ~= false - optionsFrame.smartShowCheckbox:SetEnabled(not autoOn) - local lbl = optionsFrame.smartShowCheckbox:GetFontString() - if lbl then - local g = autoOn and 0.5 or 1 - lbl:SetTextColor(g, g, g) - end - end - optionsFrame.staticOpacityCheckbox:SetChecked(EasyFind.db.staticOpacity or false) - if optionsFrame.lockPositionCheckbox then - optionsFrame.lockPositionCheckbox:SetChecked(EasyFind.db.lockPosition or false) - end - optionsFrame.loginMessageCheckbox:SetChecked(EasyFind.db.showLoginMessage ~= false) - optionsFrame.uiResultsAboveCheckbox:SetChecked(EasyFind.db.uiResultsAbove or false) - if optionsFrame.resultShortcutHintsCheckbox then - optionsFrame.resultShortcutHintsCheckbox:SetChecked(EasyFind.db.showResultShortcutHints ~= false) - end - optionsFrame.minimapBtnCheckbox:SetChecked(EasyFind.db.showMinimapButton or false) - if optionsFrame.mapPinGroup then optionsFrame.mapPinGroup:UpdateVisuals() end - if optionsFrame.automationGroup then optionsFrame.automationGroup:UpdateVisuals() end - optionsFrame.indicatorBtnText:SetText(EasyFind.db.indicatorStyle or "EasyFind Arrow") - if optionsFrame.fontBtnText then - optionsFrame.fontBtnText:SetText(EasyFind.db.font or "Default") - end - local clr = EasyFind.db.indicatorColor or "Yellow" - local rgb = ns.INDICATOR_COLORS[clr] or ns.INDICATOR_COLORS.Yellow - optionsFrame.colorBtnText:SetText(clr) - optionsFrame.colorBtnText:SetTextColor(rgb[1], rgb[2], rgb[3]) - optionsFrame.colorSwatch:SetColorTexture(rgb[1], rgb[2], rgb[3], 1) - - local key1 = GetBindingKey("EASYFIND_TOGGLE_FOCUS") - optionsFrame.toggleFocusBtn:SetText(key1 or "Not Bound") - local key2 = GetBindingKey("EASYFIND_MAP_FOCUS") - optionsFrame.mapFocusBtn:SetText(key2 or "Not Bound") - local key3 = GetBindingKey("EASYFIND_CLEAR") - optionsFrame.clearBtn:SetText(key3 or "Not Bound") + SyncOptionControls() if not self.embedded and optionsFrame.bgTex then - optionsFrame.bgTex:SetAlpha(EasyFind.db.panelOpacity or 0.9) + optionsFrame.bgTex:SetAlpha(OPTIONS_PANEL_ALPHA) end optionsFrame:Show() end @@ -1973,9 +2152,9 @@ function Options:RestoreStandalone() optionsFrame.titleText:Show() optionsFrame.closeBtn:Show() optionsFrame.bgTex:Show() - optionsFrame.bgTex:SetAlpha(EasyFind.db.panelOpacity or 0.9) - optionsFrame:SetBackdrop(FRAME_BACKDROP) - optionsFrame:SetBackdropBorderColor(0.50, 0.48, 0.45, 1.0) + optionsFrame.bgTex:SetAlpha(OPTIONS_PANEL_ALPHA) + optionsFrame:SetBackdrop(nil) + optionsFrame:SetScale(OPTIONS_PANEL_SCALE) optionsFrame:SetParent(UIParent) optionsFrame:SetFrameStrata("DIALOG") diff --git a/README.md b/README.md index 767993c..f9c64e6 100644 --- a/README.md +++ b/README.md @@ -182,11 +182,10 @@ When you install EasyFind for the first time, you'll see an interactive setup ov | Command |Description | | --------- |------------------------------------ | | /ef   /ef o |Open the options panel | -| /ef toggle   /ef t |Show/hide the search bar | | /ef clear   /ef c |Dismiss all active highlights and guides | | /ef reset   /ef r |Reset all settings to defaults (opens confirmation dialog) | -| /ef setup |Re-run the first-time setup overlay | -| /ef whatsnew |Show the What's New dialog for the current version | +| /ef setup |Re-run the tutorial | +| /ef whatsnew |Show the current version tutorial or What's New panel | | /ef bug |Get a link to submit a bug report on GitHub | | /ef feature |Get a link to submit a feature request on GitHub | diff --git a/Rescaler.lua b/Rescaler.lua index 160a561..f0a4a3d 100644 --- a/Rescaler.lua +++ b/Rescaler.lua @@ -9,31 +9,38 @@ local tinsert = Utils.tinsert local SafeCallMethod = Utils.SafeCallMethod local GOLD_COLOR = ns.GOLD_COLOR -local DARK_PANEL_BG = ns.DARK_PANEL_BG local TOOLTIP_BORDER = ns.TOOLTIP_BORDER local MIN_WIDTH = 150 local MAX_WIDTH = 600 local HANDLE_SIZE = 10 local GLOW_OUTSET = 6 +local EDGE_HANDLE_INSET = 34 local PREVIEW_ROW_H = 26 local PREVIEW_PAD = 16 local PREVIEW_MAX_ROWS = 24 local MIN_HEIGHT = 80 local MAX_HEIGHT = 700 -local MIN_FONT = 0.5 -local MAX_FONT = 2.0 +local MIN_BAR_HEIGHT = 24 +local MAX_BAR_HEIGHT = 56 +local DEFAULT_BAR_HEIGHT = ns.SEARCHBAR_HEIGHT or 30 +local DEFAULT_RESULTS_HEIGHT = 280 local activeMode = nil -local activeSearchBar = nil -- the search bar being rescaled +local activeSearchBar = nil -- the mock search bar being rescaled +local liveSearchBar = nil -- real search bar hidden while editing +local liveResultsFrame = nil +local liveContainerFrame = nil +local liveState = nil local backdrop = nil -- full-screen dim local barOverlay = nil -- glow around search bar -local resultsOverlay = nil -- glow around results local donePanel = nil -- instruction + Done button +local mockSearchBar = nil -- inert mock bar for resize mode +local mockWindowFrame = nil -- unified background behind mock bar/results local previewResults = nil -- fake results frame for preview local function GetResultsHeight() - return EasyFind.db.uiResultsHeight or 280 + return EasyFind.db.uiResultsHeight or DEFAULT_RESULTS_HEIGHT end local function SetResultsHeight(h) @@ -42,19 +49,21 @@ local function SetResultsHeight(h) end local function GetDefaultResultsHeight() - return 280 + return DEFAULT_RESULTS_HEIGHT end -local function GetFontScale() - return EasyFind.db.fontSize or 1.0 +local function GetSearchBarHeight() + local h = EasyFind.db.uiSearchBarHeight or DEFAULT_BAR_HEIGHT + return mmax(MIN_BAR_HEIGHT, mmin(MAX_BAR_HEIGHT, h)) end -local function SetFontScale(val) - EasyFind.db.fontSize = val +local function SetSearchBarHeight(h) + h = mmax(MIN_BAR_HEIGHT, mmin(MAX_BAR_HEIGHT, mfloor(h + 0.5))) + EasyFind.db.uiSearchBarHeight = h end -local function ApplyFontUpdate() - if ns.UI and ns.UI.UpdateFontSize then ns.UI:UpdateFontSize() end +local function GetFontScale() + return EasyFind.db.fontSize or 1.0 end -- Helpers @@ -63,20 +72,51 @@ local function ClampWidth(v) return mmax(MIN_WIDTH, mmin(MAX_WIDTH, v)) end -local function GetScreenMaxHeight(anchorAbove) - if not activeSearchBar then return MAX_HEIGHT end +local function GetUnifiedWindowHeight() + return GetSearchBarHeight() + GetResultsHeight() +end + +local function GetScreenMaxWindowHeight(anchorAbove) + if not activeSearchBar then return MAX_BAR_HEIGHT + MAX_HEIGHT end local available if anchorAbove then local screenTop = UIParent:GetTop() or UIParent:GetHeight() - local barTop = activeSearchBar:GetTop() or (screenTop / 2) - available = screenTop - barTop - 16 + local barBottom = activeSearchBar:GetBottom() or (screenTop / 2) + available = screenTop - barBottom - 16 else - available = (activeSearchBar:GetBottom() or (UIParent:GetHeight() / 2)) - 16 + available = (activeSearchBar:GetTop() or (UIParent:GetHeight() / 2)) - 16 end + local minTotal = MIN_BAR_HEIGHT + MIN_HEIGHT + local maxTotal = MAX_BAR_HEIGHT + MAX_HEIGHT if available > 0 then - return mmax(MIN_HEIGHT, mmin(MAX_HEIGHT, mfloor(available))) + return mmax(minTotal, mmin(maxTotal, mfloor(available))) + end + return maxTotal +end + +local function SetUnifiedWindowHeight(totalH, barRatio, preview, heightBox, anchorAbove) + local minTotal = MIN_BAR_HEIGHT + MIN_HEIGHT + local maxTotal = GetScreenMaxWindowHeight(anchorAbove) + totalH = mmax(minTotal, mmin(maxTotal, mfloor(totalH + 0.5))) + + local ratio = barRatio or (GetSearchBarHeight() / mmax(1, GetUnifiedWindowHeight())) + local barH = mmax(MIN_BAR_HEIGHT, mmin(MAX_BAR_HEIGHT, totalH * ratio)) + if totalH - barH < MIN_HEIGHT then barH = totalH - MIN_HEIGHT end + if totalH - barH > MAX_HEIGHT then barH = totalH - MAX_HEIGHT end + barH = mmax(MIN_BAR_HEIGHT, mmin(MAX_BAR_HEIGHT, barH)) + + local resultsH = mmax(MIN_HEIGHT, mmin(MAX_HEIGHT, totalH - barH)) + SetSearchBarHeight(barH) + SetResultsHeight(resultsH) + + if mockSearchBar and mockSearchBar.SetMockBarHeight then + mockSearchBar:SetMockBarHeight(GetSearchBarHeight()) + end + if preview then preview:SetPreviewHeight(GetResultsHeight()) end + if mockWindowFrame and mockWindowFrame.UpdateLayout then mockWindowFrame:UpdateLayout() end + if heightBox and not heightBox:HasFocus() then + heightBox:SetText(mfloor(GetUnifiedWindowHeight() + 0.5)) end - return MAX_HEIGHT end local function AddResetButton(editBox, onConfirm) @@ -154,8 +194,8 @@ local function CreateHandle(parent, point, xOff, yOff, cursor, isHorizontal) handle:SetFrameLevel(parent:GetFrameLevel() + 10) if isHorizontal then handle:SetSize(HANDLE_SIZE, 1) - handle:SetPoint("TOP", parent, "TOP", 0, -GLOW_OUTSET) - handle:SetPoint("BOTTOM", parent, "BOTTOM", 0, GLOW_OUTSET) + handle:SetPoint("TOP", parent, "TOP", 0, -EDGE_HANDLE_INSET) + handle:SetPoint("BOTTOM", parent, "BOTTOM", 0, EDGE_HANDLE_INSET) if point == "LEFT" then handle:SetPoint("LEFT", parent, "LEFT", xOff, 0) else @@ -163,8 +203,8 @@ local function CreateHandle(parent, point, xOff, yOff, cursor, isHorizontal) end else handle:SetHeight(HANDLE_SIZE) - handle:SetPoint("LEFT", parent, "LEFT", GLOW_OUTSET, 0) - handle:SetPoint("RIGHT", parent, "RIGHT", -GLOW_OUTSET, 0) + handle:SetPoint("LEFT", parent, "LEFT", EDGE_HANDLE_INSET, 0) + handle:SetPoint("RIGHT", parent, "RIGHT", -EDGE_HANDLE_INSET, 0) handle:SetPoint(point, parent, point, 0, yOff) end handle:EnableMouse(true) @@ -188,9 +228,9 @@ end local function CreateScaleHandle(parent, point, xOff, yOff, flipH, flipV) local handle = CreateFrame("Button", nil, parent) - handle:SetSize(16, 16) + handle:SetSize(22, 22) handle:SetPoint(point, parent, point, xOff, yOff) - handle:SetFrameLevel(parent:GetFrameLevel() + 10) + handle:SetFrameLevel(parent:GetFrameLevel() + 20) handle:EnableMouse(true) handle:RegisterForDrag("LeftButton") @@ -228,6 +268,64 @@ local function CreateGlowOverlay(name, parent, target) return glow end +local function HideRoundedFrameBorder(frame) + if not (frame and frame.combinedBorder and frame.combinedBorder.border) then return end + for _, tex in pairs(frame.combinedBorder.border) do tex:Hide() end +end + +local function SetModernButtonFill(btn, r, g, b) + if not (btn and btn.combinedBorder and btn.combinedBorder.fill) then return end + for _, tex in pairs(btn.combinedBorder.fill) do + tex:SetVertexColor(r, g, b, 1) + end +end + +local function CreateModernButton(parent, text, width, height) + local btn = CreateFrame("Button", nil, parent) + local rawSetSize = btn.SetSize + rawSetSize(btn, width or 100, height or 22) + + ns.CreateRoundedRectBorder(btn) + ns.SetRoundedRectBarHeight(btn, mmin(height or 22, 10)) + ns.SetRoundedRectBorderBgAlpha(btn, 1) + HideRoundedFrameBorder(btn) + SetModernButtonFill(btn, 0.095, 0.095, 0.108) + + local label = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + label:SetPoint("CENTER") + label:SetText(text or "") + label:SetTextColor(1, 1, 1, 1) + btn._label = label + + btn.SetText = function(self, value) + if self._label then self._label:SetText(value or "") end + end + btn.GetText = function(self) + return self._label and self._label:GetText() or "" + end + btn.SetSize = function(self, w, h) + rawSetSize(self, w, h) + ns.SetRoundedRectBarHeight(self, mmin(h or self:GetHeight() or 22, 10)) + end + + btn:SetScript("OnEnter", function(self) + if self:IsEnabled() then SetModernButtonFill(self, 0.155, 0.155, 0.172) end + end) + btn:SetScript("OnLeave", function(self) + if self:IsEnabled() then SetModernButtonFill(self, 0.095, 0.095, 0.108) end + end) + btn:SetScript("OnMouseDown", function(self) + if self:IsEnabled() then SetModernButtonFill(self, 0.065, 0.065, 0.078) end + end) + btn:SetScript("OnMouseUp", function(self) + if not self:IsEnabled() then return end + if self:IsMouseOver() then SetModernButtonFill(self, 0.155, 0.155, 0.172) + else SetModernButtonFill(self, 0.095, 0.095, 0.108) end + end) + + return btn +end + -- Preview results (fake rows to show results area) local function CreatePreviewResults(parent, targetFrame, width, heightPx, anchorAbove, leftAligned, flushDock) @@ -254,13 +352,17 @@ local function CreatePreviewResults(parent, targetFrame, width, heightPx, anchor end end - frame:SetBackdrop({ - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", - edgeFile = TOOLTIP_BORDER, - tile = true, tileSize = 32, edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 } - }) - frame:SetBackdropColor(0.1, 0.1, 0.1, 0.85) + if flushDock then + frame:SetBackdrop(nil) + else + frame:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", + edgeFile = TOOLTIP_BORDER, + tile = true, tileSize = 32, edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 } + }) + frame:SetBackdropColor(0.1, 0.1, 0.1, 0.85) + end frame:SetClipsChildren(true) local nVisible = mfloor((heightPx - PREVIEW_PAD) / rowH) @@ -315,6 +417,102 @@ local function CreatePreviewResults(parent, targetFrame, width, heightPx, anchor return frame end +local function CreateMockSearchBar(parent, liveBar, centerX, centerY) + local barH = GetSearchBarHeight() + local width = ClampWidth((liveBar and liveBar.GetWidth and liveBar:GetWidth()) or (250 * (EasyFind.db.uiSearchWidth or 1.0))) + + local frame = CreateFrame("Frame", nil, parent, "BackdropTemplate") + frame:SetFrameStrata("FULLSCREEN_DIALOG") + frame:SetFrameLevel(190) + frame:SetSize(width, barH) + frame:EnableMouse(false) + + local cx, cy = centerX, centerY + if (not cx or not cy) and liveBar and liveBar.GetCenter then + cx, cy = liveBar:GetCenter() + end + if not cx or not cy then + local parentW = UIParent:GetWidth() or 0 + local parentH = UIParent:GetHeight() or 0 + cx, cy = parentW / 2, parentH * (2 / 3) + end + frame:SetPoint("CENTER", UIParent, "BOTTOMLEFT", cx, cy) + + frame:SetBackdrop(nil) + + local contentSz = barH * (ns.SEARCHBAR_FILL or 0.72) + local iconSz = contentSz * (ns.SEARCHBAR_ICON_SCALE or 0.58) + + local iconHolder = CreateFrame("Frame", nil, frame) + iconHolder:SetPoint("TOP", frame, "TOP", 0, 0) + iconHolder:SetPoint("BOTTOM", frame, "BOTTOM", 0, 0) + iconHolder:SetPoint("LEFT", frame, "LEFT", 0, 0) + iconHolder:SetWidth(barH) + frame.iconHolder = iconHolder + + local icon = iconHolder:CreateTexture(nil, "OVERLAY") + icon:SetSize(iconSz, iconSz) + icon:SetPoint("CENTER") + if icon.SetAtlas then + icon:SetAtlas("common-search-magnifyingglass") + else + icon:SetTexture("Interface\\Common\\UI-Searchbox-Icon") + end + icon:SetAlpha(0.9) + frame.searchIcon = icon + + local text = frame:CreateFontString(nil, "OVERLAY", "GameFontDisable") + text:SetPoint("LEFT", iconHolder, "RIGHT", 0, 0) + text:SetPoint("RIGHT", frame, "RIGHT", -10, 0) + text:SetJustifyH("LEFT") + text:SetText("Search") + text:SetTextColor(0.55, 0.55, 0.58, 1) + frame.placeholder = text + + frame.SetMockBarHeight = function(self, h) + h = mmax(MIN_BAR_HEIGHT, mmin(MAX_BAR_HEIGHT, h or DEFAULT_BAR_HEIGHT)) + self:SetHeight(h) + if self.iconHolder then self.iconHolder:SetWidth(h) end + local contentSz = h * (ns.SEARCHBAR_FILL or 0.72) + local iconSz = contentSz * (ns.SEARCHBAR_ICON_SCALE or 0.58) + if self.searchIcon then self.searchIcon:SetSize(iconSz, iconSz) end + if mockWindowFrame and mockWindowFrame.UpdateLayout then mockWindowFrame:UpdateLayout() end + end + + return frame +end + +local function CreateMockWindowFrame(parent, searchBar, preview, anchorAbove) + local frame = CreateFrame("Frame", nil, parent) + frame:SetFrameStrata("FULLSCREEN_DIALOG") + frame:SetFrameLevel(188) + frame:EnableMouse(false) + if anchorAbove then + frame:SetPoint("TOPLEFT", preview, "TOPLEFT", 0, 0) + frame:SetPoint("TOPRIGHT", preview, "TOPRIGHT", 0, 0) + frame:SetPoint("BOTTOMLEFT", searchBar, "BOTTOMLEFT", 0, 0) + frame:SetPoint("BOTTOMRIGHT", searchBar, "BOTTOMRIGHT", 0, 0) + else + frame:SetPoint("TOPLEFT", searchBar, "TOPLEFT", 0, 0) + frame:SetPoint("TOPRIGHT", searchBar, "TOPRIGHT", 0, 0) + frame:SetPoint("BOTTOMLEFT", preview, "BOTTOMLEFT", 0, 0) + frame:SetPoint("BOTTOMRIGHT", preview, "BOTTOMRIGHT", 0, 0) + end + ns.CreateRoundedRectBorder(frame) + ns.CreateRoundedRectDivider(frame) + ns.SetRoundedRectBarHeight(frame, searchBar:GetHeight()) + ns.SetRoundedRectBorderBgAlpha(frame, EasyFind.db.searchBarOpacity or ns.DEFAULT_OPACITY or 0.88) + + frame.UpdateLayout = function(self) + local dividerOffset = anchorAbove and preview:GetHeight() or searchBar:GetHeight() + ns.SetRoundedRectBarHeight(self, searchBar:GetHeight()) + ns.SetRoundedRectDivider(self, dividerOffset, true) + end + frame:UpdateLayout() + + return frame +end + -- Dimension label wiring local function WireDimLabel(box, getter, setter) @@ -371,8 +569,9 @@ local function SetupHeightDrag(handle, preview, heightBox, anchorAbove) self.dragging = true local _, cy = GetCursorPosition() self.startY = cy / UIParent:GetEffectiveScale() - self.startHeight = GetResultsHeight() - self.maxH = GetScreenMaxHeight(anchorAbove) + self.startHeight = GetUnifiedWindowHeight() + self.barRatio = GetSearchBarHeight() / mmax(1, self.startHeight) + self.maxH = GetScreenMaxWindowHeight(anchorAbove) end) handle:SetScript("OnDragStop", function(self) self.dragging = false @@ -383,162 +582,78 @@ local function SetupHeightDrag(handle, preview, heightBox, anchorAbove) cy = cy / UIParent:GetEffectiveScale() local dy = self.startY - cy if anchorAbove then dy = -dy end - local newH = mmax(MIN_HEIGHT, mmin(self.maxH, mfloor(self.startHeight + dy + 0.5))) - SetResultsHeight(newH) - preview:SetPreviewHeight(newH) - if not heightBox:HasFocus() then heightBox:SetText(newH) end + local newH = mmax(MIN_BAR_HEIGHT + MIN_HEIGHT, mmin(self.maxH, mfloor(self.startHeight + dy + 0.5))) + SetUnifiedWindowHeight(newH, self.barRatio, preview, heightBox, anchorAbove) end) end --- Font size drag handler - -local function SetupFontDrag(handle, fontLabel, preview) - local PX_PER_STEP = ns.SEARCHBAR_HEIGHT * 0.1 - +local function SetupCornerDrag(handle, getWidth, setWidth, widthBox, preview, heightBox, anchorAbove) handle:SetScript("OnDragStart", function(self) self.dragging = true - local _, cy = GetCursorPosition() - self.startY = cy / UIParent:GetEffectiveScale() - self.startFont = GetFontScale() + local cx, cy = GetCursorPosition() + local scale = UIParent:GetEffectiveScale() + self.startX = cx / scale + self.startY = cy / scale + self.startWidth = getWidth() + self.startHeight = GetUnifiedWindowHeight() + self.barRatio = GetSearchBarHeight() / mmax(1, self.startHeight) + self.maxH = GetScreenMaxWindowHeight(anchorAbove) end) handle:SetScript("OnDragStop", function(self) self.dragging = false end) handle:SetScript("OnUpdate", function(self) if not self.dragging then return end - local _, cy = GetCursorPosition() - cy = cy / UIParent:GetEffectiveScale() - -- Dragging down = bigger bar = larger font - local dy = self.startY - cy - local stepDelta = mfloor(dy / PX_PER_STEP + 0.5) - local newFont = mmax(MIN_FONT, mmin(MAX_FONT, self.startFont + stepDelta * 0.1)) - newFont = mfloor(newFont * 10 + 0.5) / 10 - - SetFontScale(newFont) - ApplyFontUpdate() - - -- Update preview row fonts; height stays fixed (font size doesn't change result height) - if preview and preview.rows then - local path, baseSize, flags = GameFontDisable:GetFont() - local scaledRowH = PREVIEW_ROW_H * newFont - local h = GetResultsHeight() - local nVis = mfloor((h - PREVIEW_PAD) / scaledRowH) - for i = 1, PREVIEW_MAX_ROWS do - local row = preview.rows[i] - row:SetFont(path, baseSize * newFont, flags) - row:SetHeight(scaledRowH) - row:ClearAllPoints() - row:SetPoint("LEFT", preview, "LEFT", 12, 0) - row:SetPoint("RIGHT", preview, "RIGHT", -12, 0) - row:SetPoint("TOP", preview, "TOP", 0, -8 - (i - 1) * scaledRowH) - row:SetShown(i <= nVis) - end - preview:SetHeight(h) - end - - if not fontLabel:HasFocus() then - fontLabel:SetText(mfloor(newFont * 100 + 0.5)) + local cx, cy = GetCursorPosition() + local scale = UIParent:GetEffectiveScale() + cx, cy = cx / scale, cy / scale + + local newW = ClampWidth(self.startWidth + (cx - self.startX) * 2) + setWidth(newW) + if widthBox and not widthBox:HasFocus() then + widthBox:SetText(mfloor(newW + 0.5)) end - local optPanel = _G["EasyFindOptionsFrame"] - if optPanel then - local slider = optPanel.uiFontSlider - if slider then slider:SetValue(newFont) end - end + local dy = self.startY - cy + if anchorAbove then dy = -dy end + local newH = mmax(MIN_BAR_HEIGHT + MIN_HEIGHT, mmin(self.maxH, mfloor(self.startHeight + dy + 0.5))) + SetUnifiedWindowHeight(newH, self.barRatio, preview, heightBox, anchorAbove) end) end -- Build overlays for a target -local function BuildBarOverlay(parent, targetFrame, mode) - local overlay = CreateGlowOverlay("EasyFindRescaleBarGlow", parent, targetFrame) - overlay:SetPoint("TOPLEFT", targetFrame, "TOPLEFT", -GLOW_OUTSET, GLOW_OUTSET) - overlay:SetPoint("BOTTOMRIGHT", targetFrame, "BOTTOMRIGHT", GLOW_OUTSET, -GLOW_OUTSET) - - local label = overlay:CreateFontString(nil, "OVERLAY", "GameFontNormal") - label:SetPoint("CENTER") - label:SetText("UI Search Bar") - label:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.7) - - local widthBox = CreateDimLabel(overlay, "LEFT", "RIGHT", 8, 0, "Width:") - overlay.widthBox = widthBox - - -- Width drag handles (left and right edges) - local leftHandle = CreateHandle(overlay, "LEFT", 0, 0, nil, true) - local rightHandle = CreateHandle(overlay, "RIGHT", 0, 0, nil, true) - overlay.leftHandle = leftHandle - overlay.rightHandle = rightHandle - - -- Bottom drag handle (for font size) - local bottomHandle = CreateHandle(overlay, "BOTTOM", 0, 0, nil, false) - overlay.bottomHandle = bottomHandle - - -- Font size field (below bottom edge) - local fontBox = CreateFrame("EditBox", nil, overlay, "InputBoxTemplate") - fontBox:SetSize(50, 20) - fontBox:SetAutoFocus(false) - fontBox:SetMaxLetters(4) - fontBox:SetJustifyH("CENTER") - fontBox:SetFontObject("GameFontHighlightSmall") - - local fontPfx = overlay:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") - fontPfx:SetPoint("TOP", overlay, "BOTTOM", 0, -4) - fontPfx:SetText("Font:") - fontPfx:SetTextColor(0.9, 0.3, 0.3, 1.0) - fontBox:SetPoint("LEFT", fontPfx, "RIGHT", 6, 0) - - local fontSuffix = fontBox:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") - fontSuffix:SetPoint("LEFT", fontBox, "RIGHT", 2, 0) - fontSuffix:SetText("%") - fontSuffix:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.7) - - -- Tooltip on the prefix label - local fontTip = CreateFrame("Frame", nil, overlay) - fontTip:SetAllPoints(fontPfx) - fontTip:SetScript("OnEnter", function(self) - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - GameTooltip:SetText("Changing font size also resizes the search bar and results window.") - GameTooltip:Show() - end) - fontTip:SetScript("OnLeave", GameTooltip_Hide) - - overlay.fontBox = fontBox - - return overlay -end - -local function BuildResultsOverlay(parent, targetFrame, anchorAbove) - local overlay = CreateGlowOverlay("EasyFindRescaleResultsGlow", parent, targetFrame) - overlay:SetPoint("TOPLEFT", targetFrame, "TOPLEFT", -GLOW_OUTSET, GLOW_OUTSET) - overlay:SetPoint("BOTTOMRIGHT", targetFrame, "BOTTOMRIGHT", GLOW_OUTSET, -GLOW_OUTSET) +local function BuildUnifiedWindowOverlay(parent, searchBar, preview, anchorAbove) + local overlay = CreateGlowOverlay("EasyFindRescaleWindowGlow", parent, searchBar) + if anchorAbove then + overlay:SetPoint("TOPLEFT", preview, "TOPLEFT", -GLOW_OUTSET, GLOW_OUTSET) + overlay:SetPoint("TOPRIGHT", preview, "TOPRIGHT", GLOW_OUTSET, GLOW_OUTSET) + overlay:SetPoint("BOTTOMLEFT", searchBar, "BOTTOMLEFT", -GLOW_OUTSET, -GLOW_OUTSET) + overlay:SetPoint("BOTTOMRIGHT", searchBar, "BOTTOMRIGHT", GLOW_OUTSET, -GLOW_OUTSET) + else + overlay:SetPoint("TOPLEFT", searchBar, "TOPLEFT", -GLOW_OUTSET, GLOW_OUTSET) + overlay:SetPoint("TOPRIGHT", searchBar, "TOPRIGHT", GLOW_OUTSET, GLOW_OUTSET) + overlay:SetPoint("BOTTOMLEFT", preview, "BOTTOMLEFT", -GLOW_OUTSET, -GLOW_OUTSET) + overlay:SetPoint("BOTTOMRIGHT", preview, "BOTTOMRIGHT", GLOW_OUTSET, -GLOW_OUTSET) + end local label = overlay:CreateFontString(nil, "OVERLAY", "GameFontNormal") label:SetPoint("CENTER") - label:SetText("Search Results") + label:SetText("UI Search Window") label:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.7) local widthBox = CreateDimLabel(overlay, "LEFT", "RIGHT", 8, 0, "Width:") overlay.widthBox = widthBox - -- Height drag edge and corner handle: top edge when above, bottom edge when below - local heightEdge = anchorAbove and "TOP" or "BOTTOM" + local resizeEdge = anchorAbove and "TOP" or "BOTTOM" local cornerPoint = anchorAbove and "TOPRIGHT" or "BOTTOMRIGHT" local flipV = anchorAbove - local scaleHandle = CreateScaleHandle(overlay, cornerPoint, 0, 0, false, flipV) - overlay.scaleHandle = scaleHandle - - -- Width drag handles - local leftHandle = CreateHandle(overlay, "LEFT", 0, 0, nil, true) - local rightHandle = CreateHandle(overlay, "RIGHT", 0, 0, nil, true) - overlay.leftHandle = leftHandle - overlay.rightHandle = rightHandle - - -- Height drag handle (top or bottom edge) - local heightHandle = CreateHandle(overlay, heightEdge, 0, 0, nil, false) - overlay.heightHandle = heightHandle + overlay.leftHandle = CreateHandle(overlay, "LEFT", 0, 0, nil, true) + overlay.rightHandle = CreateHandle(overlay, "RIGHT", 0, 0, nil, true) + overlay.heightHandle = CreateHandle(overlay, resizeEdge, 0, 0, nil, false) + overlay.scaleHandle = CreateScaleHandle(overlay, cornerPoint, 0, 0, false, flipV) - -- Height field (outside the resize edge) local heightBox = CreateFrame("EditBox", nil, overlay, "InputBoxTemplate") heightBox:SetSize(50, 20) heightBox:SetAutoFocus(false) @@ -575,29 +690,17 @@ local function CreateDonePanel(parent) local DONE_W = 80 local BTN_GAP = 2 local BTN_H = 22 - local PANEL_PAD = 8 - local panel = CreateFrame("Frame", nil, parent, "BackdropTemplate") - panel:SetSize(BACK_W + BTN_GAP + DONE_W + PANEL_PAD * 2, BTN_H + 14) + local panel = CreateFrame("Frame", nil, parent) + panel:SetSize(BACK_W + BTN_GAP + DONE_W, BTN_H) panel:SetFrameStrata("FULLSCREEN_DIALOG") panel:SetFrameLevel(209) - panel:SetBackdrop({ - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", - edgeFile = TOOLTIP_BORDER, - tile = true, tileSize = 32, edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 } - }) - panel:SetBackdropColor(DARK_PANEL_BG[1], DARK_PANEL_BG[2], DARK_PANEL_BG[3], DARK_PANEL_BG[4]) - local backBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") - backBtn:SetSize(BACK_W, BTN_H) - backBtn:SetPoint("LEFT", panel, "LEFT", PANEL_PAD, 0) - backBtn:SetText("Back to Options") + local backBtn = CreateModernButton(panel, "Back to Options", BACK_W, BTN_H) + backBtn:SetPoint("LEFT", panel, "LEFT", 0, 0) panel.backBtn = backBtn - local doneBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") - doneBtn:SetSize(DONE_W, BTN_H) + local doneBtn = CreateModernButton(panel, "Done", DONE_W, BTN_H) doneBtn:SetPoint("LEFT", backBtn, "RIGHT", BTN_GAP, 0) - doneBtn:SetText("Done") panel.doneBtn = doneBtn return panel @@ -640,33 +743,38 @@ function Rescaler:Enter(mode) activeMode = mode - local searchBar, resultsFrame - local getBarWidth, setBarWidth - local getResultsWidth, getResultsScale - - searchBar = _G["EasyFindSearchFrame"] - resultsFrame = _G["EasyFindResultsFrame"] + liveSearchBar = _G["EasyFindSearchFrame"] + liveResultsFrame = _G["EasyFindResultsFrame"] + liveContainerFrame = _G["EasyFindContainerFrame"] - if not searchBar then + if not liveSearchBar then activeMode = nil return end - searchBar:Show() - searchBar:SetAlpha(1.0) + liveState = { + searchShown = liveSearchBar:IsShown(), + searchAlpha = liveSearchBar:GetAlpha(), + resultsShown = liveResultsFrame and liveResultsFrame:IsShown(), + containerShown = liveContainerFrame and liveContainerFrame:IsShown(), + } + local liveCenterX, liveCenterY = liveSearchBar:GetCenter() + if liveSearchBar.editBox then + liveSearchBar.editBox:ClearFocus() + end + if liveResultsFrame then liveResultsFrame:Hide() end + liveSearchBar:Hide() + if liveContainerFrame then liveContainerFrame:Hide() end local function setUiWidth(w) w = ClampWidth(w) - searchBar:SetWidth(w) + mockSearchBar:SetWidth(w) EasyFind.db.uiSearchWidth = w / 250 EasyFind.db.uiResultsWidth = w - if resultsFrame then resultsFrame:SetWidth(w) end end - getBarWidth = function() return searchBar:GetWidth() end - setBarWidth = setUiWidth - getResultsScale = function() return EasyFind.db.uiResultsScale or 1.0 end - getResultsWidth = function() return searchBar:GetWidth() end + local getBarWidth = function() return mockSearchBar:GetWidth() end + local setBarWidth = setUiWidth -- Hide options panel local optPanel = _G["EasyFindOptionsFrame"] @@ -674,140 +782,102 @@ function Rescaler:Enter(mode) optPanel:Hide() end - activeSearchBar = searchBar - - -- Block focus but allow shift-drag - searchBar.setupMode = true - if searchBar.editBox then - searchBar.editBox:ClearFocus() - end - -- Dim backdrop local bg = GetOrCreateBackdrop() bg:Show() SafeCallMethod(bg, "EnableKeyboard", true) + mockSearchBar = CreateMockSearchBar(bg, liveSearchBar, liveCenterX, liveCenterY) + mockSearchBar:Show() + activeSearchBar = mockSearchBar + -- Preview results (fake rows so user sees the results area) local resultsAbove = EasyFind.db.uiResultsAbove - local previewW = getResultsWidth() - local currentH = mmin(GetResultsHeight(), GetScreenMaxHeight(resultsAbove)) - previewResults = CreatePreviewResults(bg, searchBar, previewW, currentH, resultsAbove, false, true) - previewResults:SetScale(getResultsScale()) + local currentTotal = GetUnifiedWindowHeight() + local currentRatio = GetSearchBarHeight() / mmax(1, currentTotal) + SetUnifiedWindowHeight(mmin(currentTotal, GetScreenMaxWindowHeight(resultsAbove)), currentRatio, nil, nil, resultsAbove) + local previewW = getBarWidth() + local currentH = GetResultsHeight() + previewResults = CreatePreviewResults(bg, mockSearchBar, previewW, currentH, resultsAbove, false, true) previewResults:Show() + mockWindowFrame = CreateMockWindowFrame(bg, mockSearchBar, previewResults, resultsAbove) + previewResults:HookScript("OnSizeChanged", function() + if mockWindowFrame and mockWindowFrame.UpdateLayout then mockWindowFrame:UpdateLayout() end + end) - -- Bar overlay - barOverlay = BuildBarOverlay(bg, searchBar, mode) + -- One overlay around the combined bar + results silhouette. + barOverlay = BuildUnifiedWindowOverlay(bg, mockSearchBar, previewResults, resultsAbove) barOverlay:Show() - -- Results overlay (around the preview) - resultsOverlay = BuildResultsOverlay(bg, previewResults, resultsAbove) - resultsOverlay:Show() - -- If height label is near screen edge, flip it inside the overlay - local resizeEdge = resultsAbove and resultsOverlay:GetTop() or resultsOverlay:GetBottom() + local resizeEdge = resultsAbove and barOverlay:GetTop() or barOverlay:GetBottom() local screenLimit = resultsAbove and UIParent:GetTop() or 0 local nearEdge = resultsAbove and (resizeEdge and screenLimit and (screenLimit - resizeEdge) < 40) or (not resultsAbove and resizeEdge and resizeEdge < 40) if nearEdge then - resultsOverlay.heightPfx:ClearAllPoints() + barOverlay.heightPfx:ClearAllPoints() if resultsAbove then - resultsOverlay.heightPfx:SetPoint("TOPLEFT", resultsOverlay, "TOPLEFT", GLOW_OUTSET + 4, -8) + barOverlay.heightPfx:SetPoint("TOPLEFT", barOverlay, "TOPLEFT", GLOW_OUTSET + 4, -8) else - resultsOverlay.heightPfx:SetPoint("BOTTOMLEFT", resultsOverlay, "BOTTOMLEFT", GLOW_OUTSET + 4, 8) + barOverlay.heightPfx:SetPoint("BOTTOMLEFT", barOverlay, "BOTTOMLEFT", GLOW_OUTSET + 4, 8) end - resultsOverlay.heightInside = true + barOverlay.heightInside = true end - -- Wire bar width drag. In UI mode the preview is the live results - -- area; resize it in lockstep so the unified pill stays continuous. + -- Wire unified window width. The bar and preview resize in lockstep + -- because the current UI uses one combined silhouette. local function setBarWidthAndPreview(w) setBarWidth(w) - previewResults:SetWidth(searchBar:GetWidth()) + previewResults:SetWidth(mockSearchBar:GetWidth()) end SetupWidthDrag(barOverlay.leftHandle, getBarWidth, setBarWidthAndPreview, barOverlay.widthBox, "LEFT") SetupWidthDrag(barOverlay.rightHandle, getBarWidth, setBarWidthAndPreview, barOverlay.widthBox, "RIGHT") WireDimLabel(barOverlay.widthBox, getBarWidth, setBarWidthAndPreview) AddResetButton(barOverlay.widthBox, function() - local defW = 250 * 0.88 -- 220px (matches DB_DEFAULTS searchWidth = 0.88) + local defW = 250 * 1.54 setBarWidthAndPreview(defW) barOverlay.widthBox:SetText(mfloor(defW + 0.5)) end) - -- Wire bar bottom edge (font size) - local currentFont = GetFontScale() - barOverlay.fontBox:SetText(mfloor(currentFont * 100 + 0.5)) - barOverlay.fontBox:SetScript("OnEnterPressed", function(self) - local val = tonumber(self:GetText()) - if val then - val = mmax(MIN_FONT * 100, mmin(MAX_FONT * 100, mfloor(val + 0.5))) - local newFont = val / 100 - SetFontScale(newFont) - ApplyFontUpdate() - self:SetText(val) - previewResults:UpdatePreviewFont() - end - self:ClearFocus() - end) - barOverlay.fontBox:SetScript("OnEscapePressed", function(self) - self:SetText(mfloor(GetFontScale() * 100 + 0.5)) - self:ClearFocus() - end) - AddResetButton(barOverlay.fontBox, function() - SetFontScale(0.9) - ApplyFontUpdate() - barOverlay.fontBox:SetText(90) - previewResults:UpdatePreviewFont() - end) - SetupFontDrag(barOverlay.bottomHandle, barOverlay.fontBox, previewResults) - - resultsOverlay.leftHandle:Hide() - resultsOverlay.rightHandle:Hide() - resultsOverlay.widthBox:Hide() - if resultsOverlay.widthBox.prefix then resultsOverlay.widthBox.prefix:Hide() end - if resultsOverlay.widthBox.suffix then resultsOverlay.widthBox.suffix:Hide() end - - -- Wire results corner (width + height combo) - resultsOverlay.heightBox:SetText(currentH) - resultsOverlay.heightBox:SetScript("OnEnterPressed", function(self) + -- Wire unified window height. The search bar strip and result rows resize together. + barOverlay.heightBox:SetText(mfloor(GetUnifiedWindowHeight() + 0.5)) + barOverlay.heightBox:SetScript("OnEnterPressed", function(self) local val = tonumber(self:GetText()) if val then - val = mmax(MIN_HEIGHT, mmin(GetScreenMaxHeight(resultsAbove), mfloor(val + 0.5))) - SetResultsHeight(val) - previewResults:SetPreviewHeight(val) - self:SetText(val) + local ratio = GetSearchBarHeight() / mmax(1, GetUnifiedWindowHeight()) + SetUnifiedWindowHeight(val, ratio, previewResults, self, resultsAbove) + self:SetText(mfloor(GetUnifiedWindowHeight() + 0.5)) end self:ClearFocus() end) - resultsOverlay.heightBox:SetScript("OnEscapePressed", function(self) - self:SetText(GetResultsHeight()) + barOverlay.heightBox:SetScript("OnEscapePressed", function(self) + self:SetText(mfloor(GetUnifiedWindowHeight() + 0.5)) self:ClearFocus() end) - local heightReset = AddResetButton(resultsOverlay.heightBox, function() - local def = GetDefaultResultsHeight() - SetResultsHeight(def) - previewResults:SetPreviewHeight(def) - resultsOverlay.heightBox:SetText(def) + local heightReset = AddResetButton(barOverlay.heightBox, function() + local defTotal = DEFAULT_BAR_HEIGHT + GetDefaultResultsHeight() + local defRatio = DEFAULT_BAR_HEIGHT / defTotal + SetUnifiedWindowHeight(defTotal, defRatio, previewResults, barOverlay.heightBox, resultsAbove) + barOverlay.heightBox:SetText(mfloor(GetUnifiedWindowHeight() + 0.5)) end) - if resultsOverlay.anchorAbove then + if barOverlay.anchorAbove then heightReset:ClearAllPoints() - heightReset:SetPoint("BOTTOM", resultsOverlay.heightBox, "TOP", 0, 2) + heightReset:SetPoint("BOTTOM", barOverlay.heightBox, "TOP", 0, 2) end - if resultsOverlay.heightInside then + if barOverlay.heightInside then heightReset:ClearAllPoints() - if resultsOverlay.anchorAbove then - heightReset:SetPoint("TOP", resultsOverlay.heightBox, "BOTTOM", 0, -2) + if barOverlay.anchorAbove then + heightReset:SetPoint("TOP", barOverlay.heightBox, "BOTTOM", 0, -2) else - heightReset:SetPoint("BOTTOM", resultsOverlay.heightBox, "TOP", 0, 2) + heightReset:SetPoint("BOTTOM", barOverlay.heightBox, "TOP", 0, 2) end end - SetupHeightDrag(resultsOverlay.scaleHandle, previewResults, resultsOverlay.heightBox, resultsAbove) - - -- Wire results bottom edge (height) - SetupHeightDrag(resultsOverlay.heightHandle, previewResults, resultsOverlay.heightBox, resultsAbove) + SetupCornerDrag(barOverlay.scaleHandle, getBarWidth, setBarWidthAndPreview, barOverlay.widthBox, previewResults, barOverlay.heightBox, resultsAbove) + SetupHeightDrag(barOverlay.heightHandle, previewResults, barOverlay.heightBox, resultsAbove) -- Done panel donePanel = CreateDonePanel(bg) - donePanel:SetPoint("TOP", resultsOverlay, "BOTTOM", 0, -50) + donePanel:SetPoint("TOP", barOverlay, "BOTTOM", 0, -50) donePanel.doneBtn:SetScript("OnClick", function() Rescaler:Exit() end) @@ -824,24 +894,25 @@ function Rescaler:Exit(reopenOptions) -- Clean up all overlay frames if barOverlay then - barOverlay.leftHandle:SetScript("OnUpdate", nil) - barOverlay.rightHandle:SetScript("OnUpdate", nil) - barOverlay.bottomHandle:SetScript("OnUpdate", nil) + if barOverlay.leftHandle then barOverlay.leftHandle:SetScript("OnUpdate", nil) end + if barOverlay.rightHandle then barOverlay.rightHandle:SetScript("OnUpdate", nil) end + if barOverlay.heightHandle then barOverlay.heightHandle:SetScript("OnUpdate", nil) end + if barOverlay.scaleHandle then barOverlay.scaleHandle:SetScript("OnUpdate", nil) end barOverlay:Hide() barOverlay = nil end - if resultsOverlay then - resultsOverlay.leftHandle:SetScript("OnUpdate", nil) - resultsOverlay.rightHandle:SetScript("OnUpdate", nil) - resultsOverlay.scaleHandle:SetScript("OnUpdate", nil) - resultsOverlay.heightHandle:SetScript("OnUpdate", nil) - resultsOverlay:Hide() - resultsOverlay = nil - end if previewResults then previewResults:Hide() previewResults = nil end + if mockSearchBar then + mockSearchBar:Hide() + mockSearchBar = nil + end + if mockWindowFrame then + mockWindowFrame:Hide() + mockWindowFrame = nil + end if donePanel then donePanel:Hide() donePanel = nil @@ -856,12 +927,35 @@ function Rescaler:Exit(reopenOptions) activeSearchBar = nil end + if liveSearchBar then + liveSearchBar.setupMode = nil + liveSearchBar:SetAlpha((liveState and liveState.searchAlpha) or 1) + if liveState and liveState.searchShown then + liveSearchBar:Show() + else + liveSearchBar:Hide() + end + end + if liveResultsFrame then + if liveState and liveState.resultsShown then liveResultsFrame:Show() + else liveResultsFrame:Hide() end + end + if liveContainerFrame then + if liveState and liveState.containerShown then liveContainerFrame:Show() + else liveContainerFrame:Hide() end + end + if ns.UI then if ns.UI.UpdateScale then ns.UI:UpdateScale() end if ns.UI.UpdateWidth then ns.UI:UpdateWidth() end + if ns.UI.UpdateSearchBarHeight then ns.UI:UpdateSearchBarHeight() end if ns.UI.RefreshResults then ns.UI:RefreshResults() end end + liveSearchBar = nil + liveResultsFrame = nil + liveContainerFrame = nil + liveState = nil activeMode = nil if reopenOptions then diff --git a/UI.lua b/UI.lua index d15183a..6b82c3c 100644 --- a/UI.lua +++ b/UI.lua @@ -16,7 +16,6 @@ local mfloor = Utils.mfloor local sformat = Utils.sformat local GOLD_COLOR = ns.GOLD_COLOR -local DEFAULT_OPACITY = ns.DEFAULT_OPACITY local TOOLTIP_BORDER = ns.TOOLTIP_BORDER local CreateFrame = CreateFrame @@ -31,6 +30,16 @@ local wipe = wipe local REP_BAR_WIDTH = 100 +function UI:GetDefaultSearchBarPoint() + local parentH = UIParent and UIParent.GetHeight and UIParent:GetHeight() or 768 + return "CENTER", "CENTER", 0, parentH / 6 +end + +function UI:GetSearchBarHeight() + local h = EasyFind and EasyFind.db and EasyFind.db.uiSearchBarHeight or ns.SEARCHBAR_HEIGHT + return mmax(24, mmin(56, h or ns.SEARCHBAR_HEIGHT or 30)) +end + local CHARACTER_TAB_SUBFRAME = { [1] = "PaperDollFrame", [2] = "ReputationFrame", @@ -205,6 +214,7 @@ local FLAT_CATEGORY_ICONS = { -- glance from the silvery-grey game-settings cogwheel. settingAddon = { atlas = "QuestLog-icon-setting", color = { 1.0, 0.78, 0.35 } }, title = { tex = 514608, coords = { 0.016, 0.531, 0.324, 0.461 } }, + calculator = { tex = "Interface\\Icons\\INV_Misc_Note_05" }, -- Resolved lazily from PaperDollSidebarTab3 so the icon always -- matches whatever sprite-sheet region Blizzard uses for the -- Equipment Manager sidebar tab. Filled in by ResolveGearSetIcon(). @@ -251,6 +261,7 @@ local REP_FACTION_ICONS = { local function GetFlatCategoryIcon(data) if not data then return nil end + if data.calculatorResult then return FLAT_CATEGORY_ICONS.calculator end if data.mountID then return FLAT_CATEGORY_ICONS.mount end if data.toyItemID then return FLAT_CATEGORY_ICONS.toy end if data.petID then return FLAT_CATEGORY_ICONS.pet end @@ -350,6 +361,7 @@ end local function GetFlatSubtext(data) if not data then return "" end + if data.calculatorResult then return "Expression" end if data.path and #data.path > 0 then return data.path[#data.path] end @@ -391,6 +403,981 @@ local function GetFlatSubtext(data) return data.category or "" end +UI._calculator = { + PATH = { "Calculator" }, + FUNCTIONS = { + abs = true, acos = true, acosd = true, asin = true, asind = true, + atan = true, atan2 = true, atand = true, ceil = true, cos = true, + cosd = true, cosh = true, cot = true, cotd = true, csc = true, + cscd = true, exp = true, factorial = true, floor = true, ln = true, + log = true, max = true, min = true, mod = true, pow = true, round = true, + sec = true, secd = true, sin = true, sind = true, sinh = true, + sqrt = true, tan = true, tand = true, tanh = true, + }, +} + +function UI._calculator.IsFinite(value) + return type(value) == "number" and value == value + and value ~= math.huge and value ~= -math.huge +end + +function UI._calculator.ToRadians(value) + return value * math.pi / 180 +end + +function UI._calculator.ToDegrees(value) + return value * 180 / math.pi +end + +function UI._calculator.Round(value) + if value >= 0 then return math.floor(value + 0.5) end + return math.ceil(value - 0.5) +end + +function UI._calculator.Factorial(value) + if value < 0 or value ~= math.floor(value) or value > 170 then return nil end + local result = 1 + for i = 2, value do result = result * i end + return result +end + +function UI._calculator.Atan2(y, x) + if math.atan2 then return math.atan2(y, x) end + if x > 0 then + return math.atan(y / x) + elseif x < 0 and y >= 0 then + return math.atan(y / x) + math.pi + elseif x < 0 then + return math.atan(y / x) - math.pi + elseif y > 0 then + return math.pi / 2 + elseif y < 0 then + return -math.pi / 2 + end + return nil +end + +function UI._calculator.Sinh(value) + if math.sinh then return math.sinh(value) end + return (math.exp(value) - math.exp(-value)) / 2 +end + +function UI._calculator.Cosh(value) + if math.cosh then return math.cosh(value) end + return (math.exp(value) + math.exp(-value)) / 2 +end + +function UI._calculator.Tanh(value) + if math.tanh then return math.tanh(value) end + local pos = math.exp(value) + local neg = math.exp(-value) + return (pos - neg) / (pos + neg) +end + +function UI._calculator.ApplyFunction(name, args) + local argc = #args + local a, b = args[1], args[2] + local c = UI._calculator + + if name == "abs" then + if argc ~= 1 then return nil end + return math.abs(a) + elseif name == "sqrt" then + if argc ~= 1 or a < 0 then return nil end + return math.sqrt(a) + elseif name == "floor" then + if argc ~= 1 then return nil end + return math.floor(a) + elseif name == "ceil" then + if argc ~= 1 then return nil end + return math.ceil(a) + elseif name == "round" then + if argc ~= 1 then return nil end + return c.Round(a) + elseif name == "ln" then + if argc ~= 1 or a <= 0 then return nil end + return math.log(a) + elseif name == "log" then + if argc == 1 then + if a <= 0 then return nil end + return math.log(a) / math.log(10) + elseif argc == 2 then + if a <= 0 or a == 1 or b <= 0 then return nil end + return math.log(b) / math.log(a) + end + return nil + elseif name == "exp" then + if argc ~= 1 then return nil end + return math.exp(a) + elseif name == "pow" then + if argc ~= 2 then return nil end + return math.pow(a, b) + elseif name == "mod" then + if argc ~= 2 or b == 0 then return nil end + return math.fmod(a, b) + elseif name == "factorial" then + if argc ~= 1 then return nil end + return c.Factorial(a) + elseif name == "min" then + if argc < 1 then return nil end + local result = a + for i = 2, argc do if args[i] < result then result = args[i] end end + return result + elseif name == "max" then + if argc < 1 then return nil end + local result = a + for i = 2, argc do if args[i] > result then result = args[i] end end + return result + elseif name == "sin" then + if argc ~= 1 then return nil end + return math.sin(a) + elseif name == "cos" then + if argc ~= 1 then return nil end + return math.cos(a) + elseif name == "tan" then + if argc ~= 1 then return nil end + return math.tan(a) + elseif name == "sind" then + if argc ~= 1 then return nil end + return math.sin(c.ToRadians(a)) + elseif name == "cosd" then + if argc ~= 1 then return nil end + return math.cos(c.ToRadians(a)) + elseif name == "tand" then + if argc ~= 1 then return nil end + return math.tan(c.ToRadians(a)) + elseif name == "asin" then + if argc ~= 1 or a < -1 or a > 1 then return nil end + return math.asin(a) + elseif name == "acos" then + if argc ~= 1 or a < -1 or a > 1 then return nil end + return math.acos(a) + elseif name == "atan" then + if argc == 1 then return math.atan(a) end + if argc == 2 then return c.Atan2(a, b) end + return nil + elseif name == "asind" then + if argc ~= 1 or a < -1 or a > 1 then return nil end + return c.ToDegrees(math.asin(a)) + elseif name == "acosd" then + if argc ~= 1 or a < -1 or a > 1 then return nil end + return c.ToDegrees(math.acos(a)) + elseif name == "atand" then + if argc == 1 then return c.ToDegrees(math.atan(a)) end + if argc == 2 then + local v = c.Atan2(a, b) + return v and c.ToDegrees(v) or nil + end + return nil + elseif name == "atan2" then + if argc ~= 2 then return nil end + return c.Atan2(a, b) + elseif name == "sinh" then + if argc ~= 1 then return nil end + return c.Sinh(a) + elseif name == "cosh" then + if argc ~= 1 then return nil end + return c.Cosh(a) + elseif name == "tanh" then + if argc ~= 1 then return nil end + return c.Tanh(a) + elseif name == "sec" then + if argc ~= 1 then return nil end + local v = math.cos(a) + if v == 0 then return nil end + return 1 / v + elseif name == "csc" then + if argc ~= 1 then return nil end + local v = math.sin(a) + if v == 0 then return nil end + return 1 / v + elseif name == "cot" then + if argc ~= 1 then return nil end + local v = math.tan(a) + if v == 0 then return nil end + return 1 / v + elseif name == "secd" then + if argc ~= 1 then return nil end + local v = math.cos(c.ToRadians(a)) + if v == 0 then return nil end + return 1 / v + elseif name == "cscd" then + if argc ~= 1 then return nil end + local v = math.sin(c.ToRadians(a)) + if v == 0 then return nil end + return 1 / v + elseif name == "cotd" then + if argc ~= 1 then return nil end + local v = math.tan(c.ToRadians(a)) + if v == 0 then return nil end + return 1 / v + end + return nil +end + +function UI._calculator.LooksLikeInput(raw) + local text = raw and strtrim(raw) or "" + if text == "" or #text > 96 then return false end + + local lower = slower(text) + if lower == "pi" or lower == "tau" then return true end + + local hasNumber = sfind(lower, "%d") ~= nil + local hasConstant = sfind(lower, "%f[%a]pi%f[%A]") ~= nil + or sfind(lower, "%f[%a]tau%f[%A]") ~= nil + or sfind(lower, "%f[%a]e%f[%A]") ~= nil + if not hasNumber and not hasConstant then return false end + + if sfind(lower, "[%+%-%*/%^%%%!%(%)%,]") then return true end + if hasNumber and (sfind(lower, "%d%s*%a") or sfind(lower, "%d%s*%(") + or sfind(lower, "%)%s*%d") or sfind(lower, "%)%s*%a") + or sfind(lower, "%)%s*%(")) then + return true + end + + for name in pairs(UI._calculator.FUNCTIONS) do + if sfind(lower, "%f[%a]" .. name .. "%f[%A]") then return true end + end + return false +end + +function UI._calculator.Tokenize(text) + local tokens = {} + local i, n = 1, #text + + while i <= n do + local ch = text:sub(i, i) + if ch:match("%s") then + i = i + 1 + elseif ch == "," then + local prev = i > 1 and text:sub(i - 1, i - 1) or "" + local nextCh = i < n and text:sub(i + 1, i + 1) or "" + if prev:match("%d") and nextCh:match("%d") then + i = i + 1 + else + tokens[#tokens + 1] = { type = "op", value = "," } + i = i + 1 + end + elseif ch:match("%d") or ch == "." then + local parts = {} + local sawDigit, sawDot = false, false + while i <= n do + ch = text:sub(i, i) + if ch:match("%d") then + sawDigit = true + parts[#parts + 1] = ch + i = i + 1 + elseif ch == "." and not sawDot then + sawDot = true + parts[#parts + 1] = ch + i = i + 1 + elseif ch == "," then + local nextCh = i < n and text:sub(i + 1, i + 1) or "" + if nextCh:match("%d") then i = i + 1 else break end + else + break + end + end + if not sawDigit then return nil end + + if i <= n and text:sub(i, i):lower() == "e" then + local expStart = i + local expParts = { "e" } + i = i + 1 + ch = i <= n and text:sub(i, i) or "" + if ch == "+" or ch == "-" then + expParts[#expParts + 1] = ch + i = i + 1 + end + local expDigits = false + while i <= n do + ch = text:sub(i, i) + if not ch:match("%d") then break end + expDigits = true + expParts[#expParts + 1] = ch + i = i + 1 + end + if expDigits then + for pi = 1, #expParts do parts[#parts + 1] = expParts[pi] end + else + i = expStart + end + end + + local value = tonumber(tconcat(parts)) + if not value then return nil end + tokens[#tokens + 1] = { type = "number", value = value } + elseif ch:match("%a") then + local start = i + repeat + i = i + 1 + ch = i <= n and text:sub(i, i) or "" + until ch == "" or not ch:match("[%w_]") + tokens[#tokens + 1] = { type = "ident", value = slower(text:sub(start, i - 1)) } + elseif ch == "+" or ch == "-" or ch == "*" or ch == "/" + or ch == "^" or ch == "%" or ch == "!" or ch == "(" or ch == ")" then + tokens[#tokens + 1] = { type = "op", value = ch } + i = i + 1 + else + return nil + end + end + + tokens[#tokens + 1] = { type = "eof" } + return tokens +end + +function UI._calculator.Parse(tokens) + local pos = 1 + local c = UI._calculator + local parseExpression, parseAdd, parseMul, parseUnary, parsePower, parsePostfix, parsePrimary + + local function fail() + error("calculator parse failed", 0) + end + + local function current() + return tokens[pos] + end + + local function acceptOp(op) + local tok = current() + if tok and tok.type == "op" and tok.value == op then + pos = pos + 1 + return true + end + return false + end + + local function isImplicitStart(tok) + if not tok then return false end + if tok.type == "number" then return true end + if tok.type == "op" and tok.value == "(" then return true end + if tok.type == "ident" then + return tok.value ~= "deg" and tok.value ~= "degree" and tok.value ~= "degrees" + and tok.value ~= "rad" and tok.value ~= "radian" and tok.value ~= "radians" + and tok.value ~= "mod" and tok.value ~= "x" and tok.value ~= "times" + end + return false + end + + parseExpression = function() + return parseAdd() + end + + parseAdd = function() + local left = parseMul() + while true do + if acceptOp("+") then + left = left + parseMul() + elseif acceptOp("-") then + left = left - parseMul() + else + return left + end + end + end + + parseMul = function() + local left = parseUnary() + while true do + local tok = current() + if acceptOp("*") then + left = left * parseUnary() + elseif acceptOp("/") then + local right = parseUnary() + if right == 0 then fail() end + left = left / right + elseif tok and tok.type == "ident" + and (tok.value == "mod" or tok.value == "x" or tok.value == "times") then + pos = pos + 1 + local right = parseUnary() + if tok.value == "mod" then + if right == 0 then fail() end + left = math.fmod(left, right) + else + left = left * right + end + elseif isImplicitStart(tok) then + left = left * parseUnary() + else + return left + end + end + end + + parseUnary = function() + if acceptOp("+") then return parseUnary() end + if acceptOp("-") then return -parseUnary() end + + local tok = current() + if tok and tok.type == "ident" and c.FUNCTIONS[tok.value] then + local name = tok.value + pos = pos + 1 + local args = {} + if acceptOp("(") then + if not acceptOp(")") then + repeat + args[#args + 1] = parseExpression() + until not acceptOp(",") + if not acceptOp(")") then fail() end + end + else + args[1] = parseUnary() + end + local value = c.ApplyFunction(name, args) + if value == nil or not c.IsFinite(value) then fail() end + return value + end + + return parsePower() + end + + parsePower = function() + local left = parsePostfix() + if acceptOp("^") then + left = math.pow(left, parseUnary()) + if not c.IsFinite(left) then fail() end + end + return left + end + + parsePostfix = function() + local value = parsePrimary() + while true do + local tok = current() + if acceptOp("!") then + value = c.Factorial(value) + if value == nil then fail() end + elseif acceptOp("%") then + value = value / 100 + elseif tok and tok.type == "ident" + and (tok.value == "deg" or tok.value == "degree" or tok.value == "degrees") then + pos = pos + 1 + value = c.ToRadians(value) + elseif tok and tok.type == "ident" + and (tok.value == "rad" or tok.value == "radian" or tok.value == "radians") then + pos = pos + 1 + else + return value + end + if not c.IsFinite(value) then fail() end + end + end + + parsePrimary = function() + local tok = current() + if not tok then fail() end + if tok.type == "number" then + pos = pos + 1 + return tok.value + elseif tok.type == "ident" then + pos = pos + 1 + if tok.value == "pi" then + return math.pi + elseif tok.value == "tau" then + return math.pi * 2 + elseif tok.value == "e" then + return math.exp(1) + end + fail() + elseif acceptOp("(") then + local value = parseExpression() + if not acceptOp(")") then fail() end + return value + end + fail() + end + + local value = parseExpression() + local tok = current() + if not tok or tok.type ~= "eof" then fail() end + if not c.IsFinite(value) then fail() end + return value +end + +function UI._calculator.Format(value) + if math.abs(value) < 0.000000000001 then value = 0 end + local nearest = UI._calculator.Round(value) + if math.abs(value - nearest) < 0.0000000001 then + return tostring(nearest) + end + return sformat("%.12g", value) +end + +function UI:EvaluateCalculatorExpression(raw) + local c = UI._calculator + if not c.LooksLikeInput(raw) then return nil end + local expression = strtrim(raw) + local tokens = c.Tokenize(expression) + if not tokens then return nil end + + local ok, value = pcall(c.Parse, tokens) + if not ok or not c.IsFinite(value) then return nil end + + local result = c.Format(value) + return { + name = expression, + nameLower = slower(expression), + category = "Calculator", + path = c.PATH, + noPin = true, + calculatorExpression = expression, + calculatorResult = result, + calculatorValue = value, + } +end + +function UI:EnsureCalculatorCopyBox() + local box = UI._calculator.copyBox + if box then return box end + + box = CreateFrame("EditBox", "EasyFindCalculatorCopyBox", UIParent) + box:SetSize(1, 1) + -- Match the copy pattern used by chat-copy addons: the edit box must be + -- on-screen, focused, and selected. Off-screen boxes do not reliably feed + -- the client/OS copy path. + box:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 0, 0) + box:SetAlpha(0) + box:SetFontObject(GameFontNormal) + box:SetAutoFocus(false) + box:EnableKeyboard(false) + box:SetMaxLetters(0) + box:SetScript("OnKeyDown", function(self, key) + if UI:HandleCalculatorCopyKey(key) then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + end + if UI:HandleCalculatorCopyConfirmKey(key) then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + end + end) + box:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + box:SetScript("OnEnterPressed", function(self) self:ClearFocus() end) + box:SetScript("OnEditFocusLost", function() + UI._calculator.copyFocused = nil + if not UI._calculator.releasingCopyBox then + UI._calculator.prevFocus = nil + end + end) + UI._calculator.copyBox = box + return box +end + +function UI:ReleaseCalculatorCopyBox(source) + if source and UI._calculator.copySource ~= source then return end + + UI._calculator.copyToken = (UI._calculator.copyToken or 0) + 1 + UI._calculator.copySource = nil + + local prev = UI._calculator.prevFocus + local box = UI._calculator.copyBox + UI._calculator.releasingCopyBox = true + if box then + box:HighlightText(0, 0) + box:ClearFocus() + end + UI._calculator.releasingCopyBox = nil + UI._calculator.copyFocused = nil + + UI._calculator.prevFocus = nil + if not (prev and prev.SetFocus and prev.IsVisible and prev:IsVisible()) then + prev = searchFrame and searchFrame.editBox + end + if prev and prev.SetFocus and prev.IsVisible and prev:IsVisible() then + prev:SetFocus() + end +end + +function UI:CopyCalculatorResult(result, source) + if not result or result == "" then return false end + + local box = self:EnsureCalculatorCopyBox() + if not UI._calculator.copyFocused then + local cur = GetCurrentKeyBoardFocus and GetCurrentKeyBoardFocus() + if cur and cur ~= box then + UI._calculator.prevFocus = cur + end + end + UI._calculator.copySource = source or "click" + box:SetText(result) + box:SetCursorPosition(0) + box:HighlightText(0, -1) + box:SetFocus() + UI._calculator.copyFocused = true + + UI._calculator.copyToken = (UI._calculator.copyToken or 0) + 1 + local token = UI._calculator.copyToken + if C_Timer and C_Timer.After then + C_Timer.After(0, function() + local copyBox = UI._calculator.copyBox + if UI._calculator.copyToken ~= token or not copyBox then return end + if copyBox:GetText() ~= result then return end + copyBox:SetFocus() + copyBox:HighlightText(0, -1) + end) + end + return true +end + +function UI:RearmActiveCalculatorCopy(source) + local result = UI._calculator.activeResult + if not result or result == "" then return false end + if source == "ctrl" and UI._calculator.copyComplete then return false end + return self:CopyCalculatorResult(result, source or "active") +end + +function UI:EnsureCalculatorCopyWatcher() + if UI._calculator.copyWatcher then return UI._calculator.copyWatcher end + + local frame = CreateFrame("Frame") + frame:RegisterEvent("MODIFIER_STATE_CHANGED") + frame:Hide() + frame:SetScript("OnEvent", function(_, _, key, down) + if not UI._calculator.activeData or not UI._calculator.activeResult then return end + if key ~= "LCTRL" and key ~= "RCTRL" then return end + + if down == 1 and IsControlKeyDown and IsControlKeyDown() then + UI._calculator.ctrlWasDown = true + UI:RearmActiveCalculatorCopy("ctrl") + elseif down == 0 and IsControlKeyDown and not IsControlKeyDown() then + UI._calculator.ctrlWasDown = nil + UI._calculator.copyKeyWasDown = nil + UI:ReleaseCalculatorCopyBox() + end + end) + frame:SetScript("OnUpdate", function(self) + if not UI._calculator.activeData or not UI._calculator.activeResult then + UI._calculator.ctrlWasDown = nil + UI._calculator.copyKeyWasDown = nil + self:Hide() + return + end + + local ctrlDown = IsControlKeyDown and IsControlKeyDown() + if ctrlDown and not UI._calculator.ctrlWasDown then + UI:RearmActiveCalculatorCopy("ctrl") + end + UI._calculator.ctrlWasDown = ctrlDown and true or nil + + local copyDown = ctrlDown and IsKeyDown + and ((IsKeyDown("C") or IsKeyDown("c")) and true or false) + if copyDown and not UI._calculator.copyKeyWasDown then + UI:RearmActiveCalculatorCopy("confirm") + if C_Timer and C_Timer.After then + C_Timer.After(0, function() + UI:ConfirmCalculatorCopied() + end) + else + UI:ConfirmCalculatorCopied() + end + end + UI._calculator.copyKeyWasDown = copyDown and true or nil + end) + UI._calculator.copyWatcher = frame + return frame +end + +function UI:StartCalculatorCopyWatcher() + local frame = self:EnsureCalculatorCopyWatcher() + frame:Show() +end + +function UI:GetCalculatorPartValue(row, part) + local data = row and row.data + if not data or not data.calculatorResult then return nil end + if part == "expression" then + return data.calculatorExpression or data.name + end + return data.calculatorResult +end + +function UI:SetCalculatorCopyHighlight(row, part) + if not row then return end + local expressionActive = part == "expression" + local resultActive = part == "result" + local expressionCopied = expressionActive + and UI._calculator.copiedData == row.data + and UI._calculator.copiedPart == "expression" + local resultCopied = resultActive + and UI._calculator.copiedData == row.data + and UI._calculator.copiedPart == "result" + if row.calcExpressionHighlight then + row.calcExpressionHighlight:SetShown(expressionActive) + end + if row.calcResultHighlight then + row.calcResultHighlight:SetShown(resultActive) + end + if row.calcExpressionHint then + row.calcExpressionHint:SetText(expressionCopied and "Now Ctrl+V to paste" or "Ctrl+C to copy") + if expressionCopied then + row.calcExpressionHint:SetTextColor(0.48, 1.0, 0.62, 1.0) + else + row.calcExpressionHint:SetTextColor(0.72, 0.72, 0.72, 1.0) + end + row.calcExpressionHint:SetShown(expressionActive) + end + if row.calcResultHint then + row.calcResultHint:SetText(resultCopied and "Now Ctrl+V to paste" or "Ctrl+C to copy") + if resultCopied then + row.calcResultHint:SetTextColor(0.48, 1.0, 0.62, 1.0) + else + row.calcResultHint:SetTextColor(0.72, 0.72, 0.72, 1.0) + end + row.calcResultHint:SetShown(resultActive) + end + if row.calcExpressionText then + row.calcExpressionText:ClearAllPoints() + row.calcExpressionText:SetPoint("LEFT", row.calcCard, "LEFT", 12, expressionActive and 5 or 0) + row.calcExpressionText:SetPoint("RIGHT", row.calcDivider, "LEFT", -22, expressionActive and 5 or 0) + if expressionActive then + row.calcExpressionText:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) + else + row.calcExpressionText:SetTextColor(0.96, 0.96, 0.96, 1.0) + end + end + if row.calcResultText then + row.calcResultText:ClearAllPoints() + row.calcResultText:SetPoint("LEFT", row.calcDivider, "RIGHT", 22, resultActive and 5 or 0) + row.calcResultText:SetPoint("RIGHT", row.calcCard, "RIGHT", -12, resultActive and 5 or 0) + if resultActive then + row.calcResultText:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) + else + row.calcResultText:SetTextColor(0.96, 0.96, 0.96, 1.0) + end + end +end + +function UI:ClearCalculatorCopyHighlight(row) + row = row or UI._calculator.activeRow + if row then + self:SetCalculatorCopyHighlight(row, nil) + end + if not row or UI._calculator.activeRow == row then + UI._calculator.activeRow = nil + UI._calculator.activeData = nil + UI._calculator.activeResult = nil + UI._calculator.activePart = nil + UI._calculator.activeSource = nil + UI._calculator.copyComplete = nil + UI._calculator.copyCompleteValue = nil + UI._calculator.copiedData = nil + UI._calculator.copiedPart = nil + if UI._calculator.copyWatcher then + UI._calculator.copyWatcher:Hide() + end + UI._calculator.ctrlWasDown = nil + UI._calculator.copyKeyWasDown = nil + end +end + +function UI:ArmCalculatorPartFromRow(row, part, source) + if not row or not row:IsShown() or not row.data or not row.data.calculatorResult then + return false + end + source = source or "click" + part = part == "expression" and "expression" or "result" + local value = self:GetCalculatorPartValue(row, part) + if not value or value == "" then return false end + + local completedHover = source == "hover" and UI._calculator.copyComplete + if source ~= "ctrl" and source ~= "confirm" and not completedHover then + UI._calculator.copyComplete = nil + UI._calculator.copyCompleteValue = nil + end + if UI._calculator.activeData ~= row.data or UI._calculator.activePart ~= part then + UI._calculator.copiedData = nil + UI._calculator.copiedPart = nil + end + if UI._calculator.activeRow and UI._calculator.activeRow ~= row then + self:SetCalculatorCopyHighlight(UI._calculator.activeRow, nil) + end + if source ~= "hover" or ((IsControlKeyDown and IsControlKeyDown()) and not UI._calculator.copyComplete) then + if not self:CopyCalculatorResult(value, source) then + return false + end + end + UI._calculator.activeRow = row + UI._calculator.activeData = row.data + UI._calculator.activeResult = value + UI._calculator.activePart = part + UI._calculator.activeSource = source + self:SetCalculatorCopyHighlight(row, part) + self:StartCalculatorCopyWatcher() + return true +end + +function UI:ArmCalculatorResultFromRow(row, source) + return self:ArmCalculatorPartFromRow(row, "result", source or "click") +end + +function UI:ArmCalculatorResultForData(data, source) + if not data or not data.calculatorResult then return false end + for i = 1, #resultButtons do + local row = resultButtons[i] + if row and row:IsShown() and row.data == data then + return self:ArmCalculatorResultFromRow(row, source or "click") + end + end + source = source or "click" + if self:CopyCalculatorResult(data.calculatorResult, source) then + UI._calculator.activeRow = nil + UI._calculator.activeData = data + UI._calculator.activeResult = data.calculatorResult + UI._calculator.activePart = "result" + UI._calculator.activeSource = source + UI._calculator.copyComplete = nil + UI._calculator.copyCompleteValue = nil + UI._calculator.copiedData = nil + UI._calculator.copiedPart = nil + self:StartCalculatorCopyWatcher() + return true + end + return false +end + +function UI:PlayCalculatorCopyFlash(row, part) + if not row then return end + local tex = part == "expression" and row.calcExpressionFlash or row.calcResultFlash + if not tex then return end + tex:SetAlpha(0.0) + tex:Show() + if tex.anim then + if tex.anim:IsPlaying() then tex.anim:Stop() end + tex.anim:Play() + elseif C_Timer and C_Timer.After then + tex:SetAlpha(0.35) + C_Timer.After(0.35, function() + if tex then + tex:SetAlpha(0.0) + tex:Hide() + end + end) + end +end + +function UI:ConfirmCalculatorCopied() + local data = UI._calculator.activeData + local part = UI._calculator.activePart + if not data or (part ~= "expression" and part ~= "result") then return false end + self:RearmActiveCalculatorCopy("confirm") + + UI._calculator.copiedData = data + UI._calculator.copiedPart = part + UI._calculator.copyComplete = true + UI._calculator.copyCompleteValue = UI._calculator.activeResult + + local row = UI._calculator.activeRow + if not (row and row:IsShown() and row.data == data) then + row = nil + for i = 1, #resultButtons do + local candidate = resultButtons[i] + if candidate and candidate:IsShown() and candidate.data == data then + row = candidate + UI._calculator.activeRow = candidate + break + end + end + end + + if row then + self:SetCalculatorCopyHighlight(row, part) + self:PlayCalculatorCopyFlash(row, part) + end + if C_Timer and C_Timer.After then + C_Timer.After(0, function() + UI:ReleaseCalculatorCopyBox("confirm") + end) + else + self:ReleaseCalculatorCopyBox("confirm") + end + return true +end + +function UI:RestoreCalculatorTarget(row) + if row and UI._calculator.activeData == row.data and UI._calculator.activeSource == "hover" then + self:ClearCalculatorCopyHighlight(row) + self:ReleaseCalculatorCopyBox() + elseif row and UI._calculator.activeData == row.data then + self:SetCalculatorCopyHighlight(row, UI._calculator.activePart) + elseif row then + self:SetCalculatorCopyHighlight(row, nil) + end +end + +function UI:HoverCalculatorTarget(row, part) + if not row or not row.data or not row.data.calculatorResult then return end + if UI._calculator.activeSource ~= "hover" and UI._calculator.activeData then + self:ClearCalculatorCopyHighlight(UI._calculator.activeRow) + self:ReleaseCalculatorCopyBox() + end + self:ArmCalculatorPartFromRow(row, part, "hover") +end + +function UI:IsCalculatorCopyConfirmKey(key) + return UI._calculator.activeData + and IsControlKeyDown and IsControlKeyDown() + and (key == "C" or key == "c") +end + +function UI:HandleCalculatorCopyConfirmKey(key) + if not self:IsCalculatorCopyConfirmKey(key) then return false end + return self:ConfirmCalculatorCopied() +end + +function UI:IsCalculatorCopyKey(key) + if not UI._calculator.activeData then return false end + if key == "LEFT" or key == "ARROWLEFT" or key == "RIGHT" or key == "ARROWRIGHT" then + return true + end + return IsControlKeyDown and IsControlKeyDown() and (key == "H" or key == "L") +end + +function UI:HandleCalculatorCopyKey(key) + if not self:IsCalculatorCopyKey(key) then return false end + local row = UI._calculator.activeRow + if not (row and row:IsShown() and row.data == UI._calculator.activeData) then + for i = 1, #resultButtons do + local candidate = resultButtons[i] + if candidate and candidate:IsShown() and candidate.data == UI._calculator.activeData then + row = candidate + UI._calculator.activeRow = candidate + break + end + end + end + if not row then return true end + + if key == "LEFT" or key == "ARROWLEFT" or key == "H" then + self:ArmCalculatorPartFromRow(row, "expression", "key") + else + self:ArmCalculatorPartFromRow(row, "result", "key") + end + return true +end + +function UI:HandleCalculatorPasteIntoSearch(editBox, key) + if not editBox or not UI._calculator.copyCompleteValue then return false end + if not (IsControlKeyDown and IsControlKeyDown()) then return false end + if key ~= "V" and key ~= "v" then return false end + + local expected = UI._calculator.copyCompleteValue + if expected == "" then return false end + if C_Timer and C_Timer.After then + C_Timer.After(0, function() + if not searchFrame or searchFrame.editBox ~= editBox then return end + if not editBox:IsVisible() then return end + local current = editBox:GetText() or "" + if current ~= expected and strtrim(current) ~= strtrim(expected) then return end + if editBox.ResetPendingSearch then editBox:ResetPendingSearch() end + UI:HideResults() + editBox:SetFocus() + editBox:SetCursorPosition(#current) + end) + end + return false +end + local function IsSpellbookOnlyAbility(data) return data and data.category == "Ability" and data.spellID and data.isSpellbookOnly end @@ -597,6 +1584,7 @@ end local function GetActionHint(data) if not data then return nil end + if data.calculatorResult then return "Select result for Ctrl+C" end if data.titleID then return "Select to apply as your title" end if data.mountID then return "Select to summon mount" end if data.petID or data.speciesID then return "Select to summon pet | Ctrl+click to show in journal" end @@ -892,9 +1880,17 @@ local function ApplyResultRowFonts(row, theme) ScaleFont(row.sectionLabelText, "GameFontNormalSmall") ScaleFont(row.pathSubtext, theme.leafFont) ScaleFont(row.amountText, "GameFontNormalSmall") + ScaleFont(row.calcExpressionText, "GameFontHighlightLarge") + ScaleFont(row.calcArrowText, "GameFontHighlight") + ScaleFont(row.calcResultText, "GameFontHighlightLarge") + ScaleFont(row.calcExpressionHint, "GameFontDisableSmall") + ScaleFont(row.calcResultHint, "GameFontDisableSmall") ScaleFont(row.repBarText, "GameFontNormalSmall") ScaleFont(row.settingSliderValue, "GameFontNormalSmall") ScaleFont(row.shortcutNumberText, "GameFontDisableSmall") + if UI.LayoutResultShortcut then + UI:LayoutResultShortcut(row) + end end local function IsSecureActionResult(data) @@ -926,6 +1922,35 @@ local function ShouldShowResultShortcutHints() return not (EasyFind and EasyFind.db and EasyFind.db.showResultShortcutHints == false) end +function UI:LayoutResultShortcut(row) + if not (row and row.shortcutGroup) then return end + local scale = EasyFind.db.fontSize or 1.0 + local extra = mmax(0, scale - 1.0) + local numberW = mmax(9, mfloor(8 * scale + 5)) + local groupW = mmax(RESULT_SHORTCUT_WIDTH, RESULT_SHORTCUT_ICON_SIZE + numberW + 14 + mfloor(extra * 12 + 0.5)) + local rightPad = RESULT_SHORTCUT_RIGHT_PAD + mfloor(extra * 14 + 0.5) + local groupH = mmax(16, mfloor(16 * mmin(scale, 1.35) + 0.5)) + + row.shortcutGroup:ClearAllPoints() + row.shortcutGroup:SetPoint("RIGHT", row, "RIGHT", -rightPad, 0) + row.shortcutGroup:SetSize(groupW, groupH) + + if row.shortcutNumberText then + row.shortcutNumberText:ClearAllPoints() + row.shortcutNumberText:SetPoint("RIGHT", row.shortcutGroup, "RIGHT", 0, 0) + row.shortcutNumberText:SetWidth(numberW) + row.shortcutNumberText:SetJustifyH("RIGHT") + if row.shortcutNumberText.SetWordWrap then row.shortcutNumberText:SetWordWrap(false) end + if row.shortcutNumberText.SetNonSpaceWrap then row.shortcutNumberText:SetNonSpaceWrap(false) end + if row.shortcutNumberText.SetMaxLines then row.shortcutNumberText:SetMaxLines(1) end + end + if row.shortcutAltIcon and row.shortcutNumberText then + row.shortcutAltIcon:ClearAllPoints() + row.shortcutAltIcon:SetSize(RESULT_SHORTCUT_ICON_SIZE, RESULT_SHORTCUT_ICON_SIZE) + row.shortcutAltIcon:SetPoint("RIGHT", row.shortcutNumberText, "LEFT", 2, 0) + end +end + function UI:CreateUnearnedTooltip() -- Create simple tooltip frame unearnedTooltip = CreateFrame("Frame", "EasyFindUnearnedTooltip", UIParent, "BackdropTemplate") @@ -1051,7 +2076,8 @@ end function UI:CreateSearchFrame() searchFrame = CreateFrame("Frame", "EasyFindSearchFrame", UIParent, "BackdropTemplate") UI.searchFrame = searchFrame - searchFrame:SetSize(250, ns.SEARCHBAR_HEIGHT) + local barH = self:GetSearchBarHeight() + searchFrame:SetSize(250, barH) -- FULLSCREEN_DIALOG keeps the search bar above the default UI's -- DIALOG-strata menus (Game Menu, Options panel, etc.) so opening -- the bar from inside any in-game menu still puts our results on @@ -1066,7 +2092,8 @@ function UI:CreateSearchFrame() local pos = EasyFind.db.uiSearchPosition searchFrame:SetPoint(pos[1], UIParent, pos[2], pos[3], pos[4]) else - searchFrame:SetPoint("TOP", UIParent, "TOP", 0, -12) + local point, relPoint, x, y = self:GetDefaultSearchBarPoint() + searchFrame:SetPoint(point, UIParent, relPoint, x, y) end local theme = GetActiveTheme() @@ -1108,10 +2135,10 @@ function UI:CreateSearchFrame() -- doesn't crash, but the textures stay invisible. ns.SetSearchBorderShown(searchFrame, false) ns.SetRoundedRectBorderShown(containerFrame, true) - ns.SetRoundedRectBorderBgAlpha(containerFrame, EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) + ns.SetRoundedRectBorderBgAlpha(containerFrame, EasyFind.db.searchBarOpacity or ns.DEFAULT_OPACITY) -- Static magnifying-glass icon (non-interactive, flush left) - local contentSz = ns.SEARCHBAR_HEIGHT * ns.SEARCHBAR_FILL + local contentSz = barH * ns.SEARCHBAR_FILL local iconSz = contentSz * ns.SEARCHBAR_ICON_SCALE local iconHolder = CreateFrame("Frame", nil, searchFrame) @@ -1301,6 +2328,16 @@ function UI:CreateSearchFrame() editBox.UpdateAutocomplete() end end) + local function ResetPendingUISearch() + pendingUISearchFrame:Hide() + pendingUISearchText = "" + pendingUISearchGrew = false + pendingUISearchDue = 0 + lastTypedLen = 0 + historyIndex = 0 + historyDraft = "" + end + editBox.ResetPendingSearch = ResetPendingUISearch local SEARCH_THROTTLE = 0.05 -- 50ms cap on search/render frequency editBox:SetScript("OnTextChanged", function(self, userInput) self.placeholder:SetShown(self:GetText() == "") @@ -1313,7 +2350,12 @@ function UI:CreateSearchFrame() -- / Clear / etc. Without this gate every keystroke produces -- two searches (the user's and the autocomplete suffix's), -- doubling per-keystroke cost. - if not userInput then return end + if not userInput then + if self:GetText() == "" and self.ResetPendingSearch then + self:ResetPendingSearch() + end + return + end if self.IsAutocompleteBackspaceStrip and self:IsAutocompleteBackspaceStrip() then return end historyIndex = 0 historyDraft = "" @@ -1403,9 +2445,13 @@ function UI:CreateSearchFrame() selectedIndex = idx toggleFocused = false UI:UpdateSelectionHighlight() + local row = resultButtons[idx] + if row and row.data and row.data.calculatorResult then + UI:ArmCalculatorResultFromRow(row, "key") + end return end - UI:ActivateSelected() + UI:ActivateSelected("key") end) editBox:SetScript("OnEscapePressed", function(_) @@ -1618,6 +2664,8 @@ function UI:CreateSearchFrame() -- editbox, otherwise it just types as a character and the user -- can't dismiss with the same key they used to open. editBox:SetScript("OnKeyDown", function(self, key) + UI:HandleCalculatorPasteIntoSearch(self, key) + local shortcutIndex = GetResultShortcutIndex(key) if shortcutIndex then local shortcutResult = UI:ActivateVisibleResultShortcut(shortcutIndex) @@ -1836,6 +2884,9 @@ function UI:CreateSearchFrame() if key == "LSHIFT" or key == "RSHIFT" or key == "LCTRL" or key == "RCTRL" or key == "LALT" or key == "RALT" then return end + if UI:HandleCalculatorCopyConfirmKey(key) then return end + if UI:HandleCalculatorCopyKey(key) then return end + -- Ctrl+H/J/K/L: vim-style nav aliases. J/K = down/up (also -- N/P emacs-style); add Shift to jump sections like Shift+Up/Down. -- H/L = focus cycle (Shift+Tab / Tab). @@ -1942,7 +2993,7 @@ function UI:CreateSearchFrame() local target = controls[toolbarFocus] if target then target:Click() end else - UI:ActivateSelected() + UI:ActivateSelected("key") end elseif key == "ESCAPE" then -- Reset nav state inline (toolbarFocus / selectedIndex / @@ -1980,6 +3031,22 @@ function UI:CreateSearchFrame() end navFrame:SetScript("OnKeyDown", function(self, key) + if UI:IsCalculatorCopyConfirmKey(key) then + UI:RearmActiveCalculatorCopy("confirm") + if C_Timer and C_Timer.After then + C_Timer.After(0, function() + UI:ConfirmCalculatorCopied() + end) + else + UI:ConfirmCalculatorCopied() + end + -- Ctrl+C must not leak to gameplay keybinds. The hidden editbox is + -- already focused and selected; swallowing propagation still lets + -- the client copy that selected text. + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + end + local shortcutIndex = GetResultShortcutIndex(key) if shortcutIndex then local shortcutResult = UI:ActivateVisibleResultShortcut(shortcutIndex) @@ -2014,6 +3081,8 @@ function UI:CreateSearchFrame() or key == "HOME" or key == "END" or key == "TAB" or key == "ENTER" or key == "ESCAPE" then consume = true + elseif UI:IsCalculatorCopyKey(key) then + consume = true elseif IsControlKeyDown() and (key == "J" or key == "N" or key == "K" or key == "P" or key == "L" or key == "H") then consume = true @@ -3122,7 +4191,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) subRow:HookScript("OnEnter", function() if optHideTimer then optHideTimer:Cancel(); optHideTimer = nil end syncOptions() - optionsPopup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + optionsPopup:SetScale(EasyFind.db.uiSearchScale or 1.0) optionsPopup:ClearAllPoints() optionsPopup:SetPoint("TOPLEFT", subRow, "TOPRIGHT", 4, 0) optionsPopup:Show() @@ -3217,7 +4286,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) if hideTimer then hideTimer:Cancel(); hideTimer = nil end SetActiveFlyout(popup) SyncSubChecks() - popup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + popup:SetScale(EasyFind.db.uiSearchScale or 1.0) PositionPopup() popup:Show() end @@ -3411,7 +4480,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) if hideTimer then hideTimer:Cancel(); hideTimer = nil end SetActiveFlyout(popup) SyncRadio() - popup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + popup:SetScale(EasyFind.db.uiSearchScale or 1.0) PositionPopup() popup:Show() end @@ -3602,7 +4671,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) diffPopup:Hide() else SyncDiffRadios() - diffPopup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + diffPopup:SetScale(EasyFind.db.uiSearchScale or 1.0) diffPopup:ClearAllPoints() diffPopup:SetPoint("TOPLEFT", diffBtn, "BOTTOMLEFT", 0, 2) diffPopup:Show() @@ -3877,7 +4946,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) csHL:SetColorTexture(1, 1, 1, 0.1) local function OpenClassFlyout() LayoutClassFlyout() - classFlyout:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + classFlyout:SetScale(EasyFind.db.uiSearchScale or 1.0) classFlyout:ClearAllPoints() classFlyout:SetPoint("TOPLEFT", classSelectBtn, "TOPRIGHT", 2, 6) classFlyout:Show() @@ -4038,7 +5107,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) specPopup:Hide() else LayoutSpecPopup() - specPopup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + specPopup:SetScale(EasyFind.db.uiSearchScale or 1.0) specPopup:ClearAllPoints() specPopup:SetPoint("TOPLEFT", specSelectRow, "BOTTOMLEFT", 0, 2) specPopup:Show() @@ -4162,7 +5231,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) sr:SetChecked(EasyFind.db[sr.dbKey] ~= false) end end - gearOptionsPopup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + gearOptionsPopup:SetScale(EasyFind.db.uiSearchScale or 1.0) gearOptionsPopup:ClearAllPoints() gearOptionsPopup:SetPoint("TOPLEFT", row, "TOPRIGHT", 4, 0) gearOptionsPopup:Show() @@ -4484,7 +5553,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) if dropdown:IsShown() then dropdown:Hide() else - local barScale = (EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0) + local barScale = EasyFind.db.uiSearchScale or 1.0 dropdown:SetScale(barScale) local scale = anchorFrame:GetEffectiveScale() / (UIParent:GetEffectiveScale() * barScale) local right = anchorFrame:GetRight() * scale @@ -4585,9 +5654,9 @@ end local function ApplyResultShortcutGutter(row) if not row or not row.shortcutGroup then return end - row.shortcutGroup:ClearAllPoints() - row.shortcutGroup:SetPoint("RIGHT", row, "RIGHT", -RESULT_SHORTCUT_RIGHT_PAD, 0) - row.shortcutGroup:SetSize(RESULT_SHORTCUT_WIDTH, 16) + if UI.LayoutResultShortcut then + UI:LayoutResultShortcut(row) + end ReplaceRightPointToShortcut(row, row.icon) ReplaceRightPointToShortcut(row, row.amountText) @@ -4695,7 +5764,7 @@ function UI:ActivateVisibleResultShortcut(shortcutIndex) selectedIndex = i toggleFocused = false self:UpdateSelectionHighlight(true) - self:ActivateResultRow(row) + self:ActivateResultRow(row, "key") return "handled" end end @@ -5039,6 +6108,7 @@ local SCRATCH = { isLastChild = {}, catSepYPositions = {}, aliasSeen = {}, + calculatorResults = {}, filteredResults = {}, skipCategories = {}, } @@ -5471,9 +6541,143 @@ function UI:CreateResultButton(index) amountText:Hide() resultRow.amountText = amountText + local calcCard = CreateFrame("Frame", nil, resultRow) + ns.CreateRoundedRectBorder(calcCard) + ns.SetRoundedRectBarHeight(calcCard, 12) + for _, t in pairs(calcCard.combinedBorder.fill) do + t:SetVertexColor(0.17, 0.17, 0.18, 0.96) + end + for _, t in pairs(calcCard.combinedBorder.border) do + t:SetVertexColor(0.30, 0.30, 0.32, 0.85) + end + calcCard:Hide() + resultRow.calcCard = calcCard + + local calcDivider = calcCard:CreateTexture(nil, "ARTWORK") + calcDivider:SetColorTexture(0.30, 0.30, 0.32, 0) + calcDivider:SetWidth(1) + resultRow.calcDivider = calcDivider + + local calcDividerTop = calcCard:CreateTexture(nil, "ARTWORK") + calcDividerTop:SetColorTexture(0.30, 0.30, 0.32, 0.75) + calcDividerTop:SetWidth(1) + calcDividerTop:Hide() + resultRow.calcDividerTop = calcDividerTop + + local calcDividerBottom = calcCard:CreateTexture(nil, "ARTWORK") + calcDividerBottom:SetColorTexture(0.30, 0.30, 0.32, 0.75) + calcDividerBottom:SetWidth(1) + calcDividerBottom:Hide() + resultRow.calcDividerBottom = calcDividerBottom + + local calcExpressionHighlight = calcCard:CreateTexture(nil, "BORDER") + calcExpressionHighlight:SetColorTexture(1.0, 0.82, 0.22, 0.20) + calcExpressionHighlight:Hide() + resultRow.calcExpressionHighlight = calcExpressionHighlight + + local calcResultHighlight = calcCard:CreateTexture(nil, "BORDER") + calcResultHighlight:SetColorTexture(1.0, 0.82, 0.22, 0.20) + calcResultHighlight:Hide() + resultRow.calcResultHighlight = calcResultHighlight + + local calcExpressionFlash = calcCard:CreateTexture(nil, "OVERLAY") + calcExpressionFlash:SetColorTexture(0.48, 1.0, 0.62, 0.35) + calcExpressionFlash:Hide() + calcExpressionFlash.anim = calcExpressionFlash:CreateAnimationGroup() + local expressionFlashAlpha = calcExpressionFlash.anim:CreateAnimation("Alpha") + expressionFlashAlpha:SetFromAlpha(0.42) + expressionFlashAlpha:SetToAlpha(0) + expressionFlashAlpha:SetDuration(0.42) + expressionFlashAlpha:SetSmoothing("OUT") + calcExpressionFlash.anim:SetScript("OnFinished", function() + calcExpressionFlash:Hide() + end) + resultRow.calcExpressionFlash = calcExpressionFlash + + local calcResultFlash = calcCard:CreateTexture(nil, "OVERLAY") + calcResultFlash:SetColorTexture(0.48, 1.0, 0.62, 0.35) + calcResultFlash:Hide() + calcResultFlash.anim = calcResultFlash:CreateAnimationGroup() + local resultFlashAlpha = calcResultFlash.anim:CreateAnimation("Alpha") + resultFlashAlpha:SetFromAlpha(0.42) + resultFlashAlpha:SetToAlpha(0) + resultFlashAlpha:SetDuration(0.42) + resultFlashAlpha:SetSmoothing("OUT") + calcResultFlash.anim:SetScript("OnFinished", function() + calcResultFlash:Hide() + end) + resultRow.calcResultFlash = calcResultFlash + + local calcExpressionText = calcCard:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge") + calcExpressionText:SetJustifyH("CENTER") + calcExpressionText:SetWordWrap(false) + calcExpressionText:SetTextColor(0.96, 0.96, 0.96, 1.0) + resultRow.calcExpressionText = calcExpressionText + + local calcArrowText = calcCard:CreateFontString(nil, "OVERLAY", "GameFontHighlight") + calcArrowText:SetText("=") + calcArrowText:SetTextColor(0.86, 0.86, 0.86, 1.0) + resultRow.calcArrowText = calcArrowText + + local calcResultText = calcCard:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge") + calcResultText:SetJustifyH("CENTER") + calcResultText:SetWordWrap(false) + calcResultText:SetTextColor(0.96, 0.96, 0.96, 1.0) + resultRow.calcResultText = calcResultText + + local calcExpressionHint = calcCard:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + calcExpressionHint:SetText("Ctrl+C to copy") + calcExpressionHint:SetJustifyH("CENTER") + calcExpressionHint:SetTextColor(0.72, 0.72, 0.72, 1.0) + calcExpressionHint:SetPoint("TOP", calcExpressionText, "BOTTOM", 0, -1) + calcExpressionHint:Hide() + resultRow.calcExpressionHint = calcExpressionHint + + local calcResultHint = calcCard:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + calcResultHint:SetText("Ctrl+C to copy") + calcResultHint:SetJustifyH("CENTER") + calcResultHint:SetTextColor(0.72, 0.72, 0.72, 1.0) + calcResultHint:SetPoint("TOP", calcResultText, "BOTTOM", 0, -1) + calcResultHint:Hide() + resultRow.calcResultHint = calcResultHint + + local calcExpressionButton = CreateFrame("Button", nil, calcCard) + calcExpressionButton:RegisterForClicks("LeftButtonUp") + calcExpressionButton:SetFrameLevel(resultRow:GetFrameLevel() + 10) + calcExpressionButton:SetScript("OnEnter", function() + UI:HoverCalculatorTarget(resultRow, "expression") + end) + calcExpressionButton:SetScript("OnLeave", function() + UI:RestoreCalculatorTarget(resultRow) + end) + calcExpressionButton:SetScript("OnClick", function() + selectedIndex = index + toggleFocused = false + UI:UpdateSelectionHighlight(true) + UI:ArmCalculatorPartFromRow(resultRow, "expression", "click") + end) + calcExpressionButton:Hide() + resultRow.calcExpressionButton = calcExpressionButton + + local calcResultButton = CreateFrame("Button", nil, calcCard) + calcResultButton:RegisterForClicks("LeftButtonUp") + calcResultButton:SetFrameLevel(resultRow:GetFrameLevel() + 10) + calcResultButton:SetScript("OnEnter", function() + UI:HoverCalculatorTarget(resultRow, "result") + end) + calcResultButton:SetScript("OnLeave", function() + UI:RestoreCalculatorTarget(resultRow) + end) + calcResultButton:SetScript("OnClick", function() + selectedIndex = index + toggleFocused = false + UI:UpdateSelectionHighlight(true) + UI:ArmCalculatorPartFromRow(resultRow, "result", "click") + end) + calcResultButton:Hide() + resultRow.calcResultButton = calcResultButton + local shortcutGroup = CreateFrame("Frame", nil, resultRow) - shortcutGroup:SetPoint("RIGHT", resultRow, "RIGHT", -RESULT_SHORTCUT_RIGHT_PAD, 0) - shortcutGroup:SetSize(RESULT_SHORTCUT_WIDTH, 16) shortcutGroup:Hide() resultRow.shortcutGroup = shortcutGroup @@ -5484,13 +6688,11 @@ function UI:CreateResultButton(index) resultRow.shortcutAltIcon = shortcutAltIcon local shortcutNumberText = shortcutGroup:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") - shortcutNumberText:SetPoint("RIGHT", shortcutGroup, "RIGHT", 3, 0) - shortcutNumberText:SetWidth(7) shortcutNumberText:SetJustifyH("RIGHT") shortcutNumberText:SetTextColor(0.58, 0.58, 0.58, 0.85) shortcutNumberText:SetText("") resultRow.shortcutNumberText = shortcutNumberText - shortcutAltIcon:SetPoint("RIGHT", shortcutNumberText, "LEFT", 2, 0) + self:LayoutResultShortcut(resultRow) -- Right-aligned setting state widget (checkbox + optional checkmark -- overlay for boolean settings). For dropdowns we reuse amountText @@ -6351,6 +7553,7 @@ function UI:CreateResultButton(index) end -- Right-click: show pin/unpin popup (plus Guide row if entry has a guide path) if mouseButton == "RightButton" and self.data then + if self.data.calculatorResult then return end local pinData = self.data local isPinned = IsUIItemPinned(pinData) local hasGuide = pinData.steps or pinData.transmogSetID @@ -6880,7 +8083,11 @@ StaticPopupDialogs["EASYFIND_ADD_ALIAS"] = { function UI:PromptForAlias(data) if not data then return end local label = data.name or "this entry" - StaticPopup_Show("EASYFIND_ADD_ALIAS", label, nil, data) + local dialog = StaticPopup_Show("EASYFIND_ADD_ALIAS", label, nil, data) + if dialog then + dialog:SetFrameStrata("TOOLTIP") + dialog:SetFrameLevel(1000) + end end -- Step through search history from the editbox. Direction +1 = older, @@ -6946,9 +8153,9 @@ function UI:OnSearchTextChanged(text, force) if ns.Database and ns.Database.CancelDynamicWarmup then ns.Database:CancelDynamicWarmup() end - -- Only show pins if the editbox still has focus (avoid re-showing - -- after SelectResult clears the text) - if searchFrame and searchFrame.editBox and searchFrame.editBox:HasFocus() then + -- Normally only show pins when focused. Forced refreshes (ESC clear, + -- pin-menu actions) also rebuild pins so stale typed results disappear. + if force or (searchFrame and searchFrame.editBox and searchFrame.editBox:HasFocus()) then self:ShowPinnedItems() else self:HideResults() @@ -6957,7 +8164,8 @@ function UI:OnSearchTextChanged(text, force) end wipe(collapsedNodes) - local needsHeavy = ns.Database and ns.Database.QueryNeedsHeavySearchData + local calculatorData = self:EvaluateCalculatorExpression(text) + local needsHeavy = not calculatorData and ns.Database and ns.Database.QueryNeedsHeavySearchData and ns.Database:QueryNeedsHeavySearchData(text) if not force and not needsHeavy and ns.Database and ns.Database.CancelDynamicWarmup then ns.Database:CancelDynamicWarmup() @@ -7011,7 +8219,13 @@ function UI:OnSearchTextChanged(text, force) end end local _perfT0 = ns.PERF and debugprofilestop() or 0 - local results = ns.Database:SearchUI(text, skipCategories) + local results + if calculatorData then + results = SCRATCH.calculatorResults + wipe(results) + else + results = ns.Database:SearchUI(text, skipCategories) + end local _perfTSearch = ns.PERF and debugprofilestop() or 0 -- Inject user-defined alias hits at the front. Aliases bypass @@ -7089,12 +8303,15 @@ function UI:OnSearchTextChanged(text, force) -- Map Search: search static locations and dungeon entrances, merge into results local mapResults - if filters and filters.map ~= false and ns.MapSearch and ns.MapSearch.SearchForUI then + if not calculatorData and filters and filters.map ~= false and ns.MapSearch and ns.MapSearch.SearchForUI then mapResults = ns.MapSearch:SearchForUI(text) end wipe(flatCombined) local combined = flatCombined + if calculatorData then + combined[#combined + 1] = { data = calculatorData, score = math.huge } + end for ri = 1, #results do combined[#combined + 1] = results[ri] end if mapResults then for ri = 1, #mapResults do combined[#combined + 1] = mapResults[ri] end @@ -7119,7 +8336,7 @@ function UI:OnSearchTextChanged(text, force) -- ScoreName so they interleave naturally with mount / toy / setting -- hits ranked off the same query, instead of clumping at a fixed -- band. - if text ~= "" and (not filters or filters.achievements ~= false) then + if not calculatorData and text ~= "" and (not filters or filters.achievements ~= false) then local achHits = self:RequestAchievementSearch(text) if achHits and ns.Database and ns.Database.ScoreName then local lowerQ = slower(text) @@ -7155,7 +8372,7 @@ function UI:OnSearchTextChanged(text, force) e.isMatch = true e.isFlat = true e.flatCatKey = nil - e.isPinned = IsUIItemPinned(d) and true or false + e.isPinned = (not d.noPin and IsUIItemPinned(d)) and true or false e.data = d end end @@ -7370,11 +8587,9 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if theme.resultsBackdropBorderColor then resultsFrame:SetBackdropBorderColor(unpack(theme.resultsBackdropBorderColor)) end - -- Width: in rounded-theme + dropdown-below mode, the dropdown - -- silhouette merges with the bar, so it has to match the bar's - -- exact width. Otherwise honor the user's saved customW (legacy - -- floating-results behavior). - local roundedDocked = theme.searchBarRounded and not EasyFind.db.uiResultsAbove + -- Rounded search/results use one shared silhouette whether results + -- open above or below, so the dropdown must match the bar width. + local roundedDocked = theme.searchBarRounded if roundedDocked and searchFrame then resultsFrame:SetWidth(searchFrame:GetWidth()) else @@ -7517,6 +8732,19 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.sectionLabelLeft:Hide() resultRow.sectionLabelRight:Hide() end + if resultRow.calcCard then resultRow.calcCard:Hide() end + if resultRow.calcDivider then resultRow.calcDivider:Hide() end + if resultRow.calcDividerTop then resultRow.calcDividerTop:Hide() end + if resultRow.calcDividerBottom then resultRow.calcDividerBottom:Hide() end + if resultRow.calcArrowText then resultRow.calcArrowText:Hide() end + if resultRow.calcExpressionHighlight then resultRow.calcExpressionHighlight:Hide() end + if resultRow.calcResultHighlight then resultRow.calcResultHighlight:Hide() end + if resultRow.calcExpressionFlash then resultRow.calcExpressionFlash:Hide() end + if resultRow.calcResultFlash then resultRow.calcResultFlash:Hide() end + if resultRow.calcExpressionHint then resultRow.calcExpressionHint:Hide() end + if resultRow.calcResultHint then resultRow.calcResultHint:Hide() end + if resultRow.calcExpressionButton then resultRow.calcExpressionButton:Hide() end + if resultRow.calcResultButton then resultRow.calcResultButton:Hide() end resultRow.data = data -- Reset every icon.* tooltip-identifier the OnEnter handler @@ -8084,6 +9312,75 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.text:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) iconSet = true + -- Calculator rows use a Raycast-style result card instead of the + -- normal two-line flat row. + elseif data and data.calculatorResult and not entry.isPathNode then + resultRow.text:SetText("") + resultRow.amountText:Hide() + if resultRow.pathSubtext then resultRow.pathSubtext:Hide() end + if resultRow.flatCatIcon then resultRow.flatCatIcon:Hide() end + SetRowIcon(resultRow, "hidden", nil, rowIconSize) + + local rightInset = showShortcutHints and 42 or 4 + resultRow.calcCard:ClearAllPoints() + resultRow.calcCard:SetPoint("TOPLEFT", resultRow, "TOPLEFT", 4, -3) + resultRow.calcCard:SetPoint("BOTTOMRIGHT", resultRow, "BOTTOMRIGHT", -rightInset, 3) + resultRow.calcCard:Show() + + resultRow.calcDivider:ClearAllPoints() + resultRow.calcDivider:SetPoint("TOP", resultRow.calcCard, "TOP", 0, -1) + resultRow.calcDivider:SetPoint("BOTTOM", resultRow.calcCard, "BOTTOM", 0, 1) + resultRow.calcDivider:Show() + + resultRow.calcArrowText:ClearAllPoints() + resultRow.calcArrowText:SetPoint("CENTER", resultRow.calcCard, "CENTER", 0, 0) + resultRow.calcArrowText:Show() + + resultRow.calcDividerTop:ClearAllPoints() + resultRow.calcDividerTop:SetPoint("TOP", resultRow.calcCard, "TOP", 0, -1) + resultRow.calcDividerTop:SetPoint("BOTTOM", resultRow.calcArrowText, "TOP", 0, 3) + resultRow.calcDividerTop:Show() + + resultRow.calcDividerBottom:ClearAllPoints() + resultRow.calcDividerBottom:SetPoint("TOP", resultRow.calcArrowText, "BOTTOM", 0, -3) + resultRow.calcDividerBottom:SetPoint("BOTTOM", resultRow.calcCard, "BOTTOM", 0, 1) + resultRow.calcDividerBottom:Show() + + resultRow.calcExpressionText:ClearAllPoints() + resultRow.calcExpressionText:SetPoint("LEFT", resultRow.calcCard, "LEFT", 12, 0) + resultRow.calcExpressionText:SetPoint("RIGHT", resultRow.calcDivider, "LEFT", -22, 0) + SetClippedText(resultRow.calcExpressionText, data.calculatorExpression or data.name or "") + resultRow.calcExpressionHighlight:ClearAllPoints() + resultRow.calcExpressionHighlight:SetPoint("TOPLEFT", resultRow.calcCard, "TOPLEFT", 1, 0) + resultRow.calcExpressionHighlight:SetPoint("BOTTOMRIGHT", resultRow.calcDivider, "BOTTOMLEFT", -1, 1) + resultRow.calcExpressionFlash:ClearAllPoints() + resultRow.calcExpressionFlash:SetPoint("TOPLEFT", resultRow.calcExpressionHighlight, "TOPLEFT") + resultRow.calcExpressionFlash:SetPoint("BOTTOMRIGHT", resultRow.calcExpressionHighlight, "BOTTOMRIGHT") + + resultRow.calcResultText:ClearAllPoints() + resultRow.calcResultText:SetPoint("LEFT", resultRow.calcDivider, "RIGHT", 22, 0) + resultRow.calcResultText:SetPoint("RIGHT", resultRow.calcCard, "RIGHT", -12, 0) + SetClippedText(resultRow.calcResultText, data.calculatorResult) + resultRow.calcResultHighlight:ClearAllPoints() + resultRow.calcResultHighlight:SetPoint("TOPLEFT", resultRow.calcDivider, "TOPRIGHT", 1, 0) + resultRow.calcResultHighlight:SetPoint("BOTTOMRIGHT", resultRow.calcCard, "BOTTOMRIGHT", -1, 1) + resultRow.calcResultFlash:ClearAllPoints() + resultRow.calcResultFlash:SetPoint("TOPLEFT", resultRow.calcResultHighlight, "TOPLEFT") + resultRow.calcResultFlash:SetPoint("BOTTOMRIGHT", resultRow.calcResultHighlight, "BOTTOMRIGHT") + resultRow.calcExpressionButton:ClearAllPoints() + resultRow.calcExpressionButton:SetPoint("TOPLEFT", resultRow.calcCard, "TOPLEFT", 1, 0) + resultRow.calcExpressionButton:SetPoint("BOTTOMRIGHT", resultRow.calcDivider, "BOTTOMLEFT", -1, 1) + resultRow.calcExpressionButton:Show() + + resultRow.calcResultButton:ClearAllPoints() + resultRow.calcResultButton:SetPoint("TOPLEFT", resultRow.calcDivider, "TOPRIGHT", 1, 0) + resultRow.calcResultButton:SetPoint("BOTTOMRIGHT", resultRow.calcCard, "BOTTOMRIGHT", -1, 1) + resultRow.calcResultButton:Show() + + self:SetCalculatorCopyHighlight(resultRow, UI._calculator.activeData == data and UI._calculator.activePart or nil) + + 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 or data.outfitID or data.heirloomItemID or data.transmogSetID or (data.spellID and data.category == "Ability") or (data.spellID and data.category == "Talent") or (data.encounterID and data.category == "Boss") or (data.macroIndex and data.category == "Macro") or (data.bagID and data.category == "Bag")) then local iconFileID = data.icon @@ -8822,7 +10119,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- Re-apply flat anchoring last so layout is consistent across -- all categories and the path subtext is bounded by the rep bar -- when one is shown (so it stays out of the bar's horizontal area). - if entry.isFlat then + if entry.isFlat and not (data and data.calculatorResult) then local catShown = resultRow.flatCatIcon and resultRow.flatCatIcon:IsShown() local d = data local mainIconOnRight = d and (d.mountID or d.toyItemID or d.petID @@ -9007,6 +10304,19 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.repBar:Hide() if resultRow.flatCatIcon then resultRow.flatCatIcon:Hide() end if resultRow.pathSubtext then resultRow.pathSubtext:Hide() end + if resultRow.calcCard then resultRow.calcCard:Hide() end + if resultRow.calcDivider then resultRow.calcDivider:Hide() end + if resultRow.calcDividerTop then resultRow.calcDividerTop:Hide() end + if resultRow.calcDividerBottom then resultRow.calcDividerBottom:Hide() end + if resultRow.calcArrowText then resultRow.calcArrowText:Hide() end + if resultRow.calcExpressionHighlight then resultRow.calcExpressionHighlight:Hide() end + if resultRow.calcResultHighlight then resultRow.calcResultHighlight:Hide() end + if resultRow.calcExpressionFlash then resultRow.calcExpressionFlash:Hide() end + if resultRow.calcResultFlash then resultRow.calcResultFlash:Hide() end + if resultRow.calcExpressionHint then resultRow.calcExpressionHint:Hide() end + if resultRow.calcResultHint then resultRow.calcResultHint:Hide() end + if resultRow.calcExpressionButton then resultRow.calcExpressionButton:Hide() end + if resultRow.calcResultButton then resultRow.calcResultButton:Hide() end if resultRow.shortcutGroup then resultRow.shortcutGroup:Hide() end for d = 1, MAX_DEPTH do resultRow.treeVert[d]:Hide() @@ -9070,34 +10380,38 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end end - -- Anchor results above or below based on setting. The combined - -- silhouette (rounded-rect container) only makes sense when the - -- dropdown sits BELOW the bar; if the user has flipped to "above" - -- mode, fall back to the legacy gap-with-own-backdrop look. + -- Anchor results above or below based on setting. The rounded + -- container wraps the bar and dropdown in either orientation. local belowMode = not EasyFind.db.uiResultsAbove local roundedTheme = GetActiveTheme().searchBarRounded resultsFrame:ClearAllPoints() if belowMode then resultsFrame:SetPoint("TOP", searchFrame, "BOTTOM", 0, 0) else - resultsFrame:SetPoint("BOTTOM", searchFrame, "TOP", 0, -5) + resultsFrame:SetPoint("BOTTOM", searchFrame, "TOP", 0, 0) end - -- In rounded+below mode the resultsFrame backdrop is owned by - -- the container; clear its own and hide any bg atlas so the - -- unified silhouette reads as one shape. - if roundedTheme and belowMode then + -- In rounded mode the resultsFrame backdrop is owned by the + -- container; clear its own and hide any bg atlas so the unified + -- silhouette reads as one shape. + if roundedTheme then resultsFrame:SetBackdrop(nil) if resultsFrame.bgAtlasTex then resultsFrame.bgAtlasTex:Hide() end if containerFrame then containerFrame:ClearAllPoints() - containerFrame:SetPoint("TOPLEFT", searchFrame, "TOPLEFT", 0, 0) - containerFrame:SetPoint("TOPRIGHT", searchFrame, "TOPRIGHT", 0, 0) - containerFrame:SetPoint("BOTTOMLEFT", resultsFrame, "BOTTOMLEFT", 0, 0) - containerFrame:SetPoint("BOTTOMRIGHT", resultsFrame, "BOTTOMRIGHT", 0, 0) - -- Divider line at the bar's bottom edge: doubles as the - -- visual separator between search content and results. - ns.SetRoundedRectDivider(containerFrame, searchFrame:GetHeight(), true) + if belowMode then + containerFrame:SetPoint("TOPLEFT", searchFrame, "TOPLEFT", 0, 0) + containerFrame:SetPoint("TOPRIGHT", searchFrame, "TOPRIGHT", 0, 0) + containerFrame:SetPoint("BOTTOMLEFT", resultsFrame, "BOTTOMLEFT", 0, 0) + containerFrame:SetPoint("BOTTOMRIGHT", resultsFrame, "BOTTOMRIGHT", 0, 0) + ns.SetRoundedRectDivider(containerFrame, searchFrame:GetHeight(), true) + else + containerFrame:SetPoint("TOPLEFT", resultsFrame, "TOPLEFT", 0, 0) + containerFrame:SetPoint("TOPRIGHT", resultsFrame, "TOPRIGHT", 0, 0) + containerFrame:SetPoint("BOTTOMLEFT", searchFrame, "BOTTOMLEFT", 0, 0) + containerFrame:SetPoint("BOTTOMRIGHT", searchFrame, "BOTTOMRIGHT", 0, 0) + ns.SetRoundedRectDivider(containerFrame, resultsFrame:GetHeight(), true) + end end end @@ -9376,6 +10690,8 @@ function UI:HideResults() if activeKeybindBtn and activeKeybindBtn._stopCapture then activeKeybindBtn._stopCapture(activeKeybindBtn) end + self:ClearCalculatorCopyHighlight() + self:ReleaseCalculatorCopyBox() if searchFrame.StopKeyRepeat then searchFrame.StopKeyRepeat() end if searchFrame.ClearToolbarFocus then searchFrame.ClearToolbarFocus() end ClearResultShortcutBindings() @@ -9642,7 +10958,7 @@ function UI:UpdateSelectionHighlight(skipRefocus) end end -function UI:ActivateResultRow(resultRow) +function UI:ActivateResultRow(resultRow, source) if not resultRow or not resultRow:IsShown() then return false end if resultRow.isUnearnedCurrency or resultRow.isPinHeader or resultRow.isSectionHeader then return true @@ -9650,11 +10966,15 @@ function UI:ActivateResultRow(resultRow) if not resultRow.data then return false end if ActivateSettingResult(resultRow.data) then return true end + if resultRow.data.calculatorResult then + self:ArmCalculatorResultFromRow(resultRow, source or "click") + return true + end self:SelectResult(resultRow.data) return true end -function UI:ActivateSelected() +function UI:ActivateSelected(source) if selectedIndex > 0 and selectedIndex <= MAX_BUTTON_POOL then local resultRow = resultButtons[selectedIndex] if resultRow and resultRow:IsShown() then @@ -9671,7 +10991,7 @@ function UI:ActivateSelected() self:UpdateSelectionHighlight() end else - self:ActivateResultRow(resultRow) + self:ActivateResultRow(resultRow, source or "key") end return end @@ -10739,6 +12059,11 @@ function UI:SelectResult(data, forceGuide) if not data then return end local useFast = not forceGuide + if data.calculatorResult then + self:ArmCalculatorResultForData(data) + return + end + selectingResult = true searchFrame.editBox:SetText("") searchFrame.editBox:ClearFocus() @@ -12357,13 +13682,14 @@ function UI:HandleEscape() -- also lifts the popup above our results panel strata. if self:ShowUnappliedSettingsPopup() then return end if editBox and editBox:GetText() ~= "" then + if editBox.ResetPendingSearch then editBox:ResetPendingSearch() end editBox:SetText("") if editBox.placeholder then editBox.placeholder:Show() end - -- SetText("") fires OnTextChanged -> OnSearchTextChanged which - -- calls ShowPinnedItems when the editbox still has focus, so - -- the pinned list lights up. Don't follow with RequestHideResults - -- here; that would close the results we just opened to pins. Refocus() + -- Programmatic SetText intentionally does not run the throttled + -- search path. Rebuild immediately so stale typed results are + -- replaced by pinned rows, or hidden when there are no pins. + self:OnSearchTextChanged("", true) return end self:Hide() @@ -12606,7 +13932,7 @@ end function UI:UpdateOpacity() if not searchFrame then return end - local alpha = EasyFind.db.searchBarOpacity or DEFAULT_OPACITY + local alpha = EasyFind.db.searchBarOpacity or ns.DEFAULT_OPACITY if containerFrame then ns.SetRoundedRectBorderBgAlpha(containerFrame, alpha) end @@ -12614,18 +13940,21 @@ end function UI:UpdateSearchBarTheme() if not searchFrame then return end - local scale = EasyFind.db.fontSize or 1.0 - local alpha = EasyFind.db.searchBarOpacity or DEFAULT_OPACITY + local alpha = EasyFind.db.searchBarOpacity or ns.DEFAULT_OPACITY searchFrame:SetBackdrop(nil) -- Pill stays hidden; container provides the rounded silhouette. ns.SetSearchBorderShown(searchFrame, false) if containerFrame then ns.SetRoundedRectBorderShown(containerFrame, true) - ns.SetRoundedRectBarHeight(containerFrame, ns.SEARCHBAR_HEIGHT * scale) + ns.SetRoundedRectBarHeight(containerFrame, self:GetSearchBarHeight()) ns.SetRoundedRectBorderBgAlpha(containerFrame, alpha) end end +function UI:UpdateSearchBarHeight() + self:UpdateFontSize() +end + function UI:UpdateSmartShow() if not searchFrame then return end local enabled = EasyFind.db.smartShow @@ -12657,7 +13986,8 @@ end function UI:ResetPosition() if searchFrame then searchFrame:ClearAllPoints() - searchFrame:SetPoint("TOP", UIParent, "TOP", 0, -12) + local point, relPoint, x, y = self:GetDefaultSearchBarPoint() + searchFrame:SetPoint(point, UIParent, relPoint, x, y) EasyFind.db.uiSearchPosition = nil end end @@ -12884,13 +14214,12 @@ function UI:ShowFirstTimeSetup() resizer:SetScript("OnLeave", GameTooltip_Hide) local function scaleResizerVisual() - local s = mmax(0.5, mmin(2.0, EasyFind.db.fontSize or 1.0)) - resizer:SetSize(16 * s, 16 * s) + resizer:SetSize(16, 16) end - -- Dragging the bottom-left corner resizes the bar in two axes at once: + -- Dragging the bottom-left corner adjusts the search bar preview in two axes: -- horizontal -> uiSearchWidth (symmetric growth around a locked top-center) - -- vertical -> fontSize (which also grows the bar height downward) + -- vertical -> uiSearchBarHeight -- -- Uses OnMouseDown (not RegisterForDrag) so there's no 4-pixel drag -- threshold: the bar starts resizing the instant the cursor moves. @@ -12922,7 +14251,7 @@ function UI:ShowFirstTimeSetup() -- without snapping the corner to the cursor at drag start. self.startCx, self.startCy = GetCursorPosition() self.startWidth = EasyFind.db.uiSearchWidth or 1.0 - self.startFont = EasyFind.db.fontSize or 1.0 + self.startBarHeight = UI:GetSearchBarHeight() end) local function stopResize(self) @@ -12934,7 +14263,7 @@ function UI:ShowFirstTimeSetup() self.startCx = nil self.startCy = nil self.startWidth = nil - self.startFont = nil + self.startBarHeight = nil -- Persist the new TOP-anchored position so the bar sticks after drag. local point, _, relPoint, x, y = searchFrame:GetPoint() @@ -12954,7 +14283,7 @@ function UI:ShowFirstTimeSetup() stopResize(self) return end - if not (self.startCx and self.startCy and self.startWidth and self.startFont) then return end + if not (self.startCx and self.startCy and self.startWidth and self.startBarHeight) then return end local cx, cy = GetCursorPosition() local effScale = searchFrame:GetEffectiveScale() or 1.0 @@ -12965,23 +14294,23 @@ function UI:ShowFirstTimeSetup() local dyScreen = cy - self.startCy -- Bottom-right corner: cursor moving RIGHT should grow the bar - -- symmetrically, cursor moving DOWN should grow the font/height. + -- symmetrically, cursor moving DOWN should grow the bar strip. -- d(uiSearchWidth) = dxScreen / (effScale * 125) - -- d(fontSize) = -dyScreen / (effScale * SEARCHBAR_HEIGHT) + -- d(barHeight) = -dyScreen / effScale local newWidth = self.startWidth + dxScreen / (effScale * 125) - local newFont = self.startFont - dyScreen / (effScale * ns.SEARCHBAR_HEIGHT) + local newBarH = self.startBarHeight - dyScreen / effScale newWidth = mmax(0.5, mmin(2.5, newWidth)) - newFont = mmax(0.5, mmin(2.0, newFont)) + newBarH = mmax(24, mmin(56, newBarH)) EasyFind.db.uiSearchWidth = newWidth - EasyFind.db.fontSize = newFont + EasyFind.db.uiSearchBarHeight = newBarH UI:UpdateWidth() - UI:UpdateFontSize() + UI:UpdateSearchBarHeight() scaleResizerVisual() end) - -- Set the initial resizer size to match the current font scale + -- Set the initial resizer size. scaleResizerVisual() -- Fixed panel width — comfortably holds the keybind rows and @@ -13278,14 +14607,12 @@ function UI:FlashLabel(labelText) end function UI:UpdateFontSize() - local scale = EasyFind.db.fontSize or 1.0 - if not searchFrame then return end ScaleFont(searchFrame.editBox, ns.SEARCHBAR_FONT) ScaleFont(searchFrame.editBox.placeholder, ns.SEARCHBAR_FONT) - local barH = ns.SEARCHBAR_HEIGHT * scale + local barH = UI:GetSearchBarHeight() local contentSz = barH * ns.SEARCHBAR_FILL local iconSz = contentSz * ns.SEARCHBAR_ICON_SCALE searchFrame:SetHeight(barH) @@ -13300,7 +14627,7 @@ function UI:UpdateFontSize() local theme = GetActiveTheme() local WHITE8x8 = "Interface\\BUTTONS\\WHITE8x8" - local alpha = EasyFind.db.searchBarOpacity or DEFAULT_OPACITY + local alpha = EasyFind.db.searchBarOpacity or ns.DEFAULT_OPACITY if theme.searchBarRounded then searchFrame:SetBackdrop(nil) if containerFrame then @@ -13316,8 +14643,8 @@ function UI:UpdateFontSize() searchFrame:SetBackdrop({ bgFile = WHITE8x8, edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", - edgeSize = 20 * scale, - insets = { left = 5 * scale, right = 5 * scale, top = 5 * scale, bottom = 5 * scale } + edgeSize = 20, + insets = { left = 5, right = 5, top = 5, bottom = 5 } }) searchFrame:SetBackdropColor(0, 0, 0, alpha) end diff --git a/Wizard.lua b/Wizard.lua index 336fbb5..46db2a1 100644 --- a/Wizard.lua +++ b/Wizard.lua @@ -209,7 +209,8 @@ local function BuildPage1(parent) logo:SetTexture("Interface\\AddOns\\EasyFind\\Textures\\Spyglass") logo:SetPoint("TOP", p, "TOP", 0, -40) - local title = HeaderText(p, "Welcome to EasyFind") + local version = ns.version or (C_AddOns and C_AddOns.GetAddOnMetadata and C_AddOns.GetAddOnMetadata("EasyFind", "Version")) or "2.0.0" + local title = HeaderText(p, "Welcome to EasyFind v" .. version) title:SetPoint("TOP", logo, "BOTTOM", 0, -22) -- 50% larger than the GameFontNormalHuge baseline. do @@ -217,7 +218,7 @@ local function BuildPage1(parent) if sz then title:SetFont(path, sz * 1.5, fl or "") end end - local sub = BodyText(p, "Your shortcut to anything in WoW.") + local sub = BodyText(p, "Your shortcut to everything in WoW.") sub:SetPoint("TOP", title, "BOTTOM", 0, -16) sub:SetWidth(WIZ_W - 120) sub:SetTextColor(TEXT_DIM[1], TEXT_DIM[2], TEXT_DIM[3], 1) @@ -244,7 +245,7 @@ local function FeatureTile(parent, atlas, file, coords, title, desc, onClick) -- corner sizes) and skip a separate edge layer -- the rounded -- fill alone defines the shape. ns.CreateRoundedRectBorder(tile) - ns.SetRoundedRectBarHeight(tile, 12) + ns.SetRoundedRectBarHeight(tile, 10) ns.SetRoundedRectBorderBgAlpha(tile, 0.95) if tile.combinedBorder and tile.combinedBorder.fill then for _, t in pairs(tile.combinedBorder.fill) do @@ -359,7 +360,7 @@ local function BuildPage2(parent) end local d1 = CreateDetailView("Search") - local d2 = CreateDetailView("Map Search") + local d2 = CreateDetailView("Map Search Tab") local d3 = CreateDetailView("Click & Right-click") local d4 = CreateDetailView("Pin Anything") @@ -370,7 +371,7 @@ local function BuildPage2(parent) t1:SetPoint("TOPLEFT", grid, "TOPLEFT", 38, -96) local t2 = FeatureTile(grid, "Waypoint-MapPin-Untracked", nil, nil, - "Map Search", + "Map Search Tab", "Dedicated map browsing for banks, flight masters, dungeons, raids, and zones. Also reachable from Standard Search.", function() ShowDetail(d2) end) t2:SetPoint("TOPRIGHT", grid, "TOPRIGHT", -38, -96) @@ -690,7 +691,7 @@ local function CreateFrameOnce() -- instead of half the frame height. Tint the fill dark gray -- (not pure black) so it reads as a panel rather than a void. ns.CreateRoundedRectBorder(f) - ns.SetRoundedRectBarHeight(f, 20) + ns.SetRoundedRectBarHeight(f, 16) ns.SetRoundedRectBorderBgAlpha(f, PANEL_BG_ALPHA) -- Hide the bright border ring; its corner cells render as visible -- horizontal bands against the gradient fill, breaking the smooth @@ -824,7 +825,7 @@ local function CreateFrameOnce() footer:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -BANNER_INSET, BANNER_INSET) footer:SetHeight(BANNER_H) ns.CreateRoundedRectBorder(footer) - ns.SetRoundedRectBarHeight(footer, 20) + ns.SetRoundedRectBarHeight(footer, 16) ns.SetRoundedRectBorderBgAlpha(footer, 1) if footer.combinedBorder and footer.combinedBorder.fill then for _, t in pairs(footer.combinedBorder.fill) do From 12da2d818c7a1acb1bf3a2efbde3af6e35aed2fc Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Mon, 11 May 2026 00:29:00 -0400 Subject: [PATCH 087/103] Fix calculator keyboard copy focus --- UI.lua | 51 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/UI.lua b/UI.lua index 6b82c3c..cea16f5 100644 --- a/UI.lua +++ b/UI.lua @@ -146,6 +146,11 @@ end local searchFrame local resultsFrame +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 activeKeybindBtn -- Combined-frame backdrop: rounded-rect 9-slice that wraps the bar -- alone (collapsed to a pill when results are hidden) or the bar -- plus the results dropdown (rounded rectangle when open). Sibling @@ -1024,7 +1029,33 @@ function UI:RearmActiveCalculatorCopy(source) local result = UI._calculator.activeResult if not result or result == "" then return false end if source == "ctrl" and UI._calculator.copyComplete then return false end - return self:CopyCalculatorResult(result, source or "active") + local ok = self:CopyCalculatorResult(result, source or "active") + if ok and source == "ctrl" then + self:SuspendCalculatorNavForCopy() + end + return ok +end + +function UI:SuspendCalculatorNavForCopy() + if not navFrame then return end + UI._calculator.navSuspendedForCopy = navFrame:IsKeyboardEnabled() + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) +end + +function UI:RestoreSearchFocusAfterCalculatorCopy() + UI._calculator.navSuspendedForCopy = nil + selectedIndex = 0 + toggleFocused = false + if navFrame then + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + end + + local editBox = searchFrame and searchFrame.editBox + if editBox and editBox.IsVisible and editBox:IsVisible() then + editBox.blockFocus = nil + editBox:SetFocus() + editBox:SetCursorPosition(#(editBox:GetText() or "")) + end end function UI:EnsureCalculatorCopyWatcher() @@ -1287,9 +1318,11 @@ function UI:ConfirmCalculatorCopied() if C_Timer and C_Timer.After then C_Timer.After(0, function() UI:ReleaseCalculatorCopyBox("confirm") + UI:RestoreSearchFocusAfterCalculatorCopy() end) else self:ReleaseCalculatorCopyBox("confirm") + self:RestoreSearchFocusAfterCalculatorCopy() end return true end @@ -1378,6 +1411,13 @@ function UI:HandleCalculatorPasteIntoSearch(editBox, key) return false end +function UI:ArmCalculatorSelectionForKeyboard(row) + if not row or not row:IsShown() or not row.data or not row.data.calculatorResult then + return false + end + return self:ArmCalculatorResultFromRow(row, "key") +end + local function IsSpellbookOnlyAbility(data) return data and data.category == "Ability" and data.spellID and data.isSpellbookOnly end @@ -1783,12 +1823,6 @@ local function SetRowIcon(btn, kind, value, iconSize) btn.icon:Show() 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 activeKeybindBtn - -- THEME DEFINITIONS local THEMES = {} @@ -10929,6 +10963,9 @@ function UI:UpdateSelectionHighlight(skipRefocus) searchFrame.editBox:ClearFocus() end Utils.SafeCallMethod(navFrame, "EnableKeyboard", true) + if newSelRow and not toggleFocused and newSelRow.data and newSelRow.data.calculatorResult then + self:ArmCalculatorSelectionForKeyboard(newSelRow) + end else local wasNavigating = navFrame:IsKeyboardEnabled() Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) From 3912b2e37e6a6c69d2e8402ff49f836693699c4d Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Mon, 11 May 2026 21:44:39 -0400 Subject: [PATCH 088/103] Polish search UI and map tab restore --- Core.lua | 23 +- MapTab.lua | 68 +- Options.lua | 46 +- Rescaler.lua | 6 +- UI.lua | 1457 +++++++++++++++++++++++++++++++++- Utils.lua | 57 +- Wizard.lua | 74 +- textures/calculator-icon.tga | Bin 0 -> 16402 bytes 8 files changed, 1597 insertions(+), 134 deletions(-) create mode 100644 textures/calculator-icon.tga diff --git a/Core.lua b/Core.lua index df970f2..1f56bee 100644 --- a/Core.lua +++ b/Core.lua @@ -23,7 +23,7 @@ EasyFind.db = {} -- SavedVariables version. Increment when changing DB schema. -- Each migration runs once: if saved dbVersion < DB_VERSION, run all steps in order. -local DB_VERSION = 11 +local DB_VERSION = 12 -- SavedVariables defaults - new keys are auto-merged for existing users local DB_DEFAULTS = { @@ -37,7 +37,6 @@ local DB_DEFAULTS = { uiResultsScale = 1.0, uiResultsWidth = 350, uiSearchBarHeight = ns.SEARCHBAR_HEIGHT or 30, - searchBarOpacity = 0.75, -- ns.DEFAULT_OPACITY fontSize = 0.9, -- UI search font size multiplier (0.5-2.0) uiSearchPosition = nil, -- {point, relPoint, x, y} localMapDirectOpen = true, @@ -82,7 +81,7 @@ local DB_DEFAULTS = { mapTabFilters = { zones = true, instances = true, - flightpath = false, -- Off by default: zone maps are dense with flight masters + flightpath = true, travel = true, -- Portals, ships, zeppelins, trams (separate from flight paths) services = true, rares = true, @@ -94,9 +93,12 @@ local DB_DEFAULTS = { alwaysShowRares = false, -- Persistent rare tracking: show active rares on map without searching uiSearchFilters = { -- UI search category filters (all enabled by default) achievements = true, + statistics = true, currencies = true, reputations = true, collections = true, + gameOptions = true, + addonOptions = true, mounts = true, toys = true, pets = true, @@ -109,6 +111,9 @@ local DB_DEFAULTS = { options = true, abilities = true, bosses = true, + gearSets = true, + talents = true, + titles = true, map = true, }, lootSpecs = nil, -- Loot search: nil = current spec only, table of {classID, specID} pairs when customized @@ -186,13 +191,11 @@ local DB_MIGRATIONS = { if db.localMapDirectOpen == false then db.localMapDirectOpen = true end if db.globalMapDirectOpen == false then db.globalMapDirectOpen = true end end, - -- [6] = Default flightpath filter to off in MapTab. Pre-existing - -- mapTabFilters tables won't have the key (added when we split - -- flight masters out of the Travel bucket), so they default to nil - -- = enabled. Force false unless the user explicitly turned it on. + -- [6] = Populate the split flightpath filter key. The current + -- default is enabled so the filter menu starts fully checked. [6] = function(db) if db.mapTabFilters and db.mapTabFilters.flightpath == nil then - db.mapTabFilters.flightpath = false + db.mapTabFilters.flightpath = true end end, -- [8] = Theme rename: "Classic" and "Retail" renamed to @@ -221,6 +224,10 @@ local DB_MIGRATIONS = { db.tutorialDone = false db.lastSeenVersion = "2.0.0" end, + -- [12] = Search window background is always solid now. + [12] = function(db) + db.searchBarOpacity = nil + end, } -- Fields that are runtime-only and must not persist in SavedVariables diff --git a/MapTab.lua b/MapTab.lua index a34180a..a6b12f4 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -112,6 +112,8 @@ local RefreshCurrentSearch local ReleaseMapTabMemory local panel local selectedIsOurs = false +local lastSelectedWasOurs = false +local restoringBlizzardDisplayMode = false local function IsMapSearchEnabled() return not (EasyFind and EasyFind.db and EasyFind.db.enableMapSearch == false) end @@ -247,6 +249,7 @@ local function ShowOurPanel() local qmf = _G["QuestMapFrame"] if not qmf or not panel then return end selectedIsOurs = true + lastSelectedWasOurs = true -- Measure Blizzard's SearchBox BEFORE we hide QuestsFrame, so its -- GetLeft/GetRight return live values. Cache is reused for -- subsequent shows if Blizzard's panel isn't visible later. @@ -304,7 +307,9 @@ local function HideOurPanel() if qmf and qmf.SetDisplayMode and qmf.displayMode == nil then local restore = prevBlizzardDisplayMode or qmf.QuestsFrame if restore then + restoringBlizzardDisplayMode = true pcall(qmf.SetDisplayMode, qmf, restore) + restoringBlizzardDisplayMode = false end end prevBlizzardDisplayMode = nil @@ -2595,6 +2600,7 @@ end function MapTab:Initialize() if not IsMapSearchEnabled() then + lastSelectedWasOurs = false if initialized and tabFrame then tabFrame:Hide() end if initialized and panel then if panel.outer then panel.outer:Hide() else panel:Hide() end @@ -2615,14 +2621,24 @@ function MapTab:Initialize() -- lwmt:SetDisplayMode(mode). Hook both so we catch every switch. if qmf.SetDisplayMode then hooksecurefunc(qmf, "SetDisplayMode", function(_, displayMode) - if displayMode and selectedIsOurs then HideOurPanel() end + if displayMode then + if not restoringBlizzardDisplayMode then + lastSelectedWasOurs = false + end + if selectedIsOurs then HideOurPanel() end + end end) end if LibStub then local ok, lwmt = pcall(LibStub, "LibWorldMapTabs", true) if ok and lwmt and type(lwmt.SetDisplayMode) == "function" then hooksecurefunc(lwmt, "SetDisplayMode", function(_, displayMode) - if displayMode and selectedIsOurs then HideOurPanel() end + if displayMode then + if not restoringBlizzardDisplayMode then + lastSelectedWasOurs = false + end + if selectedIsOurs then HideOurPanel() end + end end) end end @@ -2641,12 +2657,32 @@ function MapTab:Initialize() panel.MeasureBlizzardSearch() end end) - -- If a Focus() request opened the map, switch to our tab - -- after Blizzard's OnShow handlers (which restore the last - -- tab) have all run. SafeAfter(0) puts us at the end of - -- this frame's pending callbacks, AFTER Blizzard's restore. + local function shouldRestoreEasyFindTab() + return lastSelectedWasOurs + and not MapTab._pendingFocus + and MapTab._pendingQuery == nil + and IsMapSearchEnabled() + and not (WorldMapFrame.IsMaximized and WorldMapFrame:IsMaximized()) + end + local function restoreEasyFindTab() + if shouldRestoreEasyFindTab() + and panel + and WorldMapFrame and WorldMapFrame:IsShown() then + ShowOurPanel() + end + end + -- If a Focus() request opened the map, switch immediately + -- so the native tab does not flash first. The next-frame + -- pass defends against late tab restoration from Blizzard + -- or another map-tab addon. if MapTab._pendingFocus then MapTab._pendingFocus = nil + if panel then + ShowOurPanel() + if panel.searchBox and panel:IsShown() then + panel.searchBox:SetFocus() + end + end SafeAfter(0, function() if panel then ShowOurPanel() @@ -2662,6 +2698,13 @@ function MapTab:Initialize() if MapTab._pendingQuery ~= nil and not MapTab._pendingFocus then local q = MapTab._pendingQuery MapTab._pendingQuery = nil + if panel then + ShowOurPanel() + if panel.searchBox and panel:IsShown() then + panel.searchBox:SetText(q or "") + panel.searchBox:ClearFocus() + end + end SafeAfter(0, function() if panel then ShowOurPanel() @@ -2672,9 +2715,22 @@ function MapTab:Initialize() end end) end + if shouldRestoreEasyFindTab() then + restoreEasyFindTab() + SafeAfter(0, restoreEasyFindTab) + else + SafeAfter(0, function() + local q = _G["QuestMapFrame"] + if q and q.SetDisplayMode and q.displayMode == nil and not selectedIsOurs then + local restore = prevBlizzardDisplayMode or q.QuestsFrame + if restore then pcall(q.SetDisplayMode, q, restore) end + end + end) + end end) WorldMapFrame:HookScript("OnHide", function() if selectedIsOurs then + lastSelectedWasOurs = true HideOurPanel() elseif ReleaseMapTabMemory then ReleaseMapTabMemory(true) diff --git a/Options.lua b/Options.lua index b4772fa..08617fa 100644 --- a/Options.lua +++ b/Options.lua @@ -10,7 +10,6 @@ local tonumber, tostring = Utils.tonumber, Utils.tostring local tinsert, tconcat = Utils.tinsert, Utils.tconcat local IsMouseButtonDown = IsMouseButtonDown -local DEFAULT_OPACITY = ns.DEFAULT_OPACITY local OPTIONS_PANEL_ALPHA = 0.9 local optionsFrame @@ -20,13 +19,19 @@ local blizzardRegistered = false local NIL = {} local DEFAULT_UI_FILTERS = { ui = true, achievements = true, statistics = true, currencies = true, - reputations = true, collections = true, mounts = true, toys = true, - pets = true, outfits = true, heirlooms = true, loot = true, - appearanceSets = true, bags = true, macros = true, options = true, - abilities = true, bosses = true, map = true, + reputations = true, collections = true, gameOptions = true, + addonOptions = true, mounts = true, toys = true, pets = true, + outfits = true, heirlooms = true, loot = true, appearanceSets = true, + bags = true, macros = true, options = true, abilities = true, + bosses = true, gearSets = true, talents = true, titles = true, + map = true, } local DEFAULT_GLOBAL_SEARCH_FILTERS = { zones = true, dungeons = true, raids = true, delves = true } local DEFAULT_LOCAL_SEARCH_FILTERS = { instances = true, travel = true, services = true, rares = true } +local DEFAULT_MAP_TAB_FILTERS = { + zones = true, instances = true, flightpath = true, travel = true, + services = true, rares = true, +} local UI_DEFAULTS = { smartShow = false, @@ -65,11 +70,11 @@ local MAP_DEFAULTS = { autoTrackPins = true, globalSearchFilters = DEFAULT_GLOBAL_SEARCH_FILTERS, localSearchFilters = DEFAULT_LOCAL_SEARCH_FILTERS, + mapTabFilters = DEFAULT_MAP_TAB_FILTERS, alwaysShowRares = false, } local GENERAL_DEFAULTS = { - searchBarOpacity = DEFAULT_OPACITY, tutorialDone = false, resultsTheme = "Modern", font = "Default", @@ -162,7 +167,6 @@ end local function SyncOptionControls() if not optionsFrame then return end - if optionsFrame.opacityPresetRow then optionsFrame.opacityPresetRow:SetValue(EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) end if optionsFrame.uiFontPresetRow then optionsFrame.uiFontPresetRow:SetValue(EasyFind.db.fontSize or 0.9) end if optionsFrame.mapIconPresetRow then optionsFrame.mapIconPresetRow:SetValue(EasyFind.db.iconScale or 0.8) end if optionsFrame.recentCountStepper then optionsFrame.recentCountStepper:SetValue(EasyFind.db.mapTabRecentCount or 3) end @@ -863,7 +867,7 @@ function Options:Initialize() ns.SetRoundedRectBarHeight(sidebar, 14) ns.SetRoundedRectBorderBgAlpha(sidebar, 0.72) HideRoundedBorder(sidebar) - TintRoundedFill(sidebar, 0.035, 0.035, 0.042) + TintRoundedFill(sidebar, 0.022, 0.022, 0.028) local divider = optionsFrame:CreateTexture(nil, "ARTWORK") divider:SetPoint("TOPLEFT", sidebar, "TOPRIGHT", 8, -2) @@ -1303,23 +1307,6 @@ function Options:Initialize() end) optionsFrame.resultShortcutHintsCheckbox = resultShortcutHintsCheckbox - local opacityChoices = { - { label = "Light", value = 0.45 }, - { label = "Balanced", value = DEFAULT_OPACITY }, - { label = "Solid", value = 0.95 }, - } - local opacityPresetRow = CreatePresetRow(sec1, "Background Opacity", opacityChoices, - function() return EasyFind.db.searchBarOpacity or DEFAULT_OPACITY end, - function(value) - EasyFind.db.searchBarOpacity = value - if ns.UI and ns.UI.UpdateOpacity then - ns.UI:UpdateOpacity() - end - end, - "Controls the UI search window background density. Text and icons remain fully visible.") - opacityPresetRow:SetPoint("TOPLEFT", resultShortcutHintsCheckbox, "BOTTOMLEFT", 0, -8) - optionsFrame.opacityPresetRow = opacityPresetRow - local fontSizeChoices = { { label = "Small", value = 0.80 }, { label = "Med", value = 0.90 }, @@ -1335,13 +1322,10 @@ function Options:Initialize() end end, "Adjusts UI search text size without resizing the search window.") - uiFontPresetRow:SetPoint("TOPLEFT", opacityPresetRow, "BOTTOMLEFT", 0, -2) + uiFontPresetRow:SetPoint("TOPLEFT", resultShortcutHintsCheckbox, "BOTTOMLEFT", 0, -8) optionsFrame.uiFontPresetRow = uiFontPresetRow local function RefreshUIPresetRows() - if optionsFrame.opacityPresetRow then - optionsFrame.opacityPresetRow:SetValue(EasyFind.db.searchBarOpacity or DEFAULT_OPACITY) - end if optionsFrame.uiFontPresetRow then optionsFrame.uiFontPresetRow:SetValue(EasyFind.db.fontSize or 0.9) end @@ -1576,6 +1560,10 @@ function Options:Initialize() .. "|cFF00FF00Tab/Shift+Tab|r or |cFF00FF00Ctrl+L/H|r Cycle focus to nav buttons\n" .. "|cFF00FF00PgUp/PgDn|r jump 5 |cFF00FF00Home/End|r first/last\n" .. "|cFF00FF00Shift+Up/Down|r or |cFF00FF00Ctrl+Shift+K/J|r jump section\n\n" + .. "|cFFFFD100Quick filters:|r\n" + .. "Type |cFF00FF00@|r for category filters. Examples: |cFF00FF00@m|r mounts, |cFF00FF00@s|r statistics, |cFF00FF00@g|r gear. |cFF00FF00Tab/Space|r selects a category.\n\n" + .. "|cFFFFD100Calculator:|r\n" + .. "Type math into search. |cFF00FF00Alt+C|r opens the calculator.\n\n" .. "|cFFFFD100Other:|r\n" .. "|cFF00FF00Shift+Drag|r reposition |cFF00FF00Right-click|r pin/unpin\n\n" .. "|cFFFFD100Slash commands:|r\n" diff --git a/Rescaler.lua b/Rescaler.lua index f0a4a3d..4a57ee4 100644 --- a/Rescaler.lua +++ b/Rescaler.lua @@ -501,7 +501,11 @@ local function CreateMockWindowFrame(parent, searchBar, preview, anchorAbove) ns.CreateRoundedRectBorder(frame) ns.CreateRoundedRectDivider(frame) ns.SetRoundedRectBarHeight(frame, searchBar:GetHeight()) - ns.SetRoundedRectBorderBgAlpha(frame, EasyFind.db.searchBarOpacity or ns.DEFAULT_OPACITY or 0.88) + if ns.SetRoundedRectBorderFillColor then + local c = ns.SEARCH_WINDOW_FILL_COLOR or {0.052, 0.052, 0.060} + ns.SetRoundedRectBorderFillColor(frame, c[1], c[2], c[3], 1) + end + ns.SetRoundedRectBorderBgAlpha(frame, ns.SEARCH_WINDOW_ALPHA or 0.95) frame.UpdateLayout = function(self) local dividerOffset = anchorAbove and preview:GetHeight() or searchBar:GetHeight() diff --git a/UI.lua b/UI.lua index cea16f5..e49504f 100644 --- a/UI.lua +++ b/UI.lua @@ -3,6 +3,12 @@ local _, ns = ... local UI = {} ns.UI = UI +function UI:ApplySearchWindowFill(frame) + if not (frame and ns.SetRoundedRectBorderFillColor) then return end + local c = ns.SEARCH_WINDOW_FILL_COLOR or {0.052, 0.052, 0.060} + ns.SetRoundedRectBorderFillColor(frame, c[1], c[2], c[3], 1) +end + local Utils = ns.Utils local UIPins = ns.UIPins local GetButtonText = Utils.GetButtonText @@ -165,6 +171,7 @@ local RESULT_SHORTCUT_WIDTH = 34 local RESULT_SHORTCUT_ICON_SIZE = 14 local RESULT_SHORTCUT_RIGHT_PAD = 0 local RESULT_SHORTCUT_GAP = -4 +local CALCULATOR_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\calculator-icon" -- Hard render cap on the search-match portion of the result list. Pinned -- items are counted separately. Pre-render cap in OnSearchTextChanged -- already trims to 15 before flattening; this is a backstop for any @@ -219,7 +226,7 @@ local FLAT_CATEGORY_ICONS = { -- glance from the silvery-grey game-settings cogwheel. settingAddon = { atlas = "QuestLog-icon-setting", color = { 1.0, 0.78, 0.35 } }, title = { tex = 514608, coords = { 0.016, 0.531, 0.324, 0.461 } }, - calculator = { tex = "Interface\\Icons\\INV_Misc_Note_05" }, + calculator = { tex = CALCULATOR_ICON_TEX }, -- Resolved lazily from PaperDollSidebarTab3 so the icon always -- matches whatever sprite-sheet region Blizzard uses for the -- Equipment Manager sidebar tab. Filled in by ResolveGearSetIcon(). @@ -267,6 +274,30 @@ local REP_FACTION_ICONS = { local function GetFlatCategoryIcon(data) if not data then return nil end if data.calculatorResult then return FLAT_CATEGORY_ICONS.calculator end + if data.quickFilterDef then + local key = data.quickFilterDef.key + if key == "abilities" then return FLAT_CATEGORY_ICONS.ability end + if key == "achievements" then return FLAT_CATEGORY_ICONS.achievement end + if key == "statistics" then return FLAT_CATEGORY_ICONS.statistic end + if key == "bags" then return FLAT_CATEGORY_ICONS.bag end + if key == "bosses" then return FLAT_CATEGORY_ICONS.boss end + if key == "macros" then return FLAT_CATEGORY_ICONS.macro end + if key == "collections" then return FLAT_CATEGORY_ICONS.mount end + if key == "appearanceSets" then return FLAT_CATEGORY_ICONS.appearanceSet end + if key == "heirlooms" then return FLAT_CATEGORY_ICONS.heirloom end + if key == "mounts" then return FLAT_CATEGORY_ICONS.mount end + if key == "outfits" then return FLAT_CATEGORY_ICONS.outfit end + if key == "pets" then return FLAT_CATEGORY_ICONS.pet end + if key == "toys" then return FLAT_CATEGORY_ICONS.toy end + if key == "gearSets" then return FLAT_CATEGORY_ICONS.gearSet end + if key == "currencies" then return FLAT_CATEGORY_ICONS.currency end + if key == "loot" then return FLAT_CATEGORY_ICONS.loot end + if key == "map" then return FLAT_CATEGORY_ICONS.map end + if key == "reputations" then return FLAT_CATEGORY_ICONS.reputation end + if key == "talents" then return FLAT_CATEGORY_ICONS.talent end + if key == "titles" then return FLAT_CATEGORY_ICONS.title end + return FLAT_CATEGORY_ICONS.setting + end if data.mountID then return FLAT_CATEGORY_ICONS.mount end if data.toyItemID then return FLAT_CATEGORY_ICONS.toy end if data.petID then return FLAT_CATEGORY_ICONS.pet end @@ -367,6 +398,8 @@ end local function GetFlatSubtext(data) if not data then return "" end if data.calculatorResult then return "Expression" end + if data.quickFilterAliasText then return data.quickFilterAliasText end + if data.quickFilterDef then return data.quickFilterDef.label or "Quick Filter" end if data.path and #data.path > 0 then return data.path[#data.path] end @@ -933,6 +966,540 @@ function UI:EvaluateCalculatorExpression(raw) } end +function UI:SetCalculatorRoundedFill(frame, r, g, b, a, br, bg, bb, ba) + if not (frame and frame.combinedBorder) then return end + if frame.combinedBorder.fill then + for _, tex in pairs(frame.combinedBorder.fill) do + tex:SetVertexColor(r, g, b, 1) + tex:SetAlpha(a or 1) + if tex.SetSnapToPixelGrid then tex:SetSnapToPixelGrid(false) end + if tex.SetTexelSnappingBias then tex:SetTexelSnappingBias(0) end + end + end + if frame.combinedBorder.border then + for _, tex in pairs(frame.combinedBorder.border) do + tex:SetVertexColor(br or 0.30, bg or 0.30, bb or 0.32, ba or 0.85) + if tex.SetSnapToPixelGrid then tex:SetSnapToPixelGrid(false) end + if tex.SetTexelSnappingBias then tex:SetTexelSnappingBias(0) end + end + end +end + +function UI:HideCalculatorRoundedBorder(frame) + if not (frame and frame.combinedBorder and frame.combinedBorder.border) then return end + for _, tex in pairs(frame.combinedBorder.border) do + tex:Hide() + end +end + +function UI:StyleCalculatorButton(btn, height) + if not btn then return end + if not btn.combinedBorder then + ns.CreateRoundedRectBorder(btn) + end + ns.SetRoundedRectBarHeight(btn, mmin(height or btn:GetHeight() or 22, 10)) + ns.SetRoundedRectBorderBgAlpha(btn, 1) + self:HideCalculatorRoundedBorder(btn) + self:SetCalculatorRoundedFill(btn, 0.095, 0.095, 0.108, 1) + btn:SetScript("OnEnter", function(self) + if self:IsEnabled() then UI:SetCalculatorRoundedFill(self, 0.155, 0.155, 0.172, 1) end + end) + btn:SetScript("OnLeave", function(self) + if self:IsEnabled() then UI:SetCalculatorRoundedFill(self, 0.095, 0.095, 0.108, 1) end + end) + btn:SetScript("OnMouseDown", function(self) + if self:IsEnabled() then UI:SetCalculatorRoundedFill(self, 0.065, 0.065, 0.078, 1) end + end) + btn:SetScript("OnMouseUp", function(self) + if not self:IsEnabled() then return end + if self:IsMouseOver() then + UI:SetCalculatorRoundedFill(self, 0.155, 0.155, 0.172, 1) + else + UI:SetCalculatorRoundedFill(self, 0.095, 0.095, 0.108, 1) + end + end) +end + +function UI:CreateCalculatorGlyph(parent, size) + local glyph = CreateFrame("Frame", nil, parent) + size = size or 22 + glyph:SetSize(size, size) + ns.CreateRoundedRectBorder(glyph) + ns.SetRoundedRectBarHeight(glyph, mmin(size, 10)) + ns.SetRoundedRectBorderBgAlpha(glyph, 1) + self:HideCalculatorRoundedBorder(glyph) + self:SetCalculatorRoundedFill(glyph, 0.095, 0.095, 0.108, 1) + + local icon = glyph:CreateTexture(nil, "ARTWORK") + icon:SetPoint("TOPLEFT", glyph, "TOPLEFT", 3, -3) + icon:SetPoint("BOTTOMRIGHT", glyph, "BOTTOMRIGHT", -3, 3) + icon:SetTexture(FLAT_CATEGORY_ICONS.calculator.tex) + icon:SetTexCoord(0, 1, 0, 1) + icon:SetVertexColor(1, 1, 1, 1) + glyph.icon = icon + return glyph +end + +function UI:RefreshCalculatorPopup() + local frame = UI._calculator.popupFrame + local editBox = UI._calculator.popupEditBox + if not frame or not editBox then return end + + local raw = editBox:GetText() or "" + local text = strtrim(raw) + local submitted = UI._calculator.popupSubmitted + and UI._calculator.popupSubmittedText == raw + local data = submitted and self:EvaluateCalculatorExpression(raw) or nil + UI._calculator.popupData = data + if frame.clearInputButton then + if text ~= "" then + frame.clearInputButton:Show() + else + frame.clearInputButton:Hide() + end + end + if submitted and data then + frame.resultText:SetText(data.calculatorResult) + frame.expressionText:SetText(data.calculatorExpression) + frame.resultText:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1) + if frame.hintText then frame.hintText:Hide() end + self:SetCalculatorRoundedFill(frame.resultCard, 0.19, 0.16, 0.10, 0.98, 0.76, 0.56, 0.22, 0.90) + else + frame.resultText:SetText(submitted and text ~= "" and "-" or "") + frame.expressionText:SetText(text ~= "" and text or "Type an expression") + frame.resultText:SetTextColor(0.58, 0.58, 0.60, 1) + if frame.hintText then frame.hintText:Hide() end + self:SetCalculatorRoundedFill(frame.resultCard, 0.13, 0.13, 0.145, 0.96, 0.28, 0.28, 0.30, 0.75) + end +end + +function UI:InsertCalculatorPopupText(text) + local editBox = UI._calculator.popupEditBox + if not editBox then return end + text = tostring(text or "") + if text == "" then return end + + local current = editBox:GetText() or "" + local pos = editBox:GetCursorPosition() or #current + editBox:SetText(current:sub(1, pos) .. text .. current:sub(pos + 1)) + editBox:SetCursorPosition(pos + #text) + editBox:SetFocus() + self:RefreshCalculatorPopup() +end + +function UI:BackspaceCalculatorPopup() + local editBox = UI._calculator.popupEditBox + if not editBox then return end + local current = editBox:GetText() or "" + local pos = editBox:GetCursorPosition() or #current + if pos <= 0 then return end + editBox:SetText(current:sub(1, pos - 1) .. current:sub(pos + 1)) + editBox:SetCursorPosition(pos - 1) + editBox:SetFocus() + self:RefreshCalculatorPopup() +end + +function UI:UseCalculatorPopupResult() + local editBox = UI._calculator.popupEditBox + if not editBox then return false end + return self:SubmitCalculatorPopupExpression(editBox:GetText() or "") +end + +function UI:SubmitCalculatorPopupExpression(raw) + local editBox = UI._calculator.popupEditBox + if not editBox then return false end + raw = tostring(raw or "") + raw = raw:gsub("%s*=$", "") + UI._calculator.popupSubmitting = true + UI._calculator.popupSubmitted = true + UI._calculator.popupSubmittedText = raw + UI._calculator.popupData = self:EvaluateCalculatorExpression(raw) + if editBox:GetText() ~= raw then + editBox:SetText(raw) + end + editBox:SetCursorPosition(#raw) + editBox:SetFocus() + UI._calculator.popupSubmitting = nil + self:RefreshCalculatorPopup() + return UI._calculator.popupData and true or false +end + +function UI:IsCalculatorPopupSubmitKey(key) + return key == "ENTER" + or key == "=" + or key == "EQUAL" + or key == "EQUALS" + or key == "NUMPADEQUALS" +end + +function UI:SwallowCalculatorPopupChar(editBox) + if not editBox then return end + Utils.SafeCallMethod(editBox, "SetPropagateKeyboardInput", false) + if not editBox.SetEnabled then return end + + editBox:SetEnabled(false) + Utils.SafeAfter(0, function() + local frame = UI._calculator.popupFrame + if frame and frame:IsShown() and editBox:IsVisible() then + editBox:SetEnabled(true) + editBox:SetFocus() + editBox:SetCursorPosition(#(editBox:GetText() or "")) + end + end) +end + +function UI:QueueCalculatorPopupEqualsSubmit(editBox) + if UI._calculator.popupEqualSubmitQueued then return end + UI._calculator.popupEqualSubmitQueued = true + Utils.SafeAfter(0, function() + UI._calculator.popupEqualSubmitQueued = nil + local frame = UI._calculator.popupFrame + if not (frame and frame:IsShown() and editBox and editBox:IsVisible()) then return end + local raw = editBox:GetText() or "" + if raw:match("%s*=$") then + UI:SubmitCalculatorPopupExpression(raw) + end + end) +end + +function UI:ArmCalculatorPopupResult() + local data = UI._calculator.popupData + if not data or not data.calculatorResult then return false end + UI._calculator.activeRow = nil + UI._calculator.activeData = data + UI._calculator.activeResult = data.calculatorResult + UI._calculator.activePart = "result" + UI._calculator.activeSource = "calculator" + UI._calculator.copyComplete = nil + UI._calculator.copyCompleteValue = nil + UI._calculator.copiedData = nil + UI._calculator.copiedPart = nil + if self:CopyCalculatorResult(data.calculatorResult, "calculator") then + self:StartCalculatorCopyWatcher() + return true + end + return false +end + +function UI:RestoreCalculatorPopupFocus() + local frame = UI._calculator.popupFrame + local editBox = UI._calculator.popupEditBox + if frame and frame:IsShown() and editBox and editBox:IsVisible() then + editBox:SetFocus() + editBox:SetCursorPosition(#(editBox:GetText() or "")) + end +end + +function UI:EnsureCalculatorFrame() + local frame = UI._calculator.popupFrame + if frame then return frame end + + frame = CreateFrame("Frame", "EasyFindCalculatorFrame", UIParent) + UI._calculator.popupFrame = frame + frame:SetSize(284, 304) + frame:SetPoint("CENTER") + frame:SetFrameStrata("FULLSCREEN_DIALOG") + frame:SetFrameLevel(900) + frame:SetClampedToScreen(true) + frame:EnableMouse(true) + frame:SetMovable(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", frame.StartMoving) + frame:SetScript("OnDragStop", frame.StopMovingOrSizing) + ns.CreateRoundedRectBorder(frame) + ns.SetRoundedRectBarHeight(frame, 14) + ns.SetRoundedRectBorderBgAlpha(frame, 0.98) + self:SetCalculatorRoundedFill(frame, 0.055, 0.055, 0.064, 0.98, 0.30, 0.30, 0.32, 0.95) + + local glyph = self:CreateCalculatorGlyph(frame, 22) + glyph:SetPoint("TOPLEFT", frame, "TOPLEFT", 14, -12) + + local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + title:SetPoint("LEFT", glyph, "RIGHT", 8, 0) + title:SetText("Calculator") + title:SetTextColor(1, 1, 1, 1) + + local close = CreateFrame("Button", nil, frame) + close:SetSize(18, 18) + close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -14, -12) + local function makeCloseStroke() + local tex = close:CreateTexture(nil, "OVERLAY") + tex:SetTexture("Interface\\Buttons\\WHITE8x8") + tex:SetSize(16, 1.5) + tex:SetPoint("CENTER") + return tex + end + local closeStroke1 = makeCloseStroke(); closeStroke1:SetRotation(math.pi / 4) + local closeStroke2 = makeCloseStroke(); closeStroke2:SetRotation(-math.pi / 4) + local function setCloseColor(r, g, b) + closeStroke1:SetVertexColor(r, g, b, 1) + closeStroke2:SetVertexColor(r, g, b, 1) + end + setCloseColor(0.55, 0.55, 0.58) + close:SetScript("OnEnter", function() setCloseColor(1, 1, 1) end) + close:SetScript("OnLeave", function() setCloseColor(0.55, 0.55, 0.58) end) + close:SetScript("OnClick", function() frame:Hide() end) + + local inputShell = CreateFrame("Frame", nil, frame) + inputShell:SetPoint("TOPLEFT", frame, "TOPLEFT", 14, -44) + inputShell:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -14, -44) + inputShell:SetHeight(38) + ns.CreateRoundedRectBorder(inputShell) + ns.SetRoundedRectBarHeight(inputShell, 10) + ns.SetRoundedRectBorderBgAlpha(inputShell, 1) + self:SetCalculatorRoundedFill(inputShell, 0.105, 0.105, 0.118, 1, 0.26, 0.26, 0.28, 0.90) + + local clearInput = CreateFrame("Button", nil, inputShell) + frame.clearInputButton = clearInput + clearInput:SetSize(18, 18) + clearInput:SetPoint("RIGHT", inputShell, "RIGHT", -9, 0) + local function makeClearStroke() + local tex = clearInput:CreateTexture(nil, "OVERLAY") + tex:SetTexture("Interface\\Buttons\\WHITE8x8") + tex:SetSize(12, 1.4) + tex:SetPoint("CENTER") + return tex + end + local clearStroke1 = makeClearStroke(); clearStroke1:SetRotation(math.pi / 4) + local clearStroke2 = makeClearStroke(); clearStroke2:SetRotation(-math.pi / 4) + local function setClearColor(r, g, b) + clearStroke1:SetVertexColor(r, g, b, 1) + clearStroke2:SetVertexColor(r, g, b, 1) + end + setClearColor(0.58, 0.58, 0.60) + clearInput:SetScript("OnEnter", function() setClearColor(1, 0.82, 0.36) end) + clearInput:SetScript("OnLeave", function() setClearColor(0.58, 0.58, 0.60) end) + clearInput:Hide() + + local editBox = CreateFrame("EditBox", nil, inputShell) + UI._calculator.popupEditBox = editBox + editBox:SetPoint("LEFT", inputShell, "LEFT", 12, 0) + editBox:SetPoint("RIGHT", clearInput, "LEFT", -6, 0) + editBox:SetHeight(30) + editBox:SetAutoFocus(false) + editBox:SetFontObject(GameFontHighlightLarge) + editBox:SetTextColor(0.96, 0.96, 0.96, 1) + editBox:SetJustifyH("LEFT") + editBox:SetScript("OnTextChanged", function(self) + local raw = self:GetText() or "" + if raw:match("%s*=$") then + UI:QueueCalculatorPopupEqualsSubmit(self) + return + end + if not UI._calculator.popupSubmitting then + UI._calculator.popupSubmitted = nil + UI._calculator.popupSubmittedText = nil + UI._calculator.popupData = nil + end + UI:RefreshCalculatorPopup() + end) + editBox:SetScript("OnEscapePressed", function() + frame:Hide() + end) + editBox:SetScript("OnEnterPressed", function() + UI:UseCalculatorPopupResult() + end) + editBox:SetScript("OnKeyDown", function(self, key) + if UI:HandleCalculatorCopyConfirmKey(key) then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + end + if key == "ESCAPE" then + frame:Hide() + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + end + if UI:IsCalculatorPopupSubmitKey(key) then + UI:UseCalculatorPopupResult() + if key ~= "ENTER" then + UI:SwallowCalculatorPopupChar(self) + else + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + end + return + end + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + end) + clearInput:SetScript("OnClick", function() + UI._calculator.popupSubmitted = nil + UI._calculator.popupSubmittedText = nil + UI._calculator.popupData = nil + editBox:SetText("") + editBox:SetFocus() + UI:RefreshCalculatorPopup() + end) + + local resultCard = CreateFrame("Button", nil, frame) + frame.resultCard = resultCard + resultCard:SetPoint("TOPLEFT", inputShell, "BOTTOMLEFT", 0, -8) + resultCard:SetPoint("TOPRIGHT", inputShell, "BOTTOMRIGHT", 0, -8) + resultCard:SetHeight(58) + resultCard:RegisterForClicks("LeftButtonUp") + ns.CreateRoundedRectBorder(resultCard) + ns.SetRoundedRectBarHeight(resultCard, 10) + ns.SetRoundedRectBorderBgAlpha(resultCard, 1) + self:SetCalculatorRoundedFill(resultCard, 0.13, 0.13, 0.145, 0.96, 0.28, 0.28, 0.30, 0.75) + + local expressionText = resultCard:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + frame.expressionText = expressionText + expressionText:SetPoint("TOPLEFT", resultCard, "TOPLEFT", 12, -8) + expressionText:SetPoint("RIGHT", resultCard, "RIGHT", -12, 0) + expressionText:SetJustifyH("LEFT") + expressionText:SetTextColor(0.66, 0.66, 0.68, 1) + + local resultText = resultCard:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge") + frame.resultText = resultText + resultText:SetPoint("BOTTOMRIGHT", resultCard, "BOTTOMRIGHT", -12, 10) + resultText:SetJustifyH("RIGHT") + resultText:SetText("-") + + local hintText = resultCard:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + frame.hintText = hintText + hintText:SetPoint("BOTTOMLEFT", resultCard, "BOTTOMLEFT", 12, 10) + hintText:SetPoint("RIGHT", resultText, "LEFT", -8, 0) + hintText:SetJustifyH("LEFT") + hintText:SetTextColor(0.54, 0.54, 0.56, 1) + hintText:Hide() + + resultCard:SetScript("OnEnter", function(self) + if UI._calculator.popupData then + UI:SetCalculatorRoundedFill(self, 0.22, 0.18, 0.11, 1, 0.95, 0.72, 0.28, 1) + end + end) + resultCard:SetScript("OnLeave", function(self) + UI:RefreshCalculatorPopup() + end) + resultCard:SetScript("OnClick", function() + UI:ArmCalculatorPopupResult() + end) + + local function makeButton(label, insertText) + local b = CreateFrame("Button", nil, frame) + b:SetSize(48, 24) + UI:StyleCalculatorButton(b, 24) + b.text = b:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + b.text:SetPoint("CENTER") + b.text:SetText(label) + b.text:SetTextColor(0.95, 0.95, 0.95, 1) + b:HookScript("OnClick", function() + if label == "C" then + editBox:SetText("") + editBox:SetFocus() + elseif label == "Back" then + UI:BackspaceCalculatorPopup() + elseif label == "=" then + UI:UseCalculatorPopupResult() + elseif insertText then + UI:InsertCalculatorPopupText(insertText) + else + UI:InsertCalculatorPopupText(label) + end + end) + return b + end + + local rows = { + { { "sin", "sin(" }, { "cos", "cos(" }, { "tan", "tan(" }, { "(", "(" }, { ")", ")" } }, + { { "7" }, { "8" }, { "9" }, { "/", "/" }, { "C" } }, + { { "4" }, { "5" }, { "6" }, { "*", "*" }, { "Back" } }, + { { "1" }, { "2" }, { "3" }, { "-", "-" }, { "!" } }, + { { "0" }, { "." }, { "=", nil }, { "+", "+" }, { "^", "^" } }, + } + local gridTop = -154 + for row = 1, #rows do + for col = 1, #rows[row] do + local spec = rows[row][col] + local b = makeButton(spec[1], spec[2]) + b:SetPoint("TOPLEFT", frame, "TOPLEFT", 14 + (col - 1) * 52, gridTop - (row - 1) * 28) + end + end + + frame:SetScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end) + frame:SetScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + if UI._calculator.activeSource == "calculator" then + UI:ClearCalculatorCopyHighlight() + UI:ReleaseCalculatorCopyBox() + end + end) + frame:SetScript("OnEvent", function(self, event) + if event ~= "GLOBAL_MOUSE_DOWN" then return end + if self:IsMouseOver() then return end + if searchFrame and searchFrame:IsMouseOver() then return end + if resultsFrame and resultsFrame:IsMouseOver() then return end + self:Hide() + end) + frame:Hide() + self:RefreshCalculatorPopup() + return frame +end + +function UI:CloseSearchForCalculator() + if searchFrame and searchFrame.editBox then + local editBox = searchFrame.editBox + if editBox.ResetPendingSearch then editBox:ResetPendingSearch() end + editBox:SetText("") + editBox:ClearFocus() + if editBox.placeholder then editBox.placeholder:Show() end + end + self:ClearQuickFilter(false) + self:HideQuickFilterSuggestions() + if searchFrame and searchFrame:IsShown() then + self:Hide() + else + self:HideResults() + end +end + +function UI:OpenCalculator(expression, deferFocus) + self:CloseSearchForCalculator() + + local frame = self:EnsureCalculatorFrame() + local editBox = UI._calculator.popupEditBox + UI._calculator.popupSubmitted = nil + UI._calculator.popupSubmittedText = nil + UI._calculator.popupData = nil + if editBox then + editBox:SetText("") + editBox:SetCursorPosition(0) + end + frame:ClearAllPoints() + frame:SetPoint("CENTER") + frame:Show() + frame:Raise() + if editBox and not deferFocus then + editBox:SetFocus() + elseif editBox and C_Timer and C_Timer.After then + C_Timer.After(0, function() + if frame and frame:IsShown() and editBox:IsVisible() then + editBox:SetFocus() + editBox:SetCursorPosition(#(editBox:GetText() or "")) + end + end) + end + self:RefreshCalculatorPopup() + return true +end + +function UI:IsCalculatorOpenShortcut(key) + if key ~= "C" and key ~= "c" then return false end + if not IsAltKeyDown or not IsAltKeyDown() then return false end + if (IsControlKeyDown and IsControlKeyDown()) + or (IsShiftKeyDown and IsShiftKeyDown()) then + return false + end + return true +end + +function UI:HandleCalculatorOpenShortcut(editBox, key) + if not self:IsCalculatorOpenShortcut(key) then return false end + local expression = editBox and editBox.GetText and editBox:GetText() + self:OpenCalculator(expression, true) + return true +end + function UI:EnsureCalculatorCopyBox() local box = UI._calculator.copyBox if box then return box end @@ -1291,6 +1858,7 @@ function UI:ConfirmCalculatorCopied() local data = UI._calculator.activeData local part = UI._calculator.activePart if not data or (part ~= "expression" and part ~= "result") then return false end + local popupCopy = UI._calculator.activeSource == "calculator" self:RearmActiveCalculatorCopy("confirm") UI._calculator.copiedData = data @@ -1318,11 +1886,19 @@ function UI:ConfirmCalculatorCopied() if C_Timer and C_Timer.After then C_Timer.After(0, function() UI:ReleaseCalculatorCopyBox("confirm") - UI:RestoreSearchFocusAfterCalculatorCopy() + if popupCopy then + UI:RestoreCalculatorPopupFocus() + else + UI:RestoreSearchFocusAfterCalculatorCopy() + end end) else self:ReleaseCalculatorCopyBox("confirm") - self:RestoreSearchFocusAfterCalculatorCopy() + if popupCopy then + self:RestoreCalculatorPopupFocus() + else + self:RestoreSearchFocusAfterCalculatorCopy() + end end return true end @@ -1625,6 +2201,7 @@ end local function GetActionHint(data) if not data then return nil end if data.calculatorResult then return "Select result for Ctrl+C" end + if data.quickFilterDef then return "Select to filter results" end if data.titleID then return "Select to apply as your title" end if data.mountID then return "Select to summon mount" end if data.petID or data.speciesID then return "Select to summon pet | Ctrl+click to show in journal" end @@ -1919,6 +2496,10 @@ local function ApplyResultRowFonts(row, theme) ScaleFont(row.calcResultText, "GameFontHighlightLarge") ScaleFont(row.calcExpressionHint, "GameFontDisableSmall") ScaleFont(row.calcResultHint, "GameFontDisableSmall") + ScaleFont(row.calcActionTitle, "GameFontHighlightSmall") + ScaleFont(row.calcActionDesc, "GameFontDisableSmall") + ScaleFont(row.calcActionKey, "GameFontDisableSmall") + ScaleFont(row.calcActionAfter, "GameFontDisableSmall") ScaleFont(row.repBarText, "GameFontNormalSmall") ScaleFont(row.settingSliderValue, "GameFontNormalSmall") ScaleFont(row.shortcutNumberText, "GameFontDisableSmall") @@ -2149,6 +2730,7 @@ function UI:CreateSearchFrame() ns.CreateRoundedRectBorder(containerFrame) ns.CreateRoundedRectDivider(containerFrame) ns.SetRoundedRectBarHeight(containerFrame, searchFrame:GetHeight()) + self:ApplySearchWindowFill(containerFrame) -- Sibling-of-searchFrame so the container's textures sit BEHIND -- the bar's content (children would render in front). The trade- @@ -2169,7 +2751,7 @@ function UI:CreateSearchFrame() -- doesn't crash, but the textures stay invisible. ns.SetSearchBorderShown(searchFrame, false) ns.SetRoundedRectBorderShown(containerFrame, true) - ns.SetRoundedRectBorderBgAlpha(containerFrame, EasyFind.db.searchBarOpacity or ns.DEFAULT_OPACITY) + ns.SetRoundedRectBorderBgAlpha(containerFrame, ns.SEARCH_WINDOW_ALPHA or 0.95) -- Static magnifying-glass icon (non-interactive, flush left) local contentSz = barH * ns.SEARCHBAR_FILL @@ -2189,6 +2771,7 @@ function UI:CreateSearchFrame() iconHolder.icon = searchIcon searchFrame.searchIcon = searchIcon searchFrame.modeBtn = iconHolder + searchFrame.iconHolder = iconHolder -- Editbox local editBox = CreateFrame("EditBox", "EasyFindSearchBox", searchFrame) @@ -2393,6 +2976,7 @@ function UI:CreateSearchFrame() if self.IsAutocompleteBackspaceStrip and self:IsAutocompleteBackspaceStrip() then return end historyIndex = 0 historyDraft = "" + if UI:HandleQuickFilterTextChanged(self) then return end -- Search query is the text up to the cursor -- anything past -- the cursor is unaccepted autocomplete suffix and must not -- feed into search results. Programmatic autocomplete SetText @@ -2521,6 +3105,11 @@ function UI:CreateSearchFrame() for i = 1, MAX_BUTTON_POOL do local row = resultButtons[i] if not row or not row:IsShown() then break end + local quickDef = row.data and row.data.quickFilterDef + local quickToken = quickDef and UI:GetQuickFilterCompletionToken(quickDef, typed) + if quickToken then + return quickToken + end local rawName = row.data and row.data.name local nm = StripMarkup(rawName) if nm and #nm >= #typed then @@ -2532,8 +3121,32 @@ function UI:CreateSearchFrame() end return nil end, + backspaceAutocompleteTarget = function(_, typed) + if not UI._quickFilterSuggestionsActive or not typed then return nil end + if not typed:match("^%s*@[%w_%-:]*$") then return nil end + local text = typed:sub(1, -2) + return text, #text + end, + onBackspaceAutocompleteRestored = function(box, text) + if box and box.placeholder then + box.placeholder:SetShown((box:GetText() or "") == "") + end + if box and box.ResetPendingSearch then + box:ResetPendingSearch() + end + if box and UI:UpdateQuickFilterSuggestions(box) then + return + end + UI:OnSearchTextChanged(text or "", true) + end, onAccepted = function(text) - if text and text ~= "" then UI:OnSearchTextChanged(text, true) end + if text and text ~= "" then + local box = searchFrame and searchFrame.editBox + if box and text:match("^%s*@[%w_%-:]+$") and UI:UpdateQuickFilterSuggestions(box) then + return + end + UI:OnSearchTextChanged(text, true) + end end, }) @@ -2660,6 +3273,8 @@ function UI:CreateSearchFrame() editBox:ClearAllPoints() editBox:SetPoint("LEFT", iconHolder, "RIGHT", 0, 0) editBox:SetPoint("RIGHT", filterBtn, "LEFT", -4, 0) + self:CreateQuickFilterPill(searchFrame, editBox, iconHolder, filterBtn) + self:UpdateQuickFilterPill() -- Click anywhere on the search frame to focus the editbox (enables blinking cursor). -- Use HookScript to preserve SmartShow OnLeave handlers; @@ -2699,6 +3314,14 @@ function UI:CreateSearchFrame() -- can't dismiss with the same key they used to open. editBox:SetScript("OnKeyDown", function(self, key) UI:HandleCalculatorPasteIntoSearch(self, key) + if UI:HandleCalculatorOpenShortcut(self, key) then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + end + if UI:HandleQuickFilterKeyDown(self, key) then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + end local shortcutIndex = GetResultShortcutIndex(key) if shortcutIndex then @@ -2918,6 +3541,7 @@ function UI:CreateSearchFrame() if key == "LSHIFT" or key == "RSHIFT" or key == "LCTRL" or key == "RCTRL" or key == "LALT" or key == "RALT" then return end + if UI:HandleCalculatorOpenShortcut(searchFrame and searchFrame.editBox, key) then return end if UI:HandleCalculatorCopyConfirmKey(key) then return end if UI:HandleCalculatorCopyKey(key) then return end @@ -3020,6 +3644,9 @@ function UI:CreateSearchFrame() UI:JumpToEnd() elseif key == "TAB" then -- Ring order: editBox → [clearBtn] → filterBtn → wrap back to editBox + if UI._quickFilterSuggestionsActive and UI:AcceptQuickFilterSuggestion() then + return + end CycleFocus(IsShiftKeyDown()) elseif key == "ENTER" then if toolbarFocus > 0 then @@ -3065,6 +3692,11 @@ function UI:CreateSearchFrame() end navFrame:SetScript("OnKeyDown", function(self, key) + if UI:HandleCalculatorOpenShortcut(searchFrame and searchFrame.editBox, key) then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + end + if UI:IsCalculatorCopyConfirmKey(key) then UI:RearmActiveCalculatorCopy("confirm") if C_Timer and C_Timer.After then @@ -3326,6 +3958,7 @@ function UI:CreateSearchFrame() end) searchFrame:HookScript("OnHide", function(self) self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + UI:HideQuickFilterSuggestions() end) searchFrame:HookScript("OnEvent", function(self, event) if event ~= "GLOBAL_MOUSE_DOWN" then return end @@ -3530,6 +4163,559 @@ local function GetUIBucket(d) return UI_BUCKET_BY_CATEGORY[d.category] end +UI.quickFilterOptions = { + { key = "abilities", canonical = "abilities", label = "Abilities", categories = { "Ability" }, aliases = { "ab", "ability", "abilities", "spell", "spells" } }, + { key = "achievements", canonical = "achievements", label = "Achievements", categories = { "Achievement", "Achievements", "Achievement Category", "Guild Achievements" }, aliases = { "a", "ach", "achievement", "achievements" } }, + { key = "statistics", canonical = "statistics", label = "Statistics", categories = { "Statistic", "Statistics" }, aliases = { "s", "stat", "stats", "statistic", "statistics" } }, + { key = "bags", canonical = "bags", label = "Bags", categories = { "Bag" }, aliases = { "b", "bag", "bags" } }, + { key = "bosses", canonical = "bosses", label = "Bosses", categories = { "Boss" }, aliases = { "bo", "boss", "bosses", "encounter", "encounters" } }, + { key = "macros", canonical = "macros", label = "Macros", categories = { "Macro" }, aliases = { "ma", "macro", "macros" } }, + { key = "collections", canonical = "collections", label = "Collections", categories = { "Mount", "Toy", "Pet", "Outfit", "Heirloom", "Appearance Set" }, aliases = { "co", "col", "collection", "collections" } }, + { key = "appearanceSets", canonical = "appearance-sets", label = "Appearance Sets", categories = { "Appearance Set" }, aliases = { "as", "appearance", "appearances", "appearance-set", "appearance-sets", "appset", "appsets", "transmog" } }, + { key = "heirlooms", canonical = "heirlooms", label = "Heirlooms", categories = { "Heirloom" }, aliases = { "h", "heirloom", "heirlooms" } }, + { key = "mounts", canonical = "mounts", label = "Mounts", categories = { "Mount" }, aliases = { "m", "mount", "mounts" } }, + { key = "outfits", canonical = "outfits", label = "Outfits", categories = { "Outfit" }, aliases = { "of", "outfit", "outfits" } }, + { key = "pets", canonical = "pets", label = "Pets", categories = { "Pet" }, aliases = { "p", "pet", "pets" } }, + { key = "toys", canonical = "toys", label = "Toys", categories = { "Toy" }, aliases = { "to", "toy", "toys" } }, + { key = "gearSets", canonical = "gear-sets", label = "Gear Sets", categories = { "Gear Set" }, aliases = { "gs", "gearset", "gearsets", "gear-set", "gear-sets", "equipment-set", "equipment-sets" } }, + { key = "currencies", canonical = "currencies", label = "Currencies", categories = { "Currency" }, aliases = { "c", "cur", "currency", "currencies" } }, + { key = "loot", canonical = "gear", label = "Gear", categories = { "Loot" }, aliases = { "g", "gear", "loot", "item", "items" } }, + { key = "map", canonical = "map", label = "Map Search", aliases = { "map", "maps", "zone", "zones", "location", "locations" } }, + { key = "options", canonical = "options", label = "Options", categories = { "Game Settings", "AddOn Settings" }, aliases = { "op", "opt", "option", "options", "setting", "settings" } }, + { key = "gameOptions", canonical = "game-options", label = "Game Options", categories = { "Game Settings" }, aliases = { "go", "game", "game-option", "game-options", "game-setting", "game-settings" } }, + { key = "addonOptions", canonical = "addon-options", label = "AddOn Options", categories = { "AddOn Settings" }, aliases = { "ao", "addon", "addons", "addon-option", "addon-options", "addon-setting", "addon-settings" } }, + { key = "reputations", canonical = "reputations", label = "Reputations", categories = { "Reputation" }, aliases = { "r", "rep", "reps", "reputation", "reputations" } }, + { key = "talents", canonical = "talents", label = "Talents", categories = { "Talent" }, aliases = { "ta", "tal", "talent", "talents" } }, + { key = "titles", canonical = "titles", label = "Titles", categories = { "Title" }, aliases = { "ti", "title", "titles" } }, +} + +UI.quickFilterByAlias = {} + +for i = 1, #UI.quickFilterOptions do + local def = UI.quickFilterOptions[i] + def.order = i + def.token = "@" .. def.canonical + def.categoryLookup = {} + if def.categories then + for ci = 1, #def.categories do + def.categoryLookup[def.categories[ci]] = true + end + end + def.bucketLookup = { [def.key] = true } + if def.key == "collections" then + def.bucketLookup.mounts = true + def.bucketLookup.toys = true + def.bucketLookup.pets = true + def.bucketLookup.outfits = true + def.bucketLookup.heirlooms = true + def.bucketLookup.appearanceSets = true + elseif def.key == "options" then + def.bucketLookup.gameOptions = true + def.bucketLookup.addonOptions = true + end + UI.quickFilterByAlias[slower(def.canonical)] = def + if def.aliases then + for ai = 1, #def.aliases do + UI.quickFilterByAlias[slower(def.aliases[ai])] = def + end + end +end + +UI.quickFilterSuggestionBuf = {} +UI.quickFilterSuggestionEntries = {} +UI.quickFilterSuggestionData = {} + +function UI:QuickFilterMatchRank(def, token) + token = slower(token or "") + if token == "" then + if def.nestedParent then return nil end + return 100 + (def.order or 0) + end + if slower(def.canonical) == token then return 0 end + if def.aliases then + for i = 1, #def.aliases do + if slower(def.aliases[i]) == token then return 1 end + end + end + if sfind(slower(def.canonical), token, 1, true) == 1 then return 10 + (def.order or 0) end + if def.aliases then + for i = 1, #def.aliases do + if sfind(slower(def.aliases[i]), token, 1, true) == 1 then return 20 + (def.order or 0) end + end + end + return nil +end + +function UI:ResolveQuickFilterToken(token) + if not token or token == "" then return nil end + token = slower(token:gsub("^@", ""):gsub("_", "-")) + local direct = UI.quickFilterByAlias[token] + if direct then return direct end + + local found, matches = nil, 0 + for i = 1, #UI.quickFilterOptions do + local def = UI.quickFilterOptions[i] + local matched = sfind(slower(def.canonical), token, 1, true) == 1 + if not matched and def.aliases then + for ai = 1, #def.aliases do + if sfind(slower(def.aliases[ai]), token, 1, true) == 1 then + matched = true + break + end + end + end + if matched then + found = def + matches = matches + 1 + if matches > 1 then return nil end + end + end + return found +end + +function UI.QuickFilterSuggestionLess(a, b) + if a.rank == b.rank then + return (a.def.order or 0) < (b.def.order or 0) + end + return a.rank < b.rank +end + +function UI:GetQuickFilter() + return self._quickFilter +end + +function UI:GetQuickFilterCompletionToken(def, typed) + if not def then return nil end + typed = slower(typed or "") + local tokens = def.completionTokens + if tokens then + for i = 1, #tokens do + local token = tokens[i] + if token and #token >= #typed + and slower(token:sub(1, #typed)) == typed + and slower(token) ~= typed then + return token + end + end + end + local token = def.token + if token and #token >= #typed + and slower(token:sub(1, #typed)) == typed + and slower(token) ~= typed then + return token + end + return nil +end + +function UI:GetQuickFilterDisplayToken(def) + if not def then return "" end + if def.displayToken then return def.displayToken end + + local alias = def.canonical + local aliasLen = #(alias or "") + if def.aliases then + for i = 1, #def.aliases do + local candidate = def.aliases[i] + if candidate and #candidate < aliasLen then + alias = candidate + aliasLen = #candidate + end + end + end + def.displayToken = "@" .. (alias or def.canonical) + return def.displayToken +end + +function UI:GetQuickFilterAliasText(def) + if not def then return "Quick Filter" end + if def.aliasText then return def.aliasText end + def.aliasText = (def.label or "Quick Filter") + return def.aliasText +end + +function UI:QuickFilterAllowsData(data, quickFilter) + local def = quickFilter or self._quickFilter + if not def then return true end + if not data or data.calculatorResult then return false end + + if data.mapSearchResult then return def.key == "map" end + if def.key == "map" then return false end + + if def.key == "mounts" then return data.mountID and true or false end + if def.key == "toys" then return data.toyItemID and true or false end + if def.key == "pets" then return data.petID and true or false end + if def.key == "outfits" then return data.outfitID and true or false end + if def.key == "heirlooms" then return data.heirloomItemID and true or false end + if def.key == "appearanceSets" then return data.transmogSetID and true or false end + if def.key == "collections" then + return (data.mountID or data.toyItemID or data.petID or data.outfitID + or data.heirloomItemID or data.transmogSetID) and true or false + end + if def.key == "loot" then + return (data.itemID and data.category == "Loot") and true or false + end + + if data.category and def.categoryLookup and def.categoryLookup[data.category] then + return true + end + local bucket = GetUIBucket(data) + return bucket and def.bucketLookup and def.bucketLookup[bucket] or false +end + +function UI:SetQuickFilterPillFill(frame, r, g, b, a) + if not (frame and frame.combinedBorder and frame.combinedBorder.fill) then return end + for _, tex in pairs(frame.combinedBorder.fill) do + tex:SetVertexColor(r, g, b, 1) + tex:SetAlpha(a or 1) + if tex.SetSnapToPixelGrid then tex:SetSnapToPixelGrid(false) end + if tex.SetTexelSnappingBias then tex:SetTexelSnappingBias(0) end + end +end + +function UI:HideQuickFilterPillBorder(frame) + if not (frame and frame.combinedBorder and frame.combinedBorder.border) then return end + for _, tex in pairs(frame.combinedBorder.border) do tex:Hide() end +end + +function UI:CreateQuickFilterPill(frame, editBox, iconHolder, filterBtn) + if not frame or frame.quickFilterPill then return end + local pill = CreateFrame("Button", "EasyFindQuickFilterPill", frame) + pill:SetFrameLevel(frame:GetFrameLevel() + 35) + pill:SetHeight(mmax(18, (editBox and editBox:GetHeight() or 20) - 3)) + pill:SetPoint("LEFT", iconHolder, "RIGHT", 2, 0) + pill:EnableMouse(true) + ns.CreateRoundedRectBorder(pill) + ns.SetRoundedRectBarHeight(pill, 10) + ns.SetRoundedRectBorderBgAlpha(pill, 1) + self:HideQuickFilterPillBorder(pill) + self:SetQuickFilterPillFill(pill, 0.095, 0.095, 0.108, 1) + + local text = pill:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + text:SetPoint("LEFT", 8, 0) + text:SetPoint("RIGHT", -8, 0) + text:SetJustifyH("CENTER") + text:SetWordWrap(false) + text:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1) + pill.text = text + + pill:SetScript("OnClick", function() + UI:ClearQuickFilter(true) + if editBox then + editBox.blockFocus = nil + editBox:SetFocus() + end + end) + pill:SetScript("OnEnter", function(self) + UI:SetQuickFilterPillFill(self, 0.155, 0.155, 0.172, 1) + GameTooltip:SetOwner(self, "ANCHOR_BOTTOM") + GameTooltip:SetText("Quick Filter") + GameTooltip:AddLine("Click or press Backspace on an empty search to clear.", 1, 1, 1, true) + GameTooltip:Show() + end) + pill:SetScript("OnLeave", function(self) + UI:SetQuickFilterPillFill(self, 0.095, 0.095, 0.108, 1) + GameTooltip_Hide() + end) + pill:Hide() + + frame.quickFilterPill = pill + frame.quickFilterEditBox = editBox + frame.quickFilterIconHolder = iconHolder + frame.quickFilterFilterBtn = filterBtn +end + +function UI:UpdateQuickFilterPill() + local frame = searchFrame + if not frame or not frame.quickFilterEditBox then return end + local editBox = frame.quickFilterEditBox + local iconHolder = frame.quickFilterIconHolder + local filterBtn = frame.quickFilterFilterBtn + local pill = frame.quickFilterPill + local active = self._quickFilter + + editBox:ClearAllPoints() + if active and pill then + pill.text:SetText(active.token) + local w = (pill.text:GetStringWidth() or 0) + 18 + pill:SetWidth(mmax(52, w)) + pill:Show() + editBox:SetPoint("LEFT", pill, "RIGHT", 5, 0) + else + if pill then pill:Hide() end + editBox:SetPoint("LEFT", iconHolder, "RIGHT", 0, 0) + end + if filterBtn then + editBox:SetPoint("RIGHT", filterBtn, "LEFT", -4, 0) + else + editBox:SetPoint("RIGHT", frame, "RIGHT", -8, 0) + end +end + +function UI:HideQuickFilterSuggestions() + self._quickFilterSuggestionsActive = nil + self._quickFilterFirstSuggestion = nil +end + +function UI:GetQuickFilterSuggestionEntries(token) + local suggestions = self.quickFilterSuggestionBuf + wipe(suggestions) + local n = 0 + token = (token or ""):gsub("_", "-") + for i = 1, #self.quickFilterOptions do + local def = self.quickFilterOptions[i] + local rank = self:QuickFilterMatchRank(def, token) + if rank then + n = n + 1 + local item = suggestions[n] or {} + item.def = def + item.rank = rank + suggestions[n] = item + end + end + for i = n + 1, #suggestions do suggestions[i] = nil end + if n == 0 then return nil end + tsort(suggestions, UI.QuickFilterSuggestionLess) + + local entries = self.quickFilterSuggestionEntries + local dataPool = self.quickFilterSuggestionData + for i = 1, n do + local def = suggestions[i].def + local data = dataPool[i] + if not data then + data = {} + dataPool[i] = data + end + wipe(data) + data.name = self:GetQuickFilterDisplayToken(def) + data.nameLower = slower(data.name) + data.category = "Quick Filter" + data.noPin = true + data.quickFilterDef = def + data.quickFilterAliasText = self:GetQuickFilterAliasText(def) + + local entry = entries[i] + if not entry then + entry = {} + entries[i] = entry + end + entry.name = data.name + entry.depth = 0 + entry.isPathNode = false + entry.isMatch = true + entry.isFlat = true + entry.flatCatKey = nil + entry.isPinned = false + entry.data = data + end + for i = n + 1, #entries do entries[i] = nil end + for i = n + 1, #dataPool do dataPool[i] = nil end + return entries, suggestions[1].def +end + +function UI:UpdateQuickFilterSuggestions(editBox) + if self._quickFilter then + self:HideQuickFilterSuggestions() + return false + end + editBox = editBox or (searchFrame and searchFrame.editBox) + if not editBox then return false end + + local cursor = editBox:GetCursorPosition() or #(editBox:GetText() or "") + local typed = (editBox:GetText() or ""):sub(1, cursor) + local token = typed:match("^%s*@([%w_%-:]*)$") + if not token then + self:HideQuickFilterSuggestions() + return false + end + + local entries, firstDef = self:GetQuickFilterSuggestionEntries(token) + self._quickFilterSuggestionsActive = true + self._quickFilterFirstSuggestion = firstDef + if entries then + self:ShowHierarchicalResults(entries) + else + self:HideResults() + end + return true +end + +function UI:GetSelectedQuickFilterSuggestion() + if selectedIndex > 0 then + local row = resultButtons[selectedIndex] + local def = row and row:IsShown() and row.data and row.data.quickFilterDef + if def then return def end + end + return self._quickFilterFirstSuggestion +end + +function UI:AcceptQuickFilterSuggestion() + if not self._quickFilterSuggestionsActive then return false end + local def = self:GetSelectedQuickFilterSuggestion() + if not def then return false end + return self:ApplyQuickFilter(def, "") +end + +function UI:ApplyQuickFilter(def, remainingText) + if not def then return false end + self._quickFilter = def + self:HideQuickFilterSuggestions() + self:UpdateQuickFilterPill() + + local editBox = searchFrame and searchFrame.editBox + remainingText = remainingText or "" + if editBox then + if editBox and editBox.ResetPendingSearch then editBox:ResetPendingSearch() end + editBox:SetText(remainingText) + editBox:SetCursorPosition(#remainingText) + if editBox.placeholder then editBox.placeholder:SetShown(remainingText == "") end + editBox.blockFocus = nil + editBox:SetFocus() + end + self:OnSearchTextChanged(remainingText, true) + return true +end + +function UI:ClearQuickFilter(refresh) + if not self._quickFilter then return false end + self._quickFilter = nil + self:HideQuickFilterSuggestions() + self:UpdateQuickFilterPill() + if refresh then + local editBox = searchFrame and searchFrame.editBox + self:OnSearchTextChanged(editBox and editBox:GetText() or "", true) + end + return true +end + +function UI:TryAcceptQuickFilterToken(editBox, includeWhitespace) + if self._quickFilter or not editBox then return false end + local text = editBox:GetText() or "" + local hasAutocomplete = editBox.HasAutocomplete and editBox:HasAutocomplete() + local cursor = editBox:GetCursorPosition() or #text + local before = text:sub(1, cursor) + local token, after + if includeWhitespace then + token, after = before:match("^%s*@([%w_%-:]+)%s+(.*)$") + else + token = before:match("^%s*@([%w_%-:]+)$") + end + if not token then return false end + + local def = self:ResolveQuickFilterToken(token) + if not def then return false end + local remaining + if includeWhitespace then + remaining = (after or "") .. text:sub(cursor + 1) + elseif hasAutocomplete then + remaining = "" + else + remaining = text:sub(cursor + 1) + end + remaining = remaining:gsub("^%s+", "") + return self:ApplyQuickFilter(def, remaining) +end + +function UI:HandleQuickFilterTextChanged(editBox) + if self:TryAcceptQuickFilterToken(editBox, true) then return true end + if self:UpdateQuickFilterSuggestions(editBox) then + if editBox and editBox.ResetPendingSearch then editBox:ResetPendingSearch() end + if editBox and editBox.UpdateAutocomplete then + if C_Timer and C_Timer.After then + local box = editBox + C_Timer.After(0, function() + if box and box:IsVisible() and box:HasFocus() + and UI._quickFilterSuggestionsActive + and box.UpdateAutocomplete then + box.UpdateAutocomplete() + end + end) + else + editBox.UpdateAutocomplete() + end + end + return true + end + return false +end + +function UI:HandleQuickFilterSuggestionBackspace(editBox) + if not (self._quickFilterSuggestionsActive and editBox) then return false end + + if editBox.StripAutocomplete then + editBox:StripAutocomplete() + end + + local text = editBox:GetText() or "" + local cursor = editBox:GetCursorPosition() or #text + if cursor <= 0 then return false end + + local before = text:sub(1, cursor) + if not before:match("^%s*@[%w_%-:]*$") then return false end + + local newBefore = before:sub(1, -2) + local newText = newBefore .. text:sub(cursor + 1) + local function applyBackspace() + if not editBox then return end + if editBox.ResetPendingSearch then editBox:ResetPendingSearch() end + editBox:SetText(newText) + editBox:SetCursorPosition(#newBefore) + editBox:HighlightText(0, 0) + if editBox.placeholder then + editBox.placeholder:SetShown(newText == "") + end + + if not UI:UpdateQuickFilterSuggestions(editBox) then + UI:OnSearchTextChanged(newText, true) + end + end + + if Utils and Utils.SafeAfter then + Utils.SafeAfter(0, applyBackspace) + else + applyBackspace() + end + return true +end + +function UI:HandleQuickFilterKeyDown(editBox, key) + if key == "BACKSPACE" and self._quickFilter and editBox + and (editBox:GetText() or "") == "" then + return self:ClearQuickFilter(true) + end + if key == "BACKSPACE" and self:HandleQuickFilterSuggestionBackspace(editBox) then + return true + end + + if self._quickFilterSuggestionsActive and key == "TAB" then + if editBox and editBox.HasAutocomplete and editBox:HasAutocomplete() + and editBox.AcceptAutocomplete + and editBox:AcceptAutocomplete("tab") then + return self:TryAcceptQuickFilterToken(editBox, false) or true + end + return self:AcceptQuickFilterSuggestion() + end + if self._quickFilterSuggestionsActive and key == "ENTER" then + return self:AcceptQuickFilterSuggestion() + end + if key == "SPACE" then + if self:TryAcceptQuickFilterToken(editBox, false) then + self:SuppressQuickFilterLeakedText(" ") + return true + end + if self._quickFilterSuggestionsActive then + if self:AcceptQuickFilterSuggestion() then + self:SuppressQuickFilterLeakedText(" ") + return true + end + end + end + return false +end + +function UI:QuickFilterNeedsHeavyData(def) + return def and (def.key == "loot" or def.key == "bosses") +end + -- Builds the Appearance Sets options popup: a class selector button + -- four checkboxes (Collected, Not Collected, PvE, PvP). Returns the -- popup frame and a sync function that re-reads EasyFind.db state. @@ -5708,6 +6894,7 @@ local function IsShortcutEligibleRow(row) return row and row:IsShown() and row.data and not row.isPinHeader and not row.isSectionHeader and not row.isUnearnedCurrency + and not row.data.calculatorResult end local function ClearResultShortcutBindings() @@ -5782,6 +6969,28 @@ function UI:UpdateVisibleResultShortcuts() end end +function UI:SuppressQuickFilterLeakedText(leakedText) + local editBox = searchFrame and searchFrame.editBox + if not (editBox and C_Timer and C_Timer.After) then return end + leakedText = tostring(leakedText or "") + if leakedText == "" then return end + + local function clearLeakedText() + if not (searchFrame and searchFrame:IsShown() and editBox:IsVisible()) then return end + if not self._quickFilter then return end + if (editBox:GetText() or "") ~= leakedText then return end + if editBox.ResetPendingSearch then editBox:ResetPendingSearch() end + editBox:SetText("") + editBox:SetCursorPosition(0) + editBox.blockFocus = nil + editBox:SetFocus() + self:OnSearchTextChanged("", true) + end + + C_Timer.After(0, clearLeakedText) + C_Timer.After(0.03, clearLeakedText) +end + function UI:ActivateVisibleResultShortcut(shortcutIndex) if not shortcutIndex then return nil end for i = 1, MAX_BUTTON_POOL do @@ -5798,6 +7007,11 @@ function UI:ActivateVisibleResultShortcut(shortcutIndex) selectedIndex = i toggleFocused = false self:UpdateSelectionHighlight(true) + if row.data and row.data.quickFilterDef then + self:ApplyQuickFilter(row.data.quickFilterDef, "") + self:SuppressQuickFilterLeakedText(shortcutIndex) + return "quickFilter" + end self:ActivateResultRow(row, "key") return "handled" end @@ -5855,6 +7069,7 @@ function UI:CreateResultsFrame() _G["EasyFindDiffPopup"], _G["EasyFindSpecPopup"], _G["EasyFindSpecFlyout"], + _G["EasyFindCalculatorFrame"], -- Blizzard's StaticPopup slots: clicks on our unapplied- -- settings popup buttons (Apply / Exit / Cancel) must not -- register as "outside" or they'd trigger an extra @@ -5883,6 +7098,15 @@ function UI:CreateResultsFrame() local scrollFrame = CreateFrame("ScrollFrame", nil, resultsFrame) resultsFrame.scrollFrame = scrollFrame + local quickFilterHelp = resultsFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + quickFilterHelp:SetPoint("TOPLEFT", resultsFrame, "TOPLEFT", 12, -8) + quickFilterHelp:SetPoint("TOPRIGHT", resultsFrame, "TOPRIGHT", -12, -8) + quickFilterHelp:SetJustifyH("LEFT") + quickFilterHelp:SetTextColor(0.78, 0.78, 0.80, 1) + quickFilterHelp:SetText("|cffffd100Quick filters:|r @m, @s, @g. Tab/Space selects a category.") + quickFilterHelp:Hide() + resultsFrame.quickFilterHelp = quickFilterHelp + local scrollChild = CreateFrame("Frame", nil, scrollFrame) scrollFrame:SetScrollChild(scrollChild) resultsFrame.scrollChild = scrollChild @@ -6144,6 +7368,7 @@ local SCRATCH = { aliasSeen = {}, calculatorResults = {}, filteredResults = {}, + quickFilterResults = {}, skipCategories = {}, } @@ -6579,10 +7804,10 @@ function UI:CreateResultButton(index) ns.CreateRoundedRectBorder(calcCard) ns.SetRoundedRectBarHeight(calcCard, 12) for _, t in pairs(calcCard.combinedBorder.fill) do - t:SetVertexColor(0.17, 0.17, 0.18, 0.96) + t:SetVertexColor(0.095, 0.095, 0.108, 1) end for _, t in pairs(calcCard.combinedBorder.border) do - t:SetVertexColor(0.30, 0.30, 0.32, 0.85) + t:SetVertexColor(0.34, 0.34, 0.36, 0.92) end calcCard:Hide() resultRow.calcCard = calcCard @@ -6593,13 +7818,13 @@ function UI:CreateResultButton(index) resultRow.calcDivider = calcDivider local calcDividerTop = calcCard:CreateTexture(nil, "ARTWORK") - calcDividerTop:SetColorTexture(0.30, 0.30, 0.32, 0.75) + calcDividerTop:SetColorTexture(0.34, 0.34, 0.36, 0.86) calcDividerTop:SetWidth(1) calcDividerTop:Hide() resultRow.calcDividerTop = calcDividerTop local calcDividerBottom = calcCard:CreateTexture(nil, "ARTWORK") - calcDividerBottom:SetColorTexture(0.30, 0.30, 0.32, 0.75) + calcDividerBottom:SetColorTexture(0.34, 0.34, 0.36, 0.86) calcDividerBottom:SetWidth(1) calcDividerBottom:Hide() resultRow.calcDividerBottom = calcDividerBottom @@ -6711,6 +7936,70 @@ function UI:CreateResultButton(index) calcResultButton:Hide() resultRow.calcResultButton = calcResultButton + local calcActionBar = CreateFrame("Button", nil, resultRow) + calcActionBar:RegisterForClicks("LeftButtonUp") + calcActionBar:SetFrameLevel(resultRow:GetFrameLevel() + 10) + self:StyleCalculatorButton(calcActionBar, 22) + calcActionBar:SetScript("OnClick", function() + selectedIndex = index + toggleFocused = false + UI:UpdateSelectionHighlight(true) + local data = resultRow.data + UI:OpenCalculator(data and data.calculatorExpression or nil) + end) + calcActionBar:Hide() + resultRow.calcActionBar = calcActionBar + + local calcActionIcon = calcActionBar:CreateTexture(nil, "ARTWORK") + calcActionIcon:SetSize(15, 15) + calcActionIcon:SetPoint("LEFT", calcActionBar, "LEFT", 8, 0) + calcActionIcon:SetTexture(FLAT_CATEGORY_ICONS.calculator.tex) + calcActionIcon:SetTexCoord(0, 1, 0, 1) + resultRow.calcActionIcon = calcActionIcon + + local calcActionTitle = calcActionBar:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + calcActionTitle:SetPoint("LEFT", calcActionIcon, "RIGHT", 7, 0) + calcActionTitle:SetJustifyH("LEFT") + calcActionTitle:SetText("Calculator") + calcActionTitle:SetTextColor(0.96, 0.96, 0.96, 1) + calcActionTitle:SetWordWrap(false) + if calcActionTitle.SetMaxLines then calcActionTitle:SetMaxLines(1) end + resultRow.calcActionTitle = calcActionTitle + + local calcActionDesc = calcActionBar:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + calcActionDesc:SetPoint("LEFT", calcActionTitle, "RIGHT", 5, 0) + calcActionDesc:SetText("") + calcActionDesc:SetTextColor(0.58, 0.58, 0.60, 1) + calcActionDesc:SetWordWrap(false) + if calcActionDesc.SetMaxLines then calcActionDesc:SetMaxLines(1) end + calcActionDesc:Hide() + resultRow.calcActionDesc = calcActionDesc + + local calcActionAfter = calcActionBar:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + calcActionAfter:SetPoint("RIGHT", calcActionBar, "RIGHT", -10, 0) + calcActionAfter:SetText("to open") + calcActionAfter:SetTextColor(0.58, 0.58, 0.60, 1) + calcActionAfter:SetWordWrap(false) + if calcActionAfter.SetMaxLines then calcActionAfter:SetMaxLines(1) end + resultRow.calcActionAfter = calcActionAfter + + local calcActionKeyCap = CreateFrame("Frame", nil, calcActionBar) + calcActionKeyCap:SetSize(38, 15) + calcActionKeyCap:SetPoint("RIGHT", calcActionAfter, "LEFT", -6, 0) + ns.CreateRoundedRectBorder(calcActionKeyCap) + ns.SetRoundedRectBarHeight(calcActionKeyCap, 7) + ns.SetRoundedRectBorderBgAlpha(calcActionKeyCap, 1) + self:SetCalculatorRoundedFill(calcActionKeyCap, 0.13, 0.13, 0.15, 1, 0.30, 0.30, 0.32, 0.95) + resultRow.calcActionKeyCap = calcActionKeyCap + + local calcActionKey = calcActionKeyCap:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + calcActionKey:SetPoint("CENTER") + calcActionKey:SetText("Alt+C") + calcActionKey:SetTextColor(0.86, 0.86, 0.88, 1) + calcActionKey:SetWordWrap(false) + if calcActionKey.SetMaxLines then calcActionKey:SetMaxLines(1) end + resultRow.calcActionKey = calcActionKey + local shortcutGroup = CreateFrame("Frame", nil, resultRow) shortcutGroup:Hide() resultRow.shortcutGroup = shortcutGroup @@ -7587,7 +8876,7 @@ function UI:CreateResultButton(index) end -- Right-click: show pin/unpin popup (plus Guide row if entry has a guide path) if mouseButton == "RightButton" and self.data then - if self.data.calculatorResult then return end + if self.data.calculatorResult or self.data.quickFilterDef then return end local pinData = self.data local isPinned = IsUIItemPinned(pinData) local hasGuide = pinData.steps or pinData.transmogSetID @@ -8183,6 +9472,7 @@ function UI:OnSearchTextChanged(text, force) -- Treat whitespace-only as empty (pins show on focus, not on blank spaces) if text then text = strtrim(text) end ClearResultTooltips() + local quickFilter = self:GetQuickFilter() if not text or text == "" then if ns.Database and ns.Database.CancelDynamicWarmup then ns.Database:CancelDynamicWarmup() @@ -8198,9 +9488,12 @@ function UI:OnSearchTextChanged(text, force) end wipe(collapsedNodes) - local calculatorData = self:EvaluateCalculatorExpression(text) - local needsHeavy = not calculatorData and ns.Database and ns.Database.QueryNeedsHeavySearchData - and ns.Database:QueryNeedsHeavySearchData(text) + local calculatorData = (not quickFilter) and self:EvaluateCalculatorExpression(text) or nil + local needsHeavy = not calculatorData and ( + (ns.Database and ns.Database.QueryNeedsHeavySearchData + and ns.Database:QueryNeedsHeavySearchData(text)) + or self:QuickFilterNeedsHeavyData(quickFilter) + ) if not force and not needsHeavy and ns.Database and ns.Database.CancelDynamicWarmup then ns.Database:CancelDynamicWarmup() end @@ -8210,7 +9503,7 @@ function UI:OnSearchTextChanged(text, force) -- Collection items (mounts/toys/pets/outfits/appearance sets) are -- skipped when their own filter is off OR the parent Collections -- toggle is off. Loot is independent. - local filters = EasyFind.db.uiSearchFilters + local filters = quickFilter and nil or EasyFind.db.uiSearchFilters local collectionsOff = filters and filters.collections == false local optionsOff = filters and filters.options == false local skipCategories @@ -8283,6 +9576,21 @@ function UI:OnSearchTextChanged(text, force) end end + if quickFilter then + wipe(SCRATCH.quickFilterResults) + local filtered = SCRATCH.quickFilterResults + local fi = 0 + for ri = 1, #results do + local r = results[ri] + if r and self:QuickFilterAllowsData(r.data, quickFilter) then + fi = fi + 1 + filtered[fi] = r + end + end + for i = fi + 1, #filtered do filtered[i] = nil end + results = filtered + end + -- Bucket-aware UI filter: drop UI entries whose bucket -- (abilities / achievements / currencies / reputations / bags / -- options) is unchecked. Base UI entries have no bucket and are @@ -8337,7 +9645,9 @@ function UI:OnSearchTextChanged(text, force) -- Map Search: search static locations and dungeon entrances, merge into results local mapResults - if not calculatorData and filters and filters.map ~= false and ns.MapSearch and ns.MapSearch.SearchForUI then + if not calculatorData and ns.MapSearch and ns.MapSearch.SearchForUI + and ((quickFilter and quickFilter.key == "map") + or (not quickFilter and filters and filters.map ~= false)) then mapResults = ns.MapSearch:SearchForUI(text) end @@ -8370,7 +9680,9 @@ function UI:OnSearchTextChanged(text, force) -- ScoreName so they interleave naturally with mount / toy / setting -- hits ranked off the same query, instead of clumping at a fixed -- band. - if not calculatorData and text ~= "" and (not filters or filters.achievements ~= false) then + if not calculatorData and text ~= "" + and ((quickFilter and quickFilter.key == "achievements") + or (not quickFilter and (not filters or filters.achievements ~= false))) then local achHits = self:RequestAchievementSearch(text) if achHits and ns.Database and ns.Database.ScoreName then local lowerQ = slower(text) @@ -8534,6 +9846,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local searchW = searchFrame and searchFrame:GetWidth() or 0 local customResultsW = EasyFind.db.uiResultsWidth or 0 local maxResultsH = EasyFind.db.uiResultsHeight or 280 + local quickFilterHelp = self._quickFilterSuggestionsActive and 1 or 0 local n = #hierarchical local last = self._lastRenderSig local same = last and last.n == n @@ -8544,6 +9857,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) and last.searchW == searchW and last.customResultsW == customResultsW and last.maxResultsH == maxResultsH + and last.quickFilterHelp == quickFilterHelp and resultsFrame:IsShown() if same then for hi = 1, n do @@ -8571,6 +9885,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) last.searchW = searchW last.customResultsW = customResultsW last.maxResultsH = maxResultsH + last.quickFilterHelp = quickFilterHelp for hi = 1, n do local e = hierarchical[hi] local stride = (hi - 1) * 3 @@ -8597,6 +9912,16 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if padT < theme.resultsPadTop then padT = theme.resultsPadTop end local padB = mfloor((theme.resultsPadBot or 0) * fontScale + 0.5) if padB < theme.resultsPadBot then padB = theme.resultsPadBot end + local quickFilterHelpH = 0 + if resultsFrame.quickFilterHelp then + if self._quickFilterSuggestionsActive then + quickFilterHelpH = 22 + resultsFrame.quickFilterHelp:SetShown(true) + else + resultsFrame.quickFilterHelp:Hide() + end + end + padT = padT + quickFilterHelpH -- Scale row icons to match leaf font height so icon top/bottom -- align with text top/bottom instead of overflowing the cap line. @@ -8682,7 +10007,8 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end local count = mmin(visibleN, MAX_BUTTON_POOL) - if pinSlots < visibleN then + local bypassSearchRowCap = self._quickFilterSuggestionsActive + if not bypassSearchRowCap and pinSlots < visibleN then count = mmin(count, pinSlots + MAX_SEARCH_RESULT_ROWS) end @@ -8747,6 +10073,11 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- the name and below the path so neither bleeds into the rep bar. local padL = theme.resultsPadLeft or 10 local entryRowH = entry.isFlat and (rowH + flatExtraH) or rowH + if data and data.calculatorResult and not entry.isPathNode then + local calcRowH = mfloor(86 * fontScale + 0.5) + if calcRowH < 76 then calcRowH = 76 end + if entryRowH < calcRowH then entryRowH = calcRowH end + end local rowContentTop = yOffset resultRow:SetSize(resultsFrame:GetWidth() - padL * 2 - scrollInset, entryRowH) resultRow:ClearAllPoints() @@ -8779,6 +10110,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if resultRow.calcResultHint then resultRow.calcResultHint:Hide() end if resultRow.calcExpressionButton then resultRow.calcExpressionButton:Hide() end if resultRow.calcResultButton then resultRow.calcResultButton:Hide() end + if resultRow.calcActionBar then resultRow.calcActionBar:Hide() end resultRow.data = data -- Reset every icon.* tooltip-identifier the OnEnter handler @@ -9355,10 +10687,16 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if resultRow.flatCatIcon then resultRow.flatCatIcon:Hide() end SetRowIcon(resultRow, "hidden", nil, rowIconSize) - local rightInset = showShortcutHints and 42 or 4 + local actionH = mmax(20, mfloor(22 * fontScale + 0.5)) + resultRow.calcActionBar:ClearAllPoints() + resultRow.calcActionBar:SetPoint("BOTTOMLEFT", resultRow, "BOTTOMLEFT", 4, 3) + resultRow.calcActionBar:SetPoint("BOTTOMRIGHT", resultRow, "BOTTOMRIGHT", -4, 3) + resultRow.calcActionBar:SetHeight(actionH) + resultRow.calcActionBar:Show() + resultRow.calcCard:ClearAllPoints() resultRow.calcCard:SetPoint("TOPLEFT", resultRow, "TOPLEFT", 4, -3) - resultRow.calcCard:SetPoint("BOTTOMRIGHT", resultRow, "BOTTOMRIGHT", -rightInset, 3) + resultRow.calcCard:SetPoint("BOTTOMRIGHT", resultRow.calcActionBar, "TOPRIGHT", 0, 4) resultRow.calcCard:Show() resultRow.calcDivider:ClearAllPoints() @@ -10351,6 +11689,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if resultRow.calcResultHint then resultRow.calcResultHint:Hide() end if resultRow.calcExpressionButton then resultRow.calcExpressionButton:Hide() end if resultRow.calcResultButton then resultRow.calcResultButton:Hide() end + if resultRow.calcActionBar then resultRow.calcActionBar:Hide() end if resultRow.shortcutGroup then resultRow.shortcutGroup:Hide() end for d = 1, MAX_DEPTH do resultRow.treeVert[d]:Hide() @@ -10812,21 +12151,30 @@ function UI:ShowPinnedItems() wipe(collapsedNodes) local entries = pinnedOnlyEntries - for i, pin in ipairs(pins) do - local e = entries[i] - if not e then - e = {} - entries[i] = e - end - e.name = pin.name - e.depth = 0 - e.isPathNode = false - e.isMatch = true - e.isPinned = true - e.isFlat = true - e.data = pin - end - for i = #pins + 1, #entries do + local quickFilter = self:GetQuickFilter() + local n = 0 + for _, pin in ipairs(pins) do + if not quickFilter or self:QuickFilterAllowsData(pin, quickFilter) then + n = n + 1 + local e = entries[n] + if not e then + e = {} + entries[n] = e + end + e.name = pin.name + e.depth = 0 + e.isPathNode = false + e.isMatch = true + e.isPinned = true + e.isFlat = true + e.data = pin + end + end + if n == 0 then + self:HideResults() + return + end + for i = n + 1, #entries do entries[i] = nil end self:ShowHierarchicalResults(entries) @@ -11002,6 +12350,9 @@ function UI:ActivateResultRow(resultRow, source) end if not resultRow.data then return false end + if resultRow.data.quickFilterDef then + return self:ApplyQuickFilter(resultRow.data.quickFilterDef, "") + end if ActivateSettingResult(resultRow.data) then return true end if resultRow.data.calculatorResult then self:ArmCalculatorResultFromRow(resultRow, source or "click") @@ -12096,6 +13447,11 @@ function UI:SelectResult(data, forceGuide) if not data then return end local useFast = not forceGuide + if data.quickFilterDef then + self:ApplyQuickFilter(data.quickFilterDef, "") + return + end + if data.calculatorResult then self:ArmCalculatorResultForData(data) return @@ -13630,6 +14986,15 @@ end function UI:Show(andFocus) if not searchFrame then return end if inCombat then return end + local wasShown = searchFrame:IsShown() + if not wasShown and self._quickFilter then + self:ClearQuickFilter(false) + if searchFrame.editBox then + if searchFrame.editBox.ResetPendingSearch then searchFrame.editBox:ResetPendingSearch() end + searchFrame.editBox:SetText("") + if searchFrame.editBox.placeholder then searchFrame.editBox.placeholder:Show() end + end + end searchFrame:Show() -- Belt-and-suspenders: OnShow hook also calls this, but if searchFrame -- was already shown the hook didn't fire and escCatcher would be left @@ -13718,10 +15083,14 @@ function UI:HandleEscape() -- exact pre-ESC state (text, scroll, pending change). The helper -- also lifts the popup above our results panel strata. if self:ShowUnappliedSettingsPopup() then return end - if editBox and editBox:GetText() ~= "" then - if editBox.ResetPendingSearch then editBox:ResetPendingSearch() end - editBox:SetText("") - if editBox.placeholder then editBox.placeholder:Show() end + if (editBox and editBox:GetText() ~= "") or self._quickFilter then + if editBox and editBox.ResetPendingSearch then editBox:ResetPendingSearch() end + if editBox then + editBox:SetText("") + if editBox.placeholder then editBox.placeholder:Show() end + end + self:ClearQuickFilter(false) + self:HideQuickFilterSuggestions() Refocus() -- Programmatic SetText intentionally does not run the throttled -- search path. Rebuild immediately so stale typed results are @@ -13969,21 +15338,23 @@ end function UI:UpdateOpacity() if not searchFrame then return end - local alpha = EasyFind.db.searchBarOpacity or ns.DEFAULT_OPACITY + local alpha = ns.SEARCH_WINDOW_ALPHA or 0.95 if containerFrame then + self:ApplySearchWindowFill(containerFrame) ns.SetRoundedRectBorderBgAlpha(containerFrame, alpha) end end function UI:UpdateSearchBarTheme() if not searchFrame then return end - local alpha = EasyFind.db.searchBarOpacity or ns.DEFAULT_OPACITY + local alpha = ns.SEARCH_WINDOW_ALPHA or 0.95 searchFrame:SetBackdrop(nil) -- Pill stays hidden; container provides the rounded silhouette. ns.SetSearchBorderShown(searchFrame, false) if containerFrame then ns.SetRoundedRectBorderShown(containerFrame, true) ns.SetRoundedRectBarHeight(containerFrame, self:GetSearchBarHeight()) + self:ApplySearchWindowFill(containerFrame) ns.SetRoundedRectBorderBgAlpha(containerFrame, alpha) end end @@ -14664,11 +16035,12 @@ function UI:UpdateFontSize() local theme = GetActiveTheme() local WHITE8x8 = "Interface\\BUTTONS\\WHITE8x8" - local alpha = EasyFind.db.searchBarOpacity or ns.DEFAULT_OPACITY + local alpha = ns.SEARCH_WINDOW_ALPHA or 0.95 if theme.searchBarRounded then searchFrame:SetBackdrop(nil) if containerFrame then ns.SetRoundedRectBarHeight(containerFrame, barH) + self:ApplySearchWindowFill(containerFrame) ns.SetRoundedRectBorderBgAlpha(containerFrame, alpha) -- If results are open, the divider stays at the bar's -- new bottom (= barH). @@ -14683,7 +16055,8 @@ function UI:UpdateFontSize() edgeSize = 20, insets = { left = 5, right = 5, top = 5, bottom = 5 } }) - searchFrame:SetBackdropColor(0, 0, 0, alpha) + local c = ns.SEARCH_WINDOW_FILL_COLOR or {0.052, 0.052, 0.060} + searchFrame:SetBackdropColor(c[1], c[2], c[3], alpha) end for i = 1, #resultButtons do diff --git a/Utils.lua b/Utils.lua index e5b1169..be2d7ce 100644 --- a/Utils.lua +++ b/Utils.lua @@ -143,11 +143,14 @@ function Utils.AttachAutocomplete(editBox, opts) local findCandidate = opts.findCandidate local onTypedChanged = opts.onTypedChanged local onAccepted = opts.onAccepted + local backspaceAutocompleteTarget = opts.backspaceAutocompleteTarget + local onBackspaceAutocompleteRestored = opts.onBackspaceAutocompleteRestored local typedText = "" local programmatic = false local currentCandidate = nil local smoothExtendDone = false local restoreBackspaceText, restoreBackspaceCursor + local restoreBackspaceNotify = false local mouseAcceptCandidate local mouseAcceptTypedLen local backspaceStripActive = false @@ -241,7 +244,9 @@ function Utils.AttachAutocomplete(editBox, opts) if restoreBackspaceText then local restoreText = restoreBackspaceText local restoreCursor = restoreBackspaceCursor or #restoreText + local notify = restoreBackspaceNotify restoreBackspaceText, restoreBackspaceCursor = nil, nil + restoreBackspaceNotify = false backspaceStripActive = false if current ~= restoreText then programmatic = true @@ -251,6 +256,11 @@ function Utils.AttachAutocomplete(editBox, opts) programmatic = false end typedText = ssub(restoreText, 1, restoreCursor) + currentCandidate = nil + smoothExtendDone = false + if notify and onBackspaceAutocompleteRestored then + onBackspaceAutocompleteRestored(self, typedText) + end return end local cursorPos = self:GetCursorPosition() or #current @@ -364,13 +374,46 @@ function Utils.AttachAutocomplete(editBox, opts) editBox:HookScript("OnKeyDown", function(self, key) if key == "BACKSPACE" and HasAutocomplete() then + local targetText, targetCursor + if backspaceAutocompleteTarget then + targetText, targetCursor = backspaceAutocompleteTarget(self, typedText, currentCandidate) + end + if targetText == nil then + targetText = typedText + targetCursor = #typedText + restoreBackspaceNotify = false + else + targetText = tostring(targetText) + if targetCursor == nil then targetCursor = #targetText end + restoreBackspaceNotify = true + end backspaceStripActive = true - restoreBackspaceText = typedText - restoreBackspaceCursor = #typedText + restoreBackspaceText = targetText + restoreBackspaceCursor = targetCursor StripAutocomplete() if C_Timer then C_Timer.After(0, function() + if restoreBackspaceText and restoreBackspaceNotify then + local restoreText = restoreBackspaceText + local restoreCursor = restoreBackspaceCursor or #restoreText + restoreBackspaceText, restoreBackspaceCursor = nil, nil + restoreBackspaceNotify = false + backspaceStripActive = false + programmatic = true + self:SetText(restoreText) + self:SetCursorPosition(restoreCursor) + self:HighlightText(0, 0) + programmatic = false + typedText = ssub(restoreText, 1, restoreCursor) + currentCandidate = nil + smoothExtendDone = false + if onBackspaceAutocompleteRestored then + onBackspaceAutocompleteRestored(self, typedText) + end + return + end restoreBackspaceText, restoreBackspaceCursor = nil, nil + restoreBackspaceNotify = false backspaceStripActive = false end) end @@ -423,10 +466,11 @@ end ns.GOLD_COLOR = {1.0, 0.82, 0.0} ns.YELLOW_HIGHLIGHT = {1, 1, 0} -ns.DEFAULT_OPACITY = 0.75 +ns.SEARCH_WINDOW_ALPHA = 0.95 ns.TOOLTIP_BORDER = "Interface\\Tooltips\\UI-Tooltip-Border" ns.EYE_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\eye" ns.DARK_PANEL_BG = {0.1, 0.1, 0.1, 0.95} +ns.SEARCH_WINDOW_FILL_COLOR = {0.052, 0.052, 0.060} ns.RESULT_ICON_SIZE = 18 ns.SEARCHBAR_HEIGHT = 30 -- base search bar frame height (before font scaling) ns.SEARCHBAR_FILL = 0.55 -- fraction of bar height filled by text/icon @@ -625,6 +669,13 @@ function ns.SetRoundedRectBorderBgAlpha(frame, alpha) for _, t in pairs(frame.combinedBorder.fill) do t:SetAlpha(alpha) end end +function ns.SetRoundedRectBorderFillColor(frame, r, g, b, a) + if not frame.combinedBorder then return end + for _, t in pairs(frame.combinedBorder.fill) do + t:SetVertexColor(r, g, b, a or 1) + end +end + -- A 1-px horizontal divider that runs across the inside of the -- container at the bottom of the bar's content area. Visible only -- when the results dropdown is open; it doubles as the "bar's diff --git a/Wizard.lua b/Wizard.lua index 46db2a1..238669d 100644 --- a/Wizard.lua +++ b/Wizard.lua @@ -339,7 +339,7 @@ local function BuildPage2(parent) d:Show() end - local function CreateDetailView(headerText) + local function CreateDetailView(headerText, detailText) local d = CreateFrame("Frame", nil, p) d:SetAllPoints(p) d:Hide() @@ -351,7 +351,7 @@ local function BuildPage2(parent) local h = HeaderText(d, headerText, "GameFontNormalLarge") h:SetPoint("TOP", d, "TOP", 0, -28) - local body = BodyText(d, "Details coming soon.") + local body = BodyText(d, detailText or "") body:SetPoint("TOP", h, "BOTTOM", 0, -16) body:SetWidth(WIZ_W - 120) @@ -359,10 +359,15 @@ local function BuildPage2(parent) return d end - local d1 = CreateDetailView("Search") - local d2 = CreateDetailView("Map Search Tab") - local d3 = CreateDetailView("Click & Right-click") - local d4 = CreateDetailView("Pin Anything") + local d1 = CreateDetailView("Search", + "Search panels, settings, achievements, statistics, abilities, talents, titles, reputations, currencies, mounts, toys, pets, outfits, transmogs, heirlooms, gear sets, bag items, bosses, loot, macros, map places, and addon options.\n\n" .. + "Right-click a result for contextual actions like Guide, Pin, Add Alias, favorite toggles, tracking, pet actions, and other row-specific tools. Pins keep important results available before typing; aliases let your own words find the same result.") + local d2 = CreateDetailView("Map Search Tab", + "Use the Map Search tab for location-first browsing: banks, flight masters, dungeons, raids, zones, and useful points of interest.") + local d3 = CreateDetailView("Item/Ability Use", + "Use supported results directly from the list: cast abilities, summon mounts and pets, use toys, swap outfits, view or try on transmogs, add heirlooms to bags, consume usable bag items, equip gear from bags, and open ordinary bag items in their bag slot.") + local d4 = CreateDetailView("Calculator", + "Type math straight into search, including 1+23, 1*7, sin(30), tan(45), or 5!. Select the expression or result and press Ctrl+C, or press Alt+C to open the calculator.") local t1 = FeatureTile(grid, nil, "Interface\\AddOns\\EasyFind\\Textures\\Spyglass", nil, "Search", @@ -379,14 +384,14 @@ local function BuildPage2(parent) -- Anchor lower-row tiles directly to the grid so a tile's hover -- scale doesn't ripple position changes onto its neighbor. local t3 = FeatureTile(grid, "UI-HUD-MicroMenu-SpellbookAbilities-Up", nil, nil, - "Click & Right-click", - "Left-click activates the result (cast, use, equip, navigate). Right-click opens Pin / Alias / Guide menu.", + "Item/Ability Use", + "Cast, use, equip, summon, swap, or open supported results directly from the list.", function() ShowDetail(d3) end) t3:SetPoint("TOPLEFT", grid, "TOPLEFT", 38, -222) - local t4 = FeatureTile(grid, "Waypoint-MapPin-ChatIcon", nil, nil, - "Pin Anything", - "Pin frequently-used results for quick access.", + local t4 = FeatureTile(grid, nil, "Interface\\Icons\\INV_Misc_Note_05", nil, + "Calculator", + "Type expressions, copy the result, or open the full calculator with Alt+C.", function() ShowDetail(d4) end) t4:SetPoint("TOPRIGHT", grid, "TOPRIGHT", -38, -222) @@ -397,12 +402,9 @@ local function BuildPage2(parent) return p end --- One capture-button widget per binding. Rounded fill (same TC9 --- 9-slice the search bar uses, pinned at button height for a true --- pill silhouette) plus a soft center glow stack -- two stacked --- white squares with low alpha and ADD blend, the smaller one --- brighter, fading outward like a worn-down key polished by years --- of presses. Right-click clears the binding, Esc cancels capture. +-- One capture-button widget per binding. Rounded gray fill matching +-- the rest of the modern EasyFind controls. Right-click clears the +-- binding, Esc cancels capture. local kbWidgets = {} local kbWaitingFor @@ -451,40 +453,22 @@ local function CreateKbWidget(parent, action, label) end end - -- Worn-key center glow: two horizontal-gradient halves meeting at - -- the button's centerline. Each half fades from transparent at - -- the outer edge to a soft warm tint at the centerline, so the - -- composite peaks in the middle and dies away symmetrically. - -- ADD blend keeps the rounded silhouette unaffected. Glow height - -- is kept inside the rounded interior so its rectangular top - -- and bottom edges fall within the flat middle of the pill. - local function MakeGlowHalf(anchorEdge, fromAlpha, toAlpha) - local g = btn:CreateTexture(nil, "ARTWORK") - g:SetTexture("Interface\\Buttons\\WHITE8x8") - g:SetBlendMode("ADD") - g:SetSize(80, 18) - g:SetPoint(anchorEdge, btn, "CENTER", 0, 0) - g:SetGradient("HORIZONTAL", - CreateColor(1, 0.96, 0.82, fromAlpha), - CreateColor(1, 0.96, 0.82, toAlpha)) - return g - end - local glowL = MakeGlowHalf("RIGHT", 0, 0.07) - local glowR = MakeGlowHalf("LEFT", 0.07, 0) - local fs = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") fs:SetPoint("CENTER") ApplyInter(fs, "semibold") btn._label = fs + local function tintFill(rr, gg, bb) + if not (btn.combinedBorder and btn.combinedBorder.fill) then return end + for _, t in pairs(btn.combinedBorder.fill) do + t:SetVertexColor(rr, gg, bb, 1) + end + end + local function setHover(hover) - local peak = hover and 0.14 or 0.07 - glowL:SetGradient("HORIZONTAL", - CreateColor(1, 0.96, 0.82, 0), - CreateColor(1, 0.96, 0.82, peak)) - glowR:SetGradient("HORIZONTAL", - CreateColor(1, 0.96, 0.82, peak), - CreateColor(1, 0.96, 0.82, 0)) + tintFill(hover and 0.24 or 0.18, + hover and 0.24 or 0.18, + hover and 0.26 or 0.20) fs:SetTextColor(hover and 1 or TEXT_PRIM[1], hover and 1 or TEXT_PRIM[2], hover and 1 or TEXT_PRIM[3], 1) diff --git a/textures/calculator-icon.tga b/textures/calculator-icon.tga new file mode 100644 index 0000000000000000000000000000000000000000..10ba82ef1f0de0529ef68a22b56c0c2efa44bd08 GIT binary patch literal 16402 zcmeHOy>8S%5VojNQqo;y3yG3PNXcVRQt}p*JOJ@snUY7Kq@+R0*(t81OqmjPkiN*+ zGqXEmv$rR0q-f4F-_OkK-YG?{Xnl9fST|5NP&ZIFAa7uI`1z{ak9S=^ z{tEGTQNR80*zU)#yTkE~Jb}N`eL24Cf=5v^b7#;GPwnmS-aqM*`S;vQJacY-%7q8iUeYuOf&;Sw9v3woE1tBh91f41)|;gUJuYfG9G-F#$BHK%iKF>Q{!(7^kbJi0aJ_jvIGtE7 z=7TzP@EnV|@bK}*)H&w*5^s*fvxKKSFw>~vCC-$e`dahRG$fvtW5vT)<5_CZqEl;ZU36={53NoN_cAYE%50*U;1ATwlXdZ7rVoR*$)7>a*8J;z_<%Jn2Xr z%1b`hyw^kGY5p9?iid{Av(%u+MNP+wCoL<7!y~8lW~o7si<*uVPg+(EheuB9%~FFN z7d0IYPdSNW#gmT2(R?I-DX)1*K3j9R-aHU=R=%!leRjjiiBdy08&JzJ_?uD&#%)L#D=E=O-UJiPnK(OV9WI=^oN zInE}Bhj04x|NZandn;a!v&rG%-A9hza(LAFeH+MeHaR?e)7Q_x9A~3>P|bUGYk9f) zius&l2RVDXi2r*J_i(eu@6Kh`Yo6b=wc$i Date: Tue, 12 May 2026 00:20:22 -0400 Subject: [PATCH 089/103] Refine EasyFind 2.0 search and map UX --- Aliases.lua | 31 +- Core.lua | 47 ++- MapSearch.lua | 38 +-- MapTab.lua | 109 +++++-- Options.lua | 279 +++++++++------- UI.lua | 618 ++++++++++++++++++++++++++---------- Utils.lua | 1 + Wizard.lua | 21 +- textures/CalcCardBorder.tga | Bin 0 -> 65554 bytes textures/CalcCardFill.tga | Bin 0 -> 65554 bytes textures/CalcHalfLeft.tga | Bin 0 -> 65554 bytes textures/CalcHalfRight.tga | Bin 0 -> 65554 bytes 12 files changed, 774 insertions(+), 370 deletions(-) create mode 100644 textures/CalcCardBorder.tga create mode 100644 textures/CalcCardFill.tga create mode 100644 textures/CalcHalfLeft.tga create mode 100644 textures/CalcHalfRight.tga diff --git a/Aliases.lua b/Aliases.lua index 30e4c60..78e7305 100644 --- a/Aliases.lua +++ b/Aliases.lua @@ -12,6 +12,26 @@ local Utils = ns.Utils local sfind, slower, strtrim = Utils.sfind, Utils.slower, strtrim local tinsert = Utils.tinsert +local function IsMapAliasTarget(data) + return data and (data.mapSearchResult or data.isZone or data.isDungeonEntrance + or data.zoneMapID or data.entranceMapID + or (data.category and (data.x or data.entranceX or data.mapID or data.coordMapID))) +end + +local function MapAliasKey(data) + local name = slower(data.name or "") + if name == "" then return nil end + local mapID = data.mapID or data.coordMapID or data.zoneMapID + or data.entranceMapID or data.parentMapID + or (WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID()) + or 0 + local x = data.x or data.entranceX or 0 + local y = data.y or data.entranceY or 0 + return "map:" .. (data.category or "location") .. ":" .. tostring(mapID) + .. ":" .. name .. ":" .. tostring(math.floor(x * 10000 + 0.5)) + .. ":" .. tostring(math.floor(y * 10000 + 0.5)) +end + -- Build a stable, type-prefixed key for a Database entry. Used both -- to record an alias target and to find the matching entry later. -- Returns nil when the entry doesn't expose a stable identifier @@ -32,6 +52,9 @@ function Aliases:GetEntryKey(data) if cid then return "currency:" .. cid end end end + if IsMapAliasTarget(data) then + return MapAliasKey(data) + end -- UI Elements: identify by full path + name. Path may be empty -- for top-level entries, so fall back to the bare name. if data.path and #data.path > 0 then @@ -82,20 +105,24 @@ function Aliases:Add(aliasText, data) -- recover them later. Snapshot the renderable fields so the alias -- hit still works after a /reload. local snapshot - if data.mapSearchResult then + if IsMapAliasTarget(data) then + local currentMapID = WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID() snapshot = { name = data.name, nameLower = data.nameLower, category = data.category, icon = data.icon, mapSearchResult = true, - mapID = data.mapID, + mapID = data.mapID or data.coordMapID or data.entranceMapID or data.zoneMapID or currentMapID, zoneName = data.zoneName, pathPrefix = data.pathPrefix, x = data.x, y = data.y, keywords = data.keywords, isZone = data.isZone, zoneMapID = data.zoneMapID, + zoneParentMapID = data.zoneParentMapID, + parentMapID = data.parentMapID, + coordMapID = data.coordMapID, entranceMapID = data.entranceMapID, entranceX = data.entranceX, entranceY = data.entranceY, diff --git a/Core.lua b/Core.lua index 1f56bee..c669d5e 100644 --- a/Core.lua +++ b/Core.lua @@ -14,7 +14,7 @@ EasyFind._ns = ns -- Expose namespace for dev tools (EasyFindDev) BINDING_NAME_EASYFIND_TOGGLE_FOCUS = "Toggle Search Bar" BINDING_NAME_EASYFIND_CLEAR = "Clear All Highlights" -BINDING_NAME_EASYFIND_MAP_FOCUS = "Open Map Search" +BINDING_NAME_EASYFIND_MAP_FOCUS = "Open Map Search Tab" local eventFrame = CreateFrame("Frame") ns.eventFrame = eventFrame @@ -23,7 +23,7 @@ EasyFind.db = {} -- SavedVariables version. Increment when changing DB schema. -- Each migration runs once: if saved dbVersion < DB_VERSION, run all steps in order. -local DB_VERSION = 12 +local DB_VERSION = 15 -- SavedVariables defaults - new keys are auto-merged for existing users local DB_DEFAULTS = { @@ -52,12 +52,12 @@ local DB_DEFAULTS = { uiResultsHeight = 280, -- Visible height of UI search results panel in pixels showTruncationMessage = true, -- Show "more results available" message when truncated hardResultsCap = false, -- Hard cap on results (no "more results" message) - staticOpacity = true, -- Keep opacity constant while moving (default-on with toggle/autoHide UX) 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) mapPinsCollapsed = false, -- Whether the map search "Pinned" header is collapsed - showLoginMessage = true, -- Show "EasyFind loaded!" message on login + showLoginMessage = false, -- Show "EasyFind loaded!" message on login + showAliasMessages = true, -- Show a short chat note when adding a search alias blinkingPins = false, -- Pulse map pins and highlights in sync with indicator bob mapPinHighlight = true, -- Show yellow highlight box around map pins autoPinClear = true, -- Auto-clear map pin when player arrives @@ -81,7 +81,7 @@ local DB_DEFAULTS = { mapTabFilters = { zones = true, instances = true, - flightpath = true, + flightpath = false, travel = true, -- Portals, ships, zeppelins, trams (separate from flight paths) services = true, rares = true, @@ -93,7 +93,7 @@ local DB_DEFAULTS = { alwaysShowRares = false, -- Persistent rare tracking: show active rares on map without searching uiSearchFilters = { -- UI search category filters (all enabled by default) achievements = true, - statistics = true, + statistics = false, currencies = true, reputations = true, collections = true, @@ -191,11 +191,12 @@ local DB_MIGRATIONS = { if db.localMapDirectOpen == false then db.localMapDirectOpen = true end if db.globalMapDirectOpen == false then db.globalMapDirectOpen = true end end, - -- [6] = Populate the split flightpath filter key. The current - -- default is enabled so the filter menu starts fully checked. + -- [6] = Populate the split flightpath filter key. Flight paths default + -- off in the Map tab because they are noisy compared to deliberate + -- travel/location searches. [6] = function(db) if db.mapTabFilters and db.mapTabFilters.flightpath == nil then - db.mapTabFilters.flightpath = true + db.mapTabFilters.flightpath = false end end, -- [8] = Theme rename: "Classic" and "Retail" renamed to @@ -228,6 +229,28 @@ local DB_MIGRATIONS = { [12] = function(db) db.searchBarOpacity = nil end, + -- [13] = Final 2.0 onboarding gate. Force the revamped tutorial + -- once for existing account-wide SavedVariables, and mark 2.0.0 + -- as seen so the legacy What's New popup cannot compete with it. + [13] = function(db) + db.tutorialDone = false + db.lastSeenVersion = "2.0.0" + end, + -- [14] = Finalize search opacity and map-tab defaults for 2.0. + -- Search opacity is no longer user-controlled, and flight paths start + -- hidden in the Map tab unless the user turns them back on. + [14] = function(db) + db.staticOpacity = nil + db.searchBarOpacity = nil + db.suggestedKeybindsApplied = nil + if db.mapTabFilters then + db.mapTabFilters.flightpath = false + end + end, + -- [15] = Make the login chat message opt-in for 2.0. + [15] = function(db) + db.showLoginMessage = false + end, } -- Fields that are runtime-only and must not persist in SavedVariables @@ -302,6 +325,7 @@ local function OnInitialize() if not EasyFindDB then EasyFindDB = { firstInstall = true } end + local savedVersion = EasyFindDB.dbVersion or 0 for k, v in pairs(DB_DEFAULTS) do if EasyFindDB[k] == nil then EasyFindDB[k] = v @@ -315,7 +339,6 @@ local function OnInitialize() end -- Run sequential migrations - local savedVersion = EasyFindDB.dbVersion or 0 for v = savedVersion + 1, DB_VERSION do if DB_MIGRATIONS[v] then DB_MIGRATIONS[v](EasyFindDB) @@ -442,12 +465,10 @@ local function OnInitialize() EasyFind:Print(" /ef feature: request a feature") elseif msg == "" then EasyFind:OpenOptions() - else - print("|cFFFFFF00Type '/ef help' for a list of commands.|r") end end - if EasyFind.db.showLoginMessage ~= false then + if EasyFind.db.showLoginMessage == true then EasyFind:Print("EasyFind loaded. Use /ef o to open options. (Disable this message in General settings.)") end end diff --git a/MapSearch.lua b/MapSearch.lua index c44a994..bf5cd44 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -3616,6 +3616,8 @@ function MapSearch:GetStaticLocations(mapID) category = loc.category, icon = loc.icon, -- nil is fine, GetCategoryIcon will handle it isStatic = true, + mapID = mapID, + coordMapID = mapID, x = loc.x, y = loc.y, keywords = loc.keywords, @@ -3640,6 +3642,8 @@ function MapSearch:GetStaticLocations(mapID) category = poi.category or "unknown", icon = nil, -- Let category icon be used isStatic = true, + mapID = mapID, + coordMapID = mapID, x = poi.x, y = poi.y, keywords = {}, @@ -3657,12 +3661,10 @@ end function MapSearch:ScanVignettes(mapID) local rares = {} - if not GetVignettes then return rares end + if not GetVignettes or not GetVignettePosition then return rares end mapID = mapID or WorldMapFrame:GetMapID() if not mapID then return rares end - local playerMapID = GetBestMapForUnit("player") - local guids = GetVignettes() if not guids then return rares end @@ -3671,11 +3673,10 @@ function MapSearch:ScanVignettes(mapID) if info and info.name and not info.isDead then local atlas = info.atlasName if atlas == "VignetteKill" or atlas == "VignetteKillElite" then - -- Try viewed map first, fall back to player's zone (sub-zone mismatch) + -- Keep the Map tab strict to the viewed map. Falling back + -- to the player's map makes "This Zone" show unrelated rares + -- when the user is browsing another zone. local pos = GetVignettePosition(guid, mapID) - if not pos and playerMapID and playerMapID ~= mapID then - pos = GetVignettePosition(guid, playerMapID) - end if pos then local entry = { name = info.name, @@ -3692,21 +3693,6 @@ function MapSearch:ScanVignettes(mapID) end end - -- Aggregate "Rares" entry always present so the tracking toggle is accessible - local instances = {} - for _, rare in ipairs(rares) do - instances[#instances + 1] = rare - end - local aggregate = { - name = "Rares", - category = "rare", - icon = CATEGORY_ICONS.rare, - keywords = {"rare", "rares"}, - isAggregate = true, - allInstances = instances, - } - rares[#rares + 1] = PreparePOI(aggregate) - return rares end @@ -4345,14 +4331,6 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) -- dungeon / raid / delve entrances as additional candidates so -- a name or abbreviation hit surfaces them regardless of -- current zone. They still get scored alongside local POIs. - local globalInstances = self:GetGlobalInstanceCache() - for _, poi in ipairs(globalInstances) do - local nameLower = GetNameLower(poi) - if not zoneNames[nameLower] and not existingNames[nameLower] then - tinsert(allPOIs, poi) - existingNames[nameLower] = true - end - end end local results = self:SearchPOIs(allPOIs, text) diff --git a/MapTab.lua b/MapTab.lua index a6b12f4..5df719c 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -329,13 +329,18 @@ local function HideOurPanel() RefreshSelectGlows() end -local function ShowPopup(isPinned, onPin, onGuide) - Utils.ShowPinMenu("EasyFindMapTabPopup", isPinned, onPin, onGuide, nil, { +local function PromptAlias(data) + if ns.UI and ns.UI.PromptForAlias then + ns.UI:PromptForAlias(data) + end +end + +local function ShowPopup(isPinned, onPin, onGuide, onAddAlias) + Utils.ShowPinMenu("EasyFindPinPopup", isPinned, onPin, onGuide, onAddAlias, { + strata = "TOOLTIP", + level = 100, width = 96, rowHeight = 22, - offsetX = -8, - offsetY = -8, - clickGrace = 0.15, }) end @@ -350,7 +355,7 @@ local function CreateResultRow(parent) -- focused) absorbs the mouseUp before OnClick fires, requiring a -- second click to actually activate the row. Down registration runs -- the action immediately on press so focus changes can't interfere. - row:RegisterForClicks("LeftButtonDown", "RightButtonDown") + row:RegisterForClicks("LeftButtonDown", "RightButtonUp") row:EnableMouse(true) row:HookScript("OnMouseDown", function() if panel and panel.searchBox and panel.searchBox.HasFocus and panel.searchBox:HasFocus() then @@ -498,7 +503,7 @@ local function RowOnClick(row, button) ShowPopup(isPinned, function() if isPinned then MapSearch:UnpinMapItem(data) else MapSearch:PinMapItem(data) end RefreshCurrentSearch() - end, function() TriggerResultSelect(data, false) end) + end, function() TriggerResultSelect(data, false) end, function() PromptAlias(data) end) return end @@ -610,7 +615,7 @@ local function CreateGroupHeader(parent) hdr:SetHeight(GROUP_HEADER_H) -- See CreateResultRow: activate on press to bypass focus-transition -- mouseUp absorption. - hdr:RegisterForClicks("LeftButtonDown", "RightButtonDown") + hdr:RegisterForClicks("LeftButtonDown", "RightButtonUp") hdr:HookScript("OnMouseDown", function() if panel and panel.searchBox and panel.searchBox.HasFocus and panel.searchBox:HasFocus() then panel.searchBox:ClearFocus() @@ -1052,7 +1057,7 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece navigateData.collapsed = nil end RefreshCurrentSearch() - end, function() TriggerResultSelect(navigateData, false) end) + end, function() TriggerResultSelect(navigateData, false) end, function() PromptAlias(navigateData) end) end local function renderEntries(entries, sectionKey) @@ -1250,7 +1255,7 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece ShowPopup(true, function() MapSearch:UnpinMapItem(pinRef) RefreshCurrentSearch() - end, function() TriggerResultSelect(pinRef, false) end) + end, function() TriggerResultSelect(pinRef, false) end, function() PromptAlias(pinRef) end) end placeGroupHeader(pinRef.name, "pinned:" .. pinRef.zoneMapID, nil, collapsed, pinRef, true, onToggle, onRightClick) @@ -1289,6 +1294,42 @@ if Enum and Enum.UIMapType then EXCLUDE_FROM_LOCAL_MAPTYPES[Enum.UIMapType.Continent] = true end +local function ResultDedupeKey(r) + if not r then return "" end + local name = r.name or "" + if r.category == "flightmaster" then + return "fm:" .. name + end + if r.isDungeonEntrance then + return "instance:" .. tostring(r.entranceMapID or r.mapID or r.coordMapID or 0) + .. ":" .. name + end + if r.isZone and r.zoneMapID then + return "zone:" .. tostring(r.zoneMapID) + end + local mapID = r.mapID or r.coordMapID or r.entranceMapID + or r.zoneMapID or r.parentMapID or 0 + local x = r.x or r.entranceX or 0 + local y = r.y or r.entranceY or 0 + return tostring(r.category or "location") .. ":" .. tostring(mapID) + .. ":" .. name .. ":" .. tostring(x) .. ":" .. tostring(y) +end + +local function IsMapTabAliasData(data) + return data and (data.mapSearchResult or data.isZone or data.isDungeonEntrance + or data.zoneMapID or data.entranceMapID + or (data.category and (data.x or data.entranceX or data.mapID or data.coordMapID))) +end + +local function ResultIsOnViewedMap(data, viewedMapID) + if not data or not viewedMapID then return false end + return data.mapID == viewedMapID + or data.coordMapID == viewedMapID + or data.entranceMapID == viewedMapID + or data.zoneMapID == viewedMapID + or data.parentMapID == viewedMapID +end + -- Filter by mapTabFilters (category bucket) and strip duplicates that -- already appear in a previous list. Preserves result ordering from -- BuildResults. When isLocal is true, also excludes continent-level and @@ -1312,12 +1353,7 @@ local function FilterAndDedupe(results, seen, isLocal) -- so the same FM has different (x,y) in each set. Key FMs -- by name alone so the local pass claims it first and the -- global pass dedupes cleanly. - local key - if r.category == "flightmaster" then - key = "fm:" .. (r.name or "") - else - key = (r.mapID or 0) .. ":" .. (r.name or "") .. ":" .. (r.x or 0) .. ":" .. (r.y or 0) - end + local key = ResultDedupeKey(r) if not seen[key] then seen[key] = true local bucket = getBucket and getBucket(r) or "other" @@ -1517,6 +1553,27 @@ function MapTab:RunSearch(text) if myGen ~= lastQueryGen then return end local globalFiltered = FilterAndDedupe(globalRaw, seen, false) + if ns.Aliases then + local aliasMatches = ns.Aliases:GetMatches(text:lower()) + if aliasMatches then + local viewedMapID = WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID() + for i = #aliasMatches, 1, -1 do + local data = aliasMatches[i] and aliasMatches[i].data + if IsMapTabAliasData(data) then + local key = ResultDedupeKey(data) + if not seen[key] then + seen[key] = true + if ResultIsOnViewedMap(data, viewedMapID) then + tinsert(localFiltered, 1, data) + else + tinsert(globalFiltered, 1, data) + end + end + end + end + end + end + -- Pull duplicate-named zones out of globalFiltered into version groups. -- Names matching 2+ entries collapse into one header that lists every -- variant (highest-mapID first as a "newest" heuristic). The header @@ -1617,7 +1674,7 @@ end -- --------------------------------------------------------------------------- -- Keyboard navigation helpers. Wiring: the search box captures arrow / --- Ctrl+J/K / Enter / Esc while focused and consumes them, falling back +-- Alt+J/K / Enter / Esc while focused and consumes them, falling back -- to typing for anything else. When the user navigates down from the -- search box, focus drops onto navFrame, which captures j/k/Enter/Esc -- directly so the user can keep navigating without reclaiming keyboard @@ -1748,13 +1805,13 @@ end -- true if the key was consumed. `keepSearchFocus` is true when the call -- site is the editbox; false when it's the navFrame. HandleNavKey = function(key, keepSearchFocus) - local ctrl = IsControlKeyDown() - if key == "DOWN" or (ctrl and key == "J") then + local alt = IsAltKeyDown() + if key == "DOWN" or (alt and key == "J") then if #visibleNavRows == 0 then return false end -- Keying into results always drops editbox focus and hands -- keyboard capture to navFrame, so subsequent keys move the -- selection without retyping in the box. Applies to both - -- arrow and Ctrl+J — they behave identically. + -- arrow and Alt+J behave identically. if keepSearchFocus and panel and panel.searchBox then panel.searchBox:ClearFocus() end @@ -1771,7 +1828,7 @@ HandleNavKey = function(key, keepSearchFocus) MoveNavSelection(1) end return true - elseif key == "UP" or (ctrl and key == "K") then + elseif key == "UP" or (alt and key == "K") then if #visibleNavRows == 0 then return false end -- Up from the first row (or from no selection): exit back to -- the search box so the user can resume typing symmetrically @@ -1946,7 +2003,7 @@ local function CreateSearchBox(parent) end, onAccepted = function(text, source) if text and text ~= "" then MapTab:RunSearch(text) end - if source ~= "right" and source ~= "ctrl-l" and source ~= "click" then + if source ~= "right" and source ~= "alt-l" and source ~= "click" then MapTab:PushRecentSearch(text) end end, @@ -1956,7 +2013,7 @@ local function CreateSearchBox(parent) UpdateClear(self) end) - -- Keyboard nav: consume arrow / Ctrl+J/K / Esc while the editbox + -- Keyboard nav: consume arrow / Alt+J/K / Esc while the editbox -- is focused. WoW editboxes default to propagating every keystroke -- to the binding system, which fires player keybinds while the user -- is typing — so we unconditionally clamp propagation to false at @@ -1968,8 +2025,8 @@ local function CreateSearchBox(parent) self:AcceptAutocomplete("right") Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) return - elseif key == "L" and IsControlKeyDown() then - self:AcceptAutocomplete("ctrl-l") + elseif key == "L" and IsAltKeyDown() then + self:AcceptAutocomplete("alt-l") Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) return end @@ -1977,7 +2034,7 @@ local function CreateSearchBox(parent) -- Keep nav keys from racing a pending search render. if pendingSearchTimer and (key == "DOWN" or key == "UP" - or (IsControlKeyDown() and (key == "J" or key == "K"))) then + or (IsAltKeyDown() and (key == "J" or key == "K"))) then CancelPendingSearch() end HandleNavKey(key, true) diff --git a/Options.lua b/Options.lua index 08617fa..e424af8 100644 --- a/Options.lua +++ b/Options.lua @@ -18,7 +18,7 @@ local blizzardRegistered = false local NIL = {} local DEFAULT_UI_FILTERS = { - ui = true, achievements = true, statistics = true, currencies = true, + ui = true, achievements = true, statistics = false, currencies = true, reputations = true, collections = true, gameOptions = true, addonOptions = true, mounts = true, toys = true, pets = true, outfits = true, heirlooms = true, loot = true, appearanceSets = true, @@ -29,7 +29,7 @@ local DEFAULT_UI_FILTERS = { local DEFAULT_GLOBAL_SEARCH_FILTERS = { zones = true, dungeons = true, raids = true, delves = true } local DEFAULT_LOCAL_SEARCH_FILTERS = { instances = true, travel = true, services = true, rares = true } local DEFAULT_MAP_TAB_FILTERS = { - zones = true, instances = true, flightpath = true, travel = true, + zones = true, instances = true, flightpath = false, travel = true, services = true, rares = true, } @@ -37,7 +37,6 @@ local UI_DEFAULTS = { smartShow = false, autoHide = true, lockPosition = false, - staticOpacity = false, uiResultsAbove = false, showResultShortcutHints = true, fontSize = 0.9, @@ -80,7 +79,8 @@ local GENERAL_DEFAULTS = { font = "Default", indicatorStyle = "EasyFind Arrow", indicatorColor = "Yellow", - showLoginMessage = true, + showLoginMessage = false, + showAliasMessages = true, showMinimapButton = true, minimapButtonAngle = 200, visible = true, @@ -128,6 +128,14 @@ local function ResetOptionsPosition() end end +local function RunSoon(fn) + if C_Timer and C_Timer.After then + C_Timer.After(0, fn) + else + fn() + end +end + local function ClearMapRuntime() if not ns.MapSearch then return end pcall(ns.MapSearch.ClearAll, ns.MapSearch) @@ -177,9 +185,9 @@ local function SyncOptionControls() local lbl = optionsFrame.smartShowCheckbox:GetFontString() if lbl then lbl:SetTextColor(1, 1, 1) end end - if optionsFrame.staticOpacityCheckbox then optionsFrame.staticOpacityCheckbox:SetChecked(EasyFind.db.staticOpacity or false) end if optionsFrame.lockPositionCheckbox then optionsFrame.lockPositionCheckbox:SetChecked(EasyFind.db.lockPosition or false) end - if optionsFrame.loginMessageCheckbox then optionsFrame.loginMessageCheckbox:SetChecked(EasyFind.db.showLoginMessage ~= false) end + if optionsFrame.loginMessageCheckbox then optionsFrame.loginMessageCheckbox:SetChecked(EasyFind.db.showLoginMessage == true) end + if optionsFrame.aliasMessageCheckbox then optionsFrame.aliasMessageCheckbox:SetChecked(EasyFind.db.showAliasMessages ~= false) end if optionsFrame.uiResultsAboveCheckbox then optionsFrame.uiResultsAboveCheckbox:SetChecked(EasyFind.db.uiResultsAbove or false) end if optionsFrame.resultShortcutHintsCheckbox then optionsFrame.resultShortcutHintsCheckbox:SetChecked(EasyFind.db.showResultShortcutHints ~= false) end if optionsFrame.minimapBtnCheckbox then optionsFrame.minimapBtnCheckbox:SetChecked(EasyFind.db.showMinimapButton ~= false) end @@ -432,11 +440,12 @@ local function CreateCheckbox(parent, name, label, tooltipText, compact, width) end local DISABLED_TEXT = { 0.5, 0.5, 0.5 } -local NORMAL_TEXT = ns.GOLD_COLOR +local NORMAL_TEXT = {1.0, 1.0, 1.0} local OPTIONS_PANEL_SCALE = 0.88 local TEXT_PRIMARY = { 1.00, 0.97, 0.86 } local TEXT_BODY = { 0.78, 0.78, 0.80 } local TEXT_DIM = { 0.55, 0.55, 0.58 } +local SECTION_TITLE_TEXT = ns.GOLD_COLOR or { 1.0, 0.82, 0.0 } local NAV_SELECTED = { 0.15, 0.15, 0.17, 0.95 } local NAV_HOVER = { 0.11, 0.11, 0.13, 0.85 } local NAV_CLEAR = { 0, 0, 0, 0 } @@ -665,6 +674,7 @@ local function CreatePresetRow(parent, labelText, choices, getter, setter, toolt local controlW = (#choices == 3) and 174 or 184 label:SetPoint("RIGHT", row, "RIGHT", -controlW - 18, 0) label:SetJustifyH("LEFT") + label:SetTextColor(NORMAL_TEXT[1], NORMAL_TEXT[2], NORMAL_TEXT[3], 1) label:SetText(labelText) row.label = label @@ -680,8 +690,9 @@ local function CreatePresetRow(parent, labelText, choices, getter, setter, toolt right = right - btnW - 2 btn:SetScript("OnClick", function(self) if not row.enabled then return end - setter(self.choice.value) - row:SetValue(self.choice.value) + local value = self.choice.value + row:SetValue(value) + RunSoon(function() setter(value) end) end) btn:SetScript("OnEnter", function(self) PaintPresetButton(self, row.activeValue == self.choice.value, true, row.enabled) @@ -709,6 +720,10 @@ local function CreatePresetRow(parent, labelText, choices, getter, setter, toolt row.SetGroupEnabled = function(self, enabled) self.enabled = enabled self:SetAlpha(enabled and 1.0 or 0.35) + if self.label then + local c = enabled and NORMAL_TEXT or DISABLED_TEXT + self.label:SetTextColor(c[1], c[2], c[3], 1) + end for _, btn in ipairs(self.buttons) do if enabled then btn:Enable() else btn:Disable() end PaintPresetButton(btn, self.activeValue == btn.choice.value, false, enabled) @@ -728,6 +743,7 @@ local function CreateStepperRow(parent, labelText, minVal, maxVal, getter, sette label:SetPoint("LEFT", row, "LEFT", 8, 0) label:SetPoint("RIGHT", row, "RIGHT", -112, 0) label:SetJustifyH("LEFT") + label:SetTextColor(NORMAL_TEXT[1], NORMAL_TEXT[2], NORMAL_TEXT[3], 1) label:SetText(labelText) local plusBtn = CreateModernButton(row, "+", 24, 20) @@ -767,6 +783,8 @@ local function CreateStepperRow(parent, labelText, minVal, maxVal, getter, sette row.SetGroupEnabled = function(self, enabled) self.enabled = enabled self:SetAlpha(enabled and 1.0 or 0.35) + local c = enabled and NORMAL_TEXT or DISABLED_TEXT + label:SetTextColor(c[1], c[2], c[3], 1) if enabled then minusBtn:Enable() plusBtn:Enable() @@ -1047,7 +1065,7 @@ function Options:Initialize() homeVersion:SetText("|cFF888888v" .. (tocVersion or "") .. "|r") local homeDesc = homeTab:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") - homeDesc:SetPoint("TOPLEFT", homeIcon, "BOTTOMLEFT", 0, -4) + homeDesc:SetPoint("TOPLEFT", homeIcon, "BOTTOMLEFT", 0, -14) homeDesc:SetPoint("RIGHT", homeTab, "RIGHT", -12, 0) homeDesc:SetJustifyH("LEFT") homeDesc:SetSpacing(3) @@ -1083,14 +1101,14 @@ function Options:Initialize() CreateURLBox(homeTab, "https://www.curseforge.com/wow/addons/easyfind", homeDesc, -6) - local sec3 = CreateTab("General") + local sec3 = CreateTab("General & Binds") -- General tab layout (no inner border, content fills the tab) local loginMessageCheckbox = CreateCheckbox(sec3, "LoginMessage", "Show Login Message", "When enabled, shows a short \"EasyFind loaded!\" message in chat when you log in.\n\nDisable to keep chat cleaner.") loginMessageCheckbox:SetPoint("TOPLEFT", sec3, "TOPLEFT", 8, -8) - loginMessageCheckbox:SetChecked(EasyFind.db.showLoginMessage ~= false) + loginMessageCheckbox:SetChecked(EasyFind.db.showLoginMessage == true) loginMessageCheckbox:SetScript("OnClick", function(self) EasyFind.db.showLoginMessage = self:GetChecked() end) @@ -1098,7 +1116,16 @@ function Options:Initialize() local minimapBtnCheckbox = CreateCheckbox(sec3, "MinimapBtn", "Show Minimap Button", "When enabled, adds a small search icon button to the minimap edge.\n\nLeft-click the button to toggle the search bar.\nRight-click to open options.\nDrag to reposition it around the minimap.") - minimapBtnCheckbox:SetPoint("TOPLEFT", loginMessageCheckbox, "BOTTOMLEFT", 0, -2) + local aliasMessageCheckbox = CreateCheckbox(sec3, "AliasMessages", "Show Alias Messages", + "When enabled, adding an alias prints a short chat note pointing back to the Aliases tab.") + aliasMessageCheckbox:SetPoint("TOPLEFT", loginMessageCheckbox, "BOTTOMLEFT", 0, -2) + aliasMessageCheckbox:SetChecked(EasyFind.db.showAliasMessages ~= false) + aliasMessageCheckbox:SetScript("OnClick", function(self) + EasyFind.db.showAliasMessages = self:GetChecked() + end) + optionsFrame.aliasMessageCheckbox = aliasMessageCheckbox + + minimapBtnCheckbox:SetPoint("TOPLEFT", aliasMessageCheckbox, "BOTTOMLEFT", 0, -2) minimapBtnCheckbox:SetChecked(EasyFind.db.showMinimapButton ~= false) minimapBtnCheckbox:SetScript("OnClick", function(self) EasyFind.db.showMinimapButton = self:GetChecked() @@ -1116,6 +1143,7 @@ function Options:Initialize() label:SetPoint("LEFT", row, "LEFT", 8, 0) label:SetPoint("RIGHT", row, "RIGHT", -SELECTOR_BTN_W - 18, 0) label:SetJustifyH("LEFT") + label:SetTextColor(NORMAL_TEXT[1], NORMAL_TEXT[2], NORMAL_TEXT[3], 1) label:SetText(labelText) return row, label end @@ -1170,7 +1198,7 @@ function Options:Initialize() label:SetPoint("RIGHT", colorBtn, "RIGHT", -6, 0) label:SetJustifyH("CENTER") label:SetText(name) - label:SetTextColor(rgb[1], rgb[2], rgb[3]) + label:SetTextColor(NORMAL_TEXT[1], NORMAL_TEXT[2], NORMAL_TEXT[3], 1) colorBtn:SetScript("OnEnter", function(self) PaintRoundedFill(rowBg, 1, 1, 1, 0.06) @@ -1178,7 +1206,7 @@ function Options:Initialize() end) colorBtn:SetScript("OnLeave", function(self) PaintRoundedFill(rowBg, 1, 1, 1, 0) - label:SetTextColor(rgb[1], rgb[2], rgb[3]) + label:SetTextColor(NORMAL_TEXT[1], NORMAL_TEXT[2], NORMAL_TEXT[3], 1) end) colorBtn:SetScript("OnClick", function() EasyFind.db.indicatorColor = name @@ -1209,10 +1237,77 @@ function Options:Initialize() optionsFrame.fontBtnText = fontBtnText optionsFrame.fontFlyout = fontFlyout + local keybindHeader = sec3:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + keybindHeader:SetPoint("TOPLEFT", fontRow, "BOTTOMLEFT", 8, -18) + keybindHeader:SetText("Keybindings") + keybindHeader:SetTextColor(SECTION_TITLE_TEXT[1], SECTION_TITLE_TEXT[2], SECTION_TITLE_TEXT[3], 1) + + local keybindDefs = { + { label = "Toggle Search Bar", action = "EASYFIND_TOGGLE_FOCUS" }, + { label = "Open Map Search Tab", action = "EASYFIND_MAP_FOCUS" }, + { label = "Clear All", action = "EASYFIND_CLEAR" }, + } + + local keybindTooltips = { + EASYFIND_TOGGLE_FOCUS = { "Toggle Search Bar", "Opens and focuses the UI search bar in one press. Press again to close." }, + EASYFIND_MAP_FOCUS = { "Open Map Search Tab", "Opens the world map, switches to the EasyFind Map Search tab, and focuses its search box." }, + EASYFIND_CLEAR = { "Clear All", "Dismisses all active highlights, map pins, zone highlights, and pending waypoints." }, + } + + local keybindButtons = {} + local KEYBIND_ROW_H = 24 + local KEYBIND_BTN_W = 116 + local KEYBIND_LABEL_W = 168 + local keybindSettings = CreateSettingsGroup(sec3, SELECTOR_ROW_W, KEYBIND_ROW_H * #keybindDefs + 8) + keybindSettings:SetPoint("TOPLEFT", keybindHeader, "BOTTOMLEFT", 0, -3) + optionsFrame.keybindSettings = keybindSettings + + for i, def in ipairs(keybindDefs) do + local row = i - 1 + + local rowFrame = CreateFrame("Frame", nil, keybindSettings) + rowFrame:SetSize(SELECTOR_ROW_W, KEYBIND_ROW_H) + rowFrame:SetPoint("TOPLEFT", keybindSettings, "TOPLEFT", 0, -4 - row * KEYBIND_ROW_H) + + local rowLabel = rowFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + rowLabel:SetPoint("LEFT", rowFrame, "LEFT", 8, 0) + rowLabel:SetText(def.label .. ":") + rowLabel:SetTextColor(NORMAL_TEXT[1], NORMAL_TEXT[2], NORMAL_TEXT[3], 1) + + local keybindBtn = CreateModernButton(rowFrame) + keybindBtn:SetNormalFontObject("GameFontHighlightSmall") + keybindBtn:SetHighlightFontObject("GameFontHighlightSmall") + keybindBtn:SetSize(KEYBIND_BTN_W, 20) + keybindBtn:SetPoint("LEFT", rowLabel, "LEFT", KEYBIND_LABEL_W, 0) + keybindBtn:SetText(GetCurrentKeybindText(def.action)) + keybindBtn:SetScript("OnClick", function(self, button) + if button == "RightButton" then + local old1, old2 = GetBindingKey(def.action) + if old1 then SetBinding(old1) end + if old2 then SetBinding(old2) end + SaveBindings(GetCurrentBindingSet()) + self:SetText("Not Bound") + else + StartCapture(self, def.action) + end + end) + keybindBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + + local tip = keybindTooltips[def.action] + if tip then + MakeKeybindTooltip(keybindBtn, tip[1], tip[2]) + end + + keybindButtons[def.action] = keybindBtn + end + optionsFrame.toggleFocusBtn = keybindButtons["EASYFIND_TOGGLE_FOCUS"] + optionsFrame.mapFocusBtn = keybindButtons["EASYFIND_MAP_FOCUS"] + optionsFrame.clearBtn = keybindButtons["EASYFIND_CLEAR"] + local RESET_BTN_W = 120 -- SECTION 2: UI Search - local sec1 = CreateTab("UI") + local sec1 = CreateTab("Search") local resizeUIBtn = CreateModernButton(sec1) resizeUIBtn:SetSize(160, 22) @@ -1244,13 +1339,15 @@ function Options:Initialize() EasyFind.db.autoHide = not smart autoHideCheckbox:SetChecked(not smart) smartShowCheckbox:SetChecked(smart) - if smart then - if ns.UI and ns.UI.Show then ns.UI:Show(false) end - if ns.UI and ns.UI.UpdateSmartShow then ns.UI:UpdateSmartShow() end - else - if ns.UI and ns.UI.UpdateSmartShow then ns.UI:UpdateSmartShow() end - if ns.UI and ns.UI.Hide then ns.UI:Hide() end - end + RunSoon(function() + if EasyFind.db.smartShow then + if ns.UI and ns.UI.Show then ns.UI:Show(false) end + if ns.UI and ns.UI.UpdateSmartShow then ns.UI:UpdateSmartShow() end + else + if ns.UI and ns.UI.UpdateSmartShow then ns.UI:UpdateSmartShow() end + if ns.UI and ns.UI.Hide then ns.UI:Hide() end + end + end) end autoHideCheckbox:SetScript("OnClick", function() SetVisibilityMode("auto") @@ -1264,21 +1361,9 @@ function Options:Initialize() autoHideCheckbox:SetChecked(not smartInitial) smartShowCheckbox:SetChecked(smartInitial) - local staticOpacityCheckbox = CreateCheckbox(sec1, "StaticOpacity", "Static Opacity", - "When enabled, the UI search bar keeps the same opacity at all times.\n\nWhen disabled (default), opacity is reduced while your character is moving so you can see the game world better, similar to how the World Map behaves.") - staticOpacityCheckbox:SetPoint("TOPLEFT", smartShowCheckbox, "BOTTOMLEFT", 0, -2) - staticOpacityCheckbox:SetChecked(EasyFind.db.staticOpacity or false) - staticOpacityCheckbox:SetScript("OnClick", function(self) - EasyFind.db.staticOpacity = self:GetChecked() - if ns.UI and ns.UI.UpdateOpacity then - ns.UI:UpdateOpacity() - end - end) - optionsFrame.staticOpacityCheckbox = staticOpacityCheckbox - local lockPositionCheckbox = CreateCheckbox(sec1, "LockPosition", "Lock Position", - "When enabled, the search bar can't be dragged. Useful if you've placed it exactly where you want and don't want to bump it by accident.\n\nReset Positions and the :reset command still work.") - lockPositionCheckbox:SetPoint("TOPLEFT", staticOpacityCheckbox, "BOTTOMLEFT", 0, -2) + "When enabled, the search bar can't be dragged. Useful if you've placed it exactly where you want and don't want to bump it by accident.\n\nReset Positions and the /reset command still work.") + lockPositionCheckbox:SetPoint("TOPLEFT", smartShowCheckbox, "BOTTOMLEFT", 0, -2) lockPositionCheckbox:SetChecked(EasyFind.db.lockPosition or false) lockPositionCheckbox:SetScript("OnClick", function(self) EasyFind.db.lockPosition = self:GetChecked() @@ -1291,7 +1376,10 @@ function Options:Initialize() uiResultsAboveCheckbox:SetChecked(EasyFind.db.uiResultsAbove or false) uiResultsAboveCheckbox:SetScript("OnClick", function(self) EasyFind.db.uiResultsAbove = self:GetChecked() - if ns.UI and ns.UI.RefreshResults then ns.UI:RefreshResults() end + if self.RefreshVisual then self:RefreshVisual() end + RunSoon(function() + if ns.UI and ns.UI.RefreshResults then ns.UI:RefreshResults() end + end) end) optionsFrame.uiResultsAboveCheckbox = uiResultsAboveCheckbox @@ -1301,9 +1389,12 @@ function Options:Initialize() resultShortcutHintsCheckbox:SetChecked(EasyFind.db.showResultShortcutHints ~= false) resultShortcutHintsCheckbox:SetScript("OnClick", function(self) EasyFind.db.showResultShortcutHints = self:GetChecked() - if ns.UI and ns.UI.RefreshResults then - ns.UI:RefreshResults() - end + if self.RefreshVisual then self:RefreshVisual() end + RunSoon(function() + if ns.UI and ns.UI.RefreshResults then + ns.UI:RefreshResults() + end + end) end) optionsFrame.resultShortcutHintsCheckbox = resultShortcutHintsCheckbox @@ -1348,7 +1439,7 @@ function Options:Initialize() StaticPopup_Show("EASYFIND_RESET_UI_POS") end) - -- SECTION 3: Map Search + -- SECTION 3: Map local sec2 = CreateTab("Map") local mapEnableCheckbox = CreateCheckbox(sec2, "EnableMap", "Enable Map Search Module", @@ -1390,6 +1481,7 @@ function Options:Initialize() local mapTabLabel = sec2:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") mapTabLabel:SetPoint("TOPLEFT", sec2, "TOPLEFT", 8, -48) mapTabLabel:SetText("Map Tab") + mapTabLabel:SetTextColor(SECTION_TITLE_TEXT[1], SECTION_TITLE_TEXT[2], SECTION_TITLE_TEXT[3], 1) local mapTabSettings = CreateSettingsGroup(sec2, GROUP_W, ROW_H * 2 + 8) mapTabSettings:SetPoint("TOPLEFT", mapTabLabel, "BOTTOMLEFT", 0, -3) @@ -1421,8 +1513,11 @@ function Options:Initialize() optionsFrame.UpdateRecentCountEnabled = UpdateRecentCountEnabled mapTabShowRecentCheckbox:SetScript("OnClick", function(self) EasyFind.db.mapTabShowRecent = self:GetChecked() + if self.RefreshVisual then self:RefreshVisual() end UpdateRecentCountEnabled() - if ns.MapTab and ns.MapTab.RefreshIfOpen then ns.MapTab:RefreshIfOpen() end + RunSoon(function() + if ns.MapTab and ns.MapTab.RefreshIfOpen then ns.MapTab:RefreshIfOpen() end + end) end) UpdateRecentCountEnabled() @@ -1432,6 +1527,7 @@ function Options:Initialize() local mapIconsLabel = sec2:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") mapIconsLabel:SetPoint("TOPLEFT", mapTabSettings, "BOTTOMLEFT", 0, -6) mapIconsLabel:SetText("EF Map Icons") + mapIconsLabel:SetTextColor(SECTION_TITLE_TEXT[1], SECTION_TITLE_TEXT[2], SECTION_TITLE_TEXT[3], 1) local mapIconSettings = CreateSettingsGroup(sec2, GROUP_W, ROW_H * 3 + 8) mapIconSettings:SetPoint("TOPLEFT", mapIconsLabel, "BOTTOMLEFT", 0, -3) @@ -1443,7 +1539,10 @@ function Options:Initialize() mapPinHighlightCheckbox:SetChecked(EasyFind.db.mapPinHighlight ~= false) mapPinHighlightCheckbox:SetScript("OnClick", function(self) EasyFind.db.mapPinHighlight = self:GetChecked() - if ns.MapSearch and ns.MapSearch.UpdatePinHighlight then ns.MapSearch:UpdatePinHighlight() end + if self.RefreshVisual then self:RefreshVisual() end + RunSoon(function() + if ns.MapSearch and ns.MapSearch.UpdatePinHighlight then ns.MapSearch:UpdatePinHighlight() end + end) end) optionsFrame.mapPinHighlightCheckbox = mapPinHighlightCheckbox @@ -1453,7 +1552,10 @@ function Options:Initialize() blinkingPinsCheckbox:SetChecked(EasyFind.db.blinkingPins or false) blinkingPinsCheckbox:SetScript("OnClick", function(self) EasyFind.db.blinkingPins = self:GetChecked() - if ns.MapSearch and ns.MapSearch.UpdateBlinkingPins then ns.MapSearch:UpdateBlinkingPins() end + if self.RefreshVisual then self:RefreshVisual() end + RunSoon(function() + if ns.MapSearch and ns.MapSearch.UpdateBlinkingPins then ns.MapSearch:UpdateBlinkingPins() end + end) end) optionsFrame.blinkingPinsCheckbox = blinkingPinsCheckbox @@ -1486,6 +1588,7 @@ function Options:Initialize() local mapPinsLabel = sec2:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") mapPinsLabel:SetPoint("TOPLEFT", mapIconSettings, "BOTTOMLEFT", 0, -6) mapPinsLabel:SetText("Map Pins") + mapPinsLabel:SetTextColor(SECTION_TITLE_TEXT[1], SECTION_TITLE_TEXT[2], SECTION_TITLE_TEXT[3], 1) local mapPinSettings = CreateSettingsGroup(sec2, GROUP_W, ROW_H * 3 + 8) mapPinSettings:SetPoint("TOPLEFT", mapPinsLabel, "BOTTOMLEFT", 0, -3) @@ -1497,12 +1600,15 @@ function Options:Initialize() rareTrackCheckbox:SetChecked(EasyFind.db.alwaysShowRares or false) rareTrackCheckbox:SetScript("OnClick", function(self) EasyFind.db.alwaysShowRares = self:GetChecked() - if ns.MapSearch then - ns.MapSearch:UpdateRareTracking() - if ns.MapSearch.UpdateAutoTrackLabel then - ns.MapSearch:UpdateAutoTrackLabel() + if self.RefreshVisual then self:RefreshVisual() end + RunSoon(function() + if ns.MapSearch then + ns.MapSearch:UpdateRareTracking() + if ns.MapSearch.UpdateAutoTrackLabel then + ns.MapSearch:UpdateAutoTrackLabel() + end end - end + end) end) optionsFrame.rareTrackCheckbox = rareTrackCheckbox @@ -1556,78 +1662,19 @@ function Options:Initialize() .. "|cFF00FF00Down|r enter results |cFF00FF00Enter|r activate |cFF00FF00Esc|r unfocus\n" .. "|cFF00FF00Tab / Shift+Tab|r cycle search/clear/filter buttons\n\n" .. "|cFFFFD100Results list:|r\n" - .. "|cFF00FF00Up/Down|r or |cFF00FF00Ctrl+K/J|r Move through results\n" - .. "|cFF00FF00Tab/Shift+Tab|r or |cFF00FF00Ctrl+L/H|r Cycle focus to nav buttons\n" + .. "|cFF00FF00Up/Down|r or |cFF00FF00Alt+K/J|r Move through results\n" + .. "|cFF00FF00Tab/Shift+Tab|r or |cFF00FF00Alt+L/H|r Cycle focus to nav buttons\n" .. "|cFF00FF00PgUp/PgDn|r jump 5 |cFF00FF00Home/End|r first/last\n" - .. "|cFF00FF00Shift+Up/Down|r or |cFF00FF00Ctrl+Shift+K/J|r jump section\n\n" + .. "|cFF00FF00Shift+Up/Down|r or |cFF00FF00Alt+Shift+K/J|r jump section\n\n" .. "|cFFFFD100Quick filters:|r\n" .. "Type |cFF00FF00@|r for category filters. Examples: |cFF00FF00@m|r mounts, |cFF00FF00@s|r statistics, |cFF00FF00@g|r gear. |cFF00FF00Tab/Space|r selects a category.\n\n" .. "|cFFFFD100Calculator:|r\n" - .. "Type math into search. |cFF00FF00Alt+C|r opens the calculator.\n\n" + .. "Type math into search for an inline result, or press |cFF00FF00Alt+C|r from the focused search bar to open the calculator.\n\n" .. "|cFFFFD100Other:|r\n" .. "|cFF00FF00Shift+Drag|r reposition |cFF00FF00Right-click|r pin/unpin\n\n" .. "|cFFFFD100Slash commands:|r\n" .. "|cFF00FF00/ef|r open options |cFF00FF00/ef c|r clear highlights and pins\n" ) - -- Keybind buttons - local KEYBIND_ROW_H = 24 - local KEYBIND_BTN_W = 116 - - local keybindDefs = { - { label = "Toggle Search Bar", action = "EASYFIND_TOGGLE_FOCUS" }, - { label = "Open Map Search", action = "EASYFIND_MAP_FOCUS" }, - { label = "Clear All", action = "EASYFIND_CLEAR" }, - } - - local keybindTooltips = { - EASYFIND_TOGGLE_FOCUS = { "Toggle Search Bar", "Opens and focuses the UI search bar in one press. Press again to close." }, - EASYFIND_MAP_FOCUS = { "Open Map Search", "Opens the world map, switches to the EasyFind Map Search tab, and focuses its search box." }, - EASYFIND_CLEAR = { "Clear All", "Dismisses all active highlights, map pins, zone highlights, and pending waypoints." }, - } - - local keybindButtons = {} - -- Single column. Two-column layout overlapped because labels like - -- "Toggle Search Bar:" exceed the small per-column allowance and - -- bleed into the button area of the same row. One column with a - -- wide label slot keeps every label clear of its button. - local KEYBIND_LABEL_W = 168 - for i, def in ipairs(keybindDefs) do - local row = i - 1 - - local rowLabel = sec4:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - rowLabel:SetPoint("TOPLEFT", shortcutText, "BOTTOMLEFT", 0, -12 - row * KEYBIND_ROW_H) - rowLabel:SetText(def.label .. ":") - - local keybindBtn = CreateModernButton(sec4) - keybindBtn:SetNormalFontObject("GameFontHighlightSmall") - keybindBtn:SetHighlightFontObject("GameFontHighlightSmall") - keybindBtn:SetSize(KEYBIND_BTN_W, 20) - keybindBtn:SetPoint("LEFT", rowLabel, "LEFT", KEYBIND_LABEL_W, 0) - keybindBtn:SetText(GetCurrentKeybindText(def.action)) - keybindBtn:SetScript("OnClick", function(self, button) - if button == "RightButton" then - local old1, old2 = GetBindingKey(def.action) - if old1 then SetBinding(old1) end - if old2 then SetBinding(old2) end - SaveBindings(GetCurrentBindingSet()) - self:SetText("Not Bound") - - else - StartCapture(self, def.action) - end - end) - keybindBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp") - - local tip = keybindTooltips[def.action] - if tip then - MakeKeybindTooltip(keybindBtn, tip[1], tip[2]) - end - - keybindButtons[def.action] = keybindBtn - end - optionsFrame.toggleFocusBtn = keybindButtons["EASYFIND_TOGGLE_FOCUS"] - optionsFrame.mapFocusBtn = keybindButtons["EASYFIND_MAP_FOCUS"] - optionsFrame.clearBtn = keybindButtons["EASYFIND_CLEAR"] -- SECTION 5: Aliases local aliasesTab = CreateTab("Aliases") @@ -2015,16 +2062,10 @@ function Options:DoResetAll() ResetOptionsPosition() ClearMapRuntime() - ClearBinding("EASYFIND_TOGGLE_FOCUS") - ClearBinding("EASYFIND_MAP_FOCUS") - ClearBinding("EASYFIND_CLEAR") - SaveBindings(GetCurrentBindingSet()) - SyncOptionControls() if ns.RefreshAddonFont then ns.RefreshAddonFont() end RefreshUIRuntime(true) RefreshMapRuntime() - if _G["EasyFindSearchFrame"] and ns.UI and ns.UI.Show then ns.UI:Show() end EasyFind:UpdateMinimapButton() if needsReload then diff --git a/UI.lua b/UI.lua index e49504f..bb8802b 100644 --- a/UI.lua +++ b/UI.lua @@ -273,7 +273,8 @@ local REP_FACTION_ICONS = { local function GetFlatCategoryIcon(data) if not data then return nil end - if data.calculatorResult then return FLAT_CATEGORY_ICONS.calculator end + if data.calculatorResult or data.calculatorLauncher then return FLAT_CATEGORY_ICONS.calculator end + if data.searchCommand then return FLAT_CATEGORY_ICONS.setting end if data.quickFilterDef then local key = data.quickFilterDef.key if key == "abilities" then return FLAT_CATEGORY_ICONS.ability end @@ -398,6 +399,8 @@ end local function GetFlatSubtext(data) if not data then return "" end if data.calculatorResult then return "Expression" end + if data.calculatorLauncher then return "Alt+C to open" end + if data.searchCommandDesc then return data.searchCommandDesc end if data.quickFilterAliasText then return data.quickFilterAliasText end if data.quickFilterDef then return data.quickFilterDef.label or "Quick Filter" end if data.path and #data.path > 0 then @@ -443,6 +446,15 @@ end UI._calculator = { PATH = { "Calculator" }, + LAUNCHER = { + name = "Calculator", + nameLower = "calculator", + category = "Calculator", + path = { "Calculator" }, + noPin = true, + calculatorLauncher = true, + keywords = { "calculator", "calc", "math" }, + }, FUNCTIONS = { abs = true, acos = true, acosd = true, asin = true, asind = true, atan = true, atan2 = true, atand = true, ceil = true, cos = true, @@ -454,6 +466,35 @@ UI._calculator = { }, } +UI.searchBarCommands = { + { + command = "reset", + display = "/reset", + desc = "Reset search bar position and size", + aliases = { "reset", "resetpos", "resetposition" }, + }, + { + command = "resize", + display = "/resize", + desc = "Resize the search window", + aliases = { "resize", "rescale" }, + }, + { + command = "options", + display = "/options", + desc = "Open EasyFind options", + aliases = { "options", "o", "config", "settings" }, + }, + { + command = "tutorial", + display = "/tutorial", + desc = "Open the EasyFind tutorial", + aliases = { "tutorial", "wizard", "welcome" }, + }, +} +UI.searchBarCommandEntries = {} +UI.searchBarCommandData = {} + function UI._calculator.IsFinite(value) return type(value) == "number" and value == value and value ~= math.huge and value ~= -math.huge @@ -750,8 +791,13 @@ function UI._calculator.Tokenize(text) repeat i = i + 1 ch = i <= n and text:sub(i, i) or "" - until ch == "" or not ch:match("[%w_]") - tokens[#tokens + 1] = { type = "ident", value = slower(text:sub(start, i - 1)) } + until ch == "" or not ch:match("[%a_]") + local ident = slower(text:sub(start, i - 1)) + if ident == "atan" and i <= n and text:sub(i, i) == "2" then + ident = "atan2" + i = i + 1 + end + tokens[#tokens + 1] = { type = "ident", value = ident } elseif ch == "+" or ch == "-" or ch == "*" or ch == "/" or ch == "^" or ch == "%" or ch == "!" or ch == "(" or ch == ")" then tokens[#tokens + 1] = { type = "op", value = ch } @@ -966,6 +1012,121 @@ function UI:EvaluateCalculatorExpression(raw) } end +function UI:GetCalculatorLauncherMatch(raw) + raw = slower(strtrim(raw or "")) + if raw == "" then return nil end + local launcher = UI._calculator.LAUNCHER + if sfind("calculator", raw, 1, true) == 1 + or sfind("calc", raw, 1, true) == 1 + or raw == "math" then + return launcher + end + return nil +end + +function UI:RunSearchBarCommand(command) + command = slower(strtrim(tostring(command or "")):gsub("^/", "")) + if command == "" then return false end + + local canonical + for i = 1, #self.searchBarCommands do + local def = self.searchBarCommands[i] + for ai = 1, #def.aliases do + if command == def.aliases[ai] then + canonical = def.command + break + end + end + if canonical then break end + end + if not canonical then return false end + + local editBox = searchFrame and searchFrame.editBox + if editBox then + if editBox.ResetPendingSearch then editBox:ResetPendingSearch() end + editBox:SetText("") + editBox:SetCursorPosition(0) + if editBox.placeholder then editBox.placeholder:Show() end + end + self:HideResults() + + if canonical == "reset" then + StaticPopup_Show("EASYFIND_RESET_SEARCH_BAR") + elseif canonical == "resize" then + if ns.Rescaler and ns.Rescaler.Enter then + ns.Rescaler:Enter("ui") + end + elseif canonical == "options" then + EasyFind:OpenOptions() + elseif canonical == "tutorial" then + if ns.Wizard and ns.Wizard.Show then + EasyFind.db.tutorialDone = false + ns.Wizard:Show() + end + end + return true +end + +function UI:GetSearchBarCommandSuggestionEntries(text) + text = strtrim(text or "") + local token = text:match("^/([%w]*)$") + if token == nil then return nil end + token = slower(token) + + local entries = self.searchBarCommandEntries + local dataPool = self.searchBarCommandData + local n = 0 + for i = 1, #self.searchBarCommands do + local def = self.searchBarCommands[i] + local matches = token == "" + if not matches then + local displayToken = slower((def.display or ""):gsub("^/", "")) + matches = sfind(displayToken, token, 1, true) == 1 + if not matches then + for ai = 1, #def.aliases do + if sfind(def.aliases[ai], token, 1, true) == 1 then + matches = true + break + end + end + end + end + if matches then + n = n + 1 + local data = dataPool[n] + if not data then + data = {} + dataPool[n] = data + end + wipe(data) + data.name = def.display + data.nameLower = slower(def.display) + data.category = "Command" + data.path = { "Search Bar" } + data.noPin = true + data.searchCommand = def.command + data.searchCommandDesc = def.desc + + local entry = entries[n] + if not entry then + entry = {} + entries[n] = entry + end + entry.name = data.name + entry.depth = 0 + entry.isPathNode = false + entry.isMatch = true + entry.isFlat = true + entry.flatCatKey = nil + entry.isPinned = false + entry.data = data + end + end + for i = n + 1, #entries do entries[i] = nil end + for i = n + 1, #dataPool do dataPool[i] = nil end + return n > 0 and entries or nil +end + function UI:SetCalculatorRoundedFill(frame, r, g, b, a, br, bg, bb, ba) if not (frame and frame.combinedBorder) then return end if frame.combinedBorder.fill then @@ -1049,8 +1210,12 @@ function UI:RefreshCalculatorPopup() local text = strtrim(raw) local submitted = UI._calculator.popupSubmitted and UI._calculator.popupSubmittedText == raw - local data = submitted and self:EvaluateCalculatorExpression(raw) or nil + local data = text ~= "" and self:EvaluateCalculatorExpression(raw) or nil UI._calculator.popupData = data + if UI._calculator.activeSource == "calculator" + and (not data or UI._calculator.activeResult ~= data.calculatorResult) then + self:ClearCalculatorPopupCopyTarget(true) + end if frame.clearInputButton then if text ~= "" then frame.clearInputButton:Show() @@ -1060,17 +1225,24 @@ function UI:RefreshCalculatorPopup() end if submitted and data then frame.resultText:SetText(data.calculatorResult) - frame.expressionText:SetText(data.calculatorExpression) + if frame.expressionText then frame.expressionText:Hide() end frame.resultText:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1) if frame.hintText then frame.hintText:Hide() end self:SetCalculatorRoundedFill(frame.resultCard, 0.19, 0.16, 0.10, 0.98, 0.76, 0.56, 0.22, 0.90) + elseif data then + frame.resultText:SetText(data.calculatorResult) + if frame.expressionText then frame.expressionText:Hide() end + frame.resultText:SetTextColor(0.86, 0.86, 0.88, 1) + if frame.hintText then frame.hintText:Hide() end + self:SetCalculatorRoundedFill(frame.resultCard, 0.13, 0.13, 0.145, 0.96, 0.28, 0.28, 0.30, 0.75) else frame.resultText:SetText(submitted and text ~= "" and "-" or "") - frame.expressionText:SetText(text ~= "" and text or "Type an expression") + if frame.expressionText then frame.expressionText:Hide() end frame.resultText:SetTextColor(0.58, 0.58, 0.60, 1) if frame.hintText then frame.hintText:Hide() end self:SetCalculatorRoundedFill(frame.resultCard, 0.13, 0.13, 0.145, 0.96, 0.28, 0.28, 0.30, 0.75) end + self:UpdateCalculatorPopupCopyVisual(data) end function UI:InsertCalculatorPopupText(text) @@ -1125,8 +1297,12 @@ function UI:SubmitCalculatorPopupExpression(raw) end function UI:IsCalculatorPopupSubmitKey(key) - return key == "ENTER" - or key == "=" + if key == "ENTER" then return true end + if IsShiftKeyDown and IsShiftKeyDown() + and (key == "=" or key == "EQUAL" or key == "EQUALS") then + return false + end + return key == "=" or key == "EQUAL" or key == "EQUALS" or key == "NUMPADEQUALS" @@ -1162,23 +1338,89 @@ function UI:QueueCalculatorPopupEqualsSubmit(editBox) end) end -function UI:ArmCalculatorPopupResult() - local data = UI._calculator.popupData - if not data or not data.calculatorResult then return false end +function UI:UpdateCalculatorPopupCopyVisual(data) + local frame = UI._calculator.popupFrame + if not (frame and frame.resultCard and frame.resultText) then return end + local result = data and data.calculatorResult + local active = result + and UI._calculator.activeSource == "calculator" + and UI._calculator.activePart == "result" + and UI._calculator.activeResult == result + local copied = active + and UI._calculator.copyComplete + and UI._calculator.copyCompleteValue == result + + frame.resultText:ClearAllPoints() + if active then + frame.resultText:SetPoint("TOPLEFT", frame.resultCard, "TOPLEFT", 12, -8) + frame.resultText:SetPoint("RIGHT", frame.resultCard, "RIGHT", -12, 0) + frame.resultText:SetTextColor( + copied and 0.48 or GOLD_COLOR[1], + copied and 1.0 or GOLD_COLOR[2], + copied and 0.62 or GOLD_COLOR[3], + 1) + if frame.hintText then + frame.hintText:SetText(copied and "Now Ctrl+V to paste" or "Ctrl+C to copy") + frame.hintText:SetTextColor(copied and 0.48 or 0.72, copied and 1.0 or 0.72, copied and 0.62 or 0.72, 1) + frame.hintText:Show() + end + self:SetCalculatorRoundedFill(frame.resultCard, 0.22, 0.18, 0.11, 1, 0.95, 0.72, 0.28, 1) + else + frame.resultText:SetPoint("LEFT", frame.resultCard, "LEFT", 12, 0) + frame.resultText:SetPoint("RIGHT", frame.resultCard, "RIGHT", -12, 0) + end +end + +function UI:ClearCalculatorPopupCopyTarget(release) + if UI._calculator.activeSource ~= "calculator" then return end UI._calculator.activeRow = nil - UI._calculator.activeData = data - UI._calculator.activeResult = data.calculatorResult - UI._calculator.activePart = "result" - UI._calculator.activeSource = "calculator" + UI._calculator.activeData = nil + UI._calculator.activeResult = nil + UI._calculator.activePart = nil + UI._calculator.activeSource = nil + UI._calculator.popupArmedSource = nil UI._calculator.copyComplete = nil UI._calculator.copyCompleteValue = nil UI._calculator.copiedData = nil UI._calculator.copiedPart = nil - if self:CopyCalculatorResult(data.calculatorResult, "calculator") then - self:StartCalculatorCopyWatcher() - return true + UI._calculator.ctrlWasDown = nil + UI._calculator.copyKeyWasDown = nil + if UI._calculator.copyWatcher then + UI._calculator.copyWatcher:Hide() end - return false + if release then + self:ReleaseCalculatorCopyBox() + end +end + +function UI:ArmCalculatorPopupResult(source) + local data = UI._calculator.popupData + if not data or not data.calculatorResult then return false end + source = source or "click" + local result = data.calculatorResult + local completedHover = source == "hover" and UI._calculator.copyComplete + if source ~= "ctrl" and source ~= "confirm" and not completedHover then + UI._calculator.copyComplete = nil + UI._calculator.copyCompleteValue = nil + end + if UI._calculator.activeResult ~= result then + UI._calculator.copiedData = nil + UI._calculator.copiedPart = nil + end + if source ~= "hover" or ((IsControlKeyDown and IsControlKeyDown()) and not UI._calculator.copyComplete) then + if not self:CopyCalculatorResult(result, "calculator") then + return false + end + end + UI._calculator.activeRow = nil + UI._calculator.activeData = data + UI._calculator.activeResult = result + UI._calculator.activePart = "result" + UI._calculator.activeSource = "calculator" + UI._calculator.popupArmedSource = source + self:UpdateCalculatorPopupCopyVisual(data) + self:StartCalculatorCopyWatcher() + return true end function UI:RestoreCalculatorPopupFocus() @@ -1194,9 +1436,19 @@ function UI:EnsureCalculatorFrame() local frame = UI._calculator.popupFrame if frame then return frame end + local CALC_PAD = 14 + local CALC_BUTTON_W = 40 + local CALC_BUTTON_H = 24 + local CALC_BUTTON_GAP_X = 4 + local CALC_BUTTON_GAP_Y = 4 + local CALC_COLS = 5 + local CALC_W = CALC_PAD * 2 + + CALC_BUTTON_W * CALC_COLS + + CALC_BUTTON_GAP_X * (CALC_COLS - 1) + frame = CreateFrame("Frame", "EasyFindCalculatorFrame", UIParent) UI._calculator.popupFrame = frame - frame:SetSize(284, 304) + frame:SetSize(CALC_W, 304) frame:SetPoint("CENTER") frame:SetFrameStrata("FULLSCREEN_DIALOG") frame:SetFrameLevel(900) @@ -1212,7 +1464,7 @@ function UI:EnsureCalculatorFrame() self:SetCalculatorRoundedFill(frame, 0.055, 0.055, 0.064, 0.98, 0.30, 0.30, 0.32, 0.95) local glyph = self:CreateCalculatorGlyph(frame, 22) - glyph:SetPoint("TOPLEFT", frame, "TOPLEFT", 14, -12) + glyph:SetPoint("TOPLEFT", frame, "TOPLEFT", CALC_PAD, -12) local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") title:SetPoint("LEFT", glyph, "RIGHT", 8, 0) @@ -1221,7 +1473,7 @@ function UI:EnsureCalculatorFrame() local close = CreateFrame("Button", nil, frame) close:SetSize(18, 18) - close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -14, -12) + close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -CALC_PAD, -12) local function makeCloseStroke() local tex = close:CreateTexture(nil, "OVERLAY") tex:SetTexture("Interface\\Buttons\\WHITE8x8") @@ -1241,8 +1493,8 @@ function UI:EnsureCalculatorFrame() close:SetScript("OnClick", function() frame:Hide() end) local inputShell = CreateFrame("Frame", nil, frame) - inputShell:SetPoint("TOPLEFT", frame, "TOPLEFT", 14, -44) - inputShell:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -14, -44) + inputShell:SetPoint("TOPLEFT", frame, "TOPLEFT", CALC_PAD, -44) + inputShell:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -CALC_PAD, -44) inputShell:SetHeight(38) ns.CreateRoundedRectBorder(inputShell) ns.SetRoundedRectBarHeight(inputShell, 10) @@ -1346,37 +1598,42 @@ function UI:EnsureCalculatorFrame() expressionText:SetPoint("RIGHT", resultCard, "RIGHT", -12, 0) expressionText:SetJustifyH("LEFT") expressionText:SetTextColor(0.66, 0.66, 0.68, 1) + expressionText:Hide() local resultText = resultCard:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge") frame.resultText = resultText - resultText:SetPoint("BOTTOMRIGHT", resultCard, "BOTTOMRIGHT", -12, 10) - resultText:SetJustifyH("RIGHT") + resultText:SetPoint("LEFT", resultCard, "LEFT", 12, 0) + resultText:SetPoint("RIGHT", resultCard, "RIGHT", -12, 0) + resultText:SetWidth(CALC_W - CALC_PAD * 2 - 16) + resultText:SetJustifyH("LEFT") resultText:SetText("-") local hintText = resultCard:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") frame.hintText = hintText hintText:SetPoint("BOTTOMLEFT", resultCard, "BOTTOMLEFT", 12, 10) - hintText:SetPoint("RIGHT", resultText, "LEFT", -8, 0) + hintText:SetPoint("RIGHT", resultCard, "RIGHT", -12, 0) hintText:SetJustifyH("LEFT") hintText:SetTextColor(0.54, 0.54, 0.56, 1) hintText:Hide() resultCard:SetScript("OnEnter", function(self) - if UI._calculator.popupData then - UI:SetCalculatorRoundedFill(self, 0.22, 0.18, 0.11, 1, 0.95, 0.72, 0.28, 1) - end + UI:ArmCalculatorPopupResult("hover") end) resultCard:SetScript("OnLeave", function(self) + if UI._calculator.activeSource == "calculator" + and UI._calculator.popupArmedSource == "hover" then + UI:ClearCalculatorPopupCopyTarget(true) + end UI:RefreshCalculatorPopup() end) resultCard:SetScript("OnClick", function() - UI:ArmCalculatorPopupResult() + UI:ArmCalculatorPopupResult("click") end) local function makeButton(label, insertText) local b = CreateFrame("Button", nil, frame) - b:SetSize(48, 24) - UI:StyleCalculatorButton(b, 24) + b:SetSize(CALC_BUTTON_W, CALC_BUTTON_H) + UI:StyleCalculatorButton(b, CALC_BUTTON_H) b.text = b:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") b.text:SetPoint("CENTER") b.text:SetText(label) @@ -1410,27 +1667,20 @@ function UI:EnsureCalculatorFrame() for col = 1, #rows[row] do local spec = rows[row][col] local b = makeButton(spec[1], spec[2]) - b:SetPoint("TOPLEFT", frame, "TOPLEFT", 14 + (col - 1) * 52, gridTop - (row - 1) * 28) + b:SetPoint( + "TOPLEFT", frame, "TOPLEFT", + CALC_PAD + (col - 1) * (CALC_BUTTON_W + CALC_BUTTON_GAP_X), + gridTop - (row - 1) * (CALC_BUTTON_H + CALC_BUTTON_GAP_Y) + ) end end - frame:SetScript("OnShow", function(self) - self:RegisterEvent("GLOBAL_MOUSE_DOWN") - end) frame:SetScript("OnHide", function(self) - self:UnregisterEvent("GLOBAL_MOUSE_DOWN") if UI._calculator.activeSource == "calculator" then UI:ClearCalculatorCopyHighlight() UI:ReleaseCalculatorCopyBox() end end) - frame:SetScript("OnEvent", function(self, event) - if event ~= "GLOBAL_MOUSE_DOWN" then return end - if self:IsMouseOver() then return end - if searchFrame and searchFrame:IsMouseOver() then return end - if resultsFrame and resultsFrame:IsMouseOver() then return end - self:Hide() - end) frame:Hide() self:RefreshCalculatorPopup() return frame @@ -1454,6 +1704,7 @@ function UI:CloseSearchForCalculator() end function UI:OpenCalculator(expression, deferFocus) + expression = tostring(expression or "") self:CloseSearchForCalculator() local frame = self:EnsureCalculatorFrame() @@ -1462,8 +1713,8 @@ function UI:OpenCalculator(expression, deferFocus) UI._calculator.popupSubmittedText = nil UI._calculator.popupData = nil if editBox then - editBox:SetText("") - editBox:SetCursorPosition(0) + editBox:SetText(expression) + editBox:SetCursorPosition(#expression) end frame:ClearAllPoints() frame:SetPoint("CENTER") @@ -1495,7 +1746,12 @@ end function UI:HandleCalculatorOpenShortcut(editBox, key) if not self:IsCalculatorOpenShortcut(key) then return false end - local expression = editBox and editBox.GetText and editBox:GetText() + local expression = "" + if editBox and editBox.GetText then + local text = editBox:GetText() or "" + local cursor = editBox.GetCursorPosition and editBox:GetCursorPosition() or #text + expression = text:sub(1, cursor) + end self:OpenCalculator(expression, true) return true end @@ -1883,6 +2139,9 @@ function UI:ConfirmCalculatorCopied() self:SetCalculatorCopyHighlight(row, part) self:PlayCalculatorCopyFlash(row, part) end + if popupCopy then + self:UpdateCalculatorPopupCopyVisual(UI._calculator.popupData) + end if C_Timer and C_Timer.After then C_Timer.After(0, function() UI:ReleaseCalculatorCopyBox("confirm") @@ -1939,7 +2198,7 @@ function UI:IsCalculatorCopyKey(key) if key == "LEFT" or key == "ARROWLEFT" or key == "RIGHT" or key == "ARROWRIGHT" then return true end - return IsControlKeyDown and IsControlKeyDown() and (key == "H" or key == "L") + return IsAltKeyDown and IsAltKeyDown() and (key == "H" or key == "L") end function UI:HandleCalculatorCopyKey(key) @@ -2323,8 +2582,34 @@ function UI:KeepPinnedResultsOpenBriefly() end local function IsOptionsSurfaceMouseOver() + local function hasFocus(frame) + if not frame or not frame:IsShown() then return false end + if GetMouseFoci then + local foci = GetMouseFoci() + if foci then + for i = 1, #foci do + local f = foci[i] + while f do + if f == frame then return true end + f = f.GetParent and f:GetParent() + end + end + end + elseif GetMouseFocus then + local f = GetMouseFocus() + while f do + if f == frame then return true end + f = f.GetParent and f:GetParent() + end + end + return false + end + local function isInside(frame) + return Utils.IsFrameOrChildMouseOver(frame) or hasFocus(frame) + end + local frame = ns.optionsFrame - if Utils.IsFrameOrChildMouseOver(frame) then return true end + if isInside(frame) then return true end if not frame then return false end local guards = { frame.indicatorFlyout, @@ -2335,7 +2620,7 @@ local function IsOptionsSurfaceMouseOver() frame.automationGroup and frame.automationGroup.flyout, } for i = 1, #guards do - if Utils.IsFrameOrChildMouseOver(guards[i]) then return true end + if isInside(guards[i]) then return true end end return false end @@ -2836,7 +3121,7 @@ function UI:CreateSearchFrame() else -- Refocus with leftover text: select all so the user can -- start typing fresh (overwrites) or hit Right Arrow / - -- Ctrl+L to keep editing from the end. + -- Alt+L to keep editing from the end. self:HighlightText(0, #text) self:SetCursorPosition(#text) -- Re-show results if they were closed by a prior click-out. @@ -3006,30 +3291,10 @@ function UI:CreateSearchFrame() local typed = strtrim(self:GetText() or "") -- /command parser. Anything starting with "/" is treated as a - -- bar command, not a search query. /reset snaps the bar back to - -- the top of the screen, /resize opens the drag-to-resize overlay. + -- bar command, not a search query. /reset restores the default + -- position and size, /resize opens the drag-to-resize overlay. if typed:sub(1, 1) == "/" then - local cmd = typed:lower():sub(2) - self:SetText("") - self.placeholder:Show() - UI:HideResults() - if cmd == "reset" or cmd == "resetpos" or cmd == "resetposition" then - UI:ResetPosition() - EasyFind:Print("Search bar position reset.") - elseif cmd == "resize" or cmd == "rescale" then - if ns.Rescaler and ns.Rescaler.Enter then - ns.Rescaler:Enter("ui") - end - elseif cmd == "options" or cmd == "o" or cmd == "config" or cmd == "settings" then - EasyFind:OpenOptions() - elseif cmd == "tutorial" or cmd == "wizard" or cmd == "welcome" then - if ns.Wizard and ns.Wizard.Show then - EasyFind.db.tutorialDone = false - ns.Wizard:Show() - end - else - EasyFind:Print("Unknown command: /" .. cmd) - end + UI:RunSearchBarCommand(typed) return end @@ -3349,8 +3614,8 @@ function UI:CreateSearchFrame() self:AcceptAutocomplete("right") Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) return - elseif key == "L" and IsControlKeyDown() then - self:AcceptAutocomplete("ctrl-l") + elseif key == "L" and IsAltKeyDown() then + self:AcceptAutocomplete("alt-l") Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) return end @@ -3369,8 +3634,8 @@ function UI:CreateSearchFrame() -- into the results list. Drop-into-results works regardless -- of buffer content: the user wants keyboard nav into rows -- without having to press Enter first, even mid-edit. - local isUpHist = key == "UP" or (IsControlKeyDown() and (key == "K" or key == "P")) - local isDownHist = key == "DOWN" or (IsControlKeyDown() and (key == "J" or key == "N")) + local isUpHist = key == "UP" or (IsAltKeyDown() and key == "K") + local isDownHist = key == "DOWN" or (IsAltKeyDown() and key == "J") if isUpHist then if UI:NavigateSearchHistory(1) then Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) @@ -3387,7 +3652,7 @@ function UI:CreateSearchFrame() return end -- historyIndex == 0 (live draft): fall through to result - -- nav so DOWN/Ctrl+J jumps into the first row. + -- nav so DOWN/Alt+J jumps into the first row. end if resultsFrame and resultsFrame:IsShown() and selectedIndex == 0 then @@ -3397,17 +3662,17 @@ function UI:CreateSearchFrame() if key == "DOWN" then UI:MoveSelection(1) end end end - -- Ctrl+J/K (and the emacs-equivalent Ctrl+N/P) walk into the + -- Alt+J/K walks into the -- result list once history navigation has been exhausted by -- the branch above. Single-step only (no key-repeat) because -- MoveSelection transfers keyboard focus to navFrame and the -- subsequent KeyUp event gets lost in the focus transition, -- leaving the repeat ticker firing forever and cascading -- through the entire result list. - if IsControlKeyDown() then - if key == "J" or key == "N" then + if IsAltKeyDown() then + if key == "J" then UI:MoveSelection(1) - elseif key == "K" or key == "P" then + elseif key == "K" then UI:MoveSelection(-1) end end @@ -3496,7 +3761,7 @@ function UI:CreateSearchFrame() -- Cycle focus through editBox → [clearBtn] → filterBtn → row → -- toggle button (chevron / pin toggle) and back. Shared by Tab, - -- Shift+Tab, and the Ctrl+L / Ctrl+H vim aliases below. + -- Shift+Tab, and the Alt+L / Alt+H vim aliases below. local function CycleFocus(reverse) if reverse then if selectedIndex > 0 and toggleFocused then @@ -3545,27 +3810,27 @@ function UI:CreateSearchFrame() if UI:HandleCalculatorCopyConfirmKey(key) then return end if UI:HandleCalculatorCopyKey(key) then return end - -- Ctrl+H/J/K/L: vim-style nav aliases. J/K = down/up (also - -- N/P emacs-style); add Shift to jump sections like Shift+Up/Down. + -- Alt+H/J/K/L: vim-style nav aliases. J/K = down/up; + -- add Shift to jump sections like Shift+Up/Down. -- H/L = focus cycle (Shift+Tab / Tab). - if IsControlKeyDown() and (key == "J" or key == "N") then + if IsAltKeyDown() and key == "J" then if IsShiftKeyDown() then UI:JumpToNextSection(1) else StartKeyRepeat(key, function() UI:MoveSelection(1) end) end return - elseif IsControlKeyDown() and (key == "K" or key == "P") then + elseif IsAltKeyDown() and key == "K" then if IsShiftKeyDown() then UI:JumpToNextSection(-1) else StartKeyRepeat(key, function() UI:MoveSelection(-1) end) end return - elseif IsControlKeyDown() and key == "L" then + elseif IsAltKeyDown() and key == "L" then CycleFocus(false) return - elseif IsControlKeyDown() and key == "H" then + elseif IsAltKeyDown() and key == "H" then CycleFocus(true) return end @@ -3749,8 +4014,7 @@ function UI:CreateSearchFrame() consume = true elseif UI:IsCalculatorCopyKey(key) then consume = true - elseif IsControlKeyDown() and (key == "J" or key == "N" or key == "K" - or key == "P" or key == "L" or key == "H") then + elseif IsAltKeyDown() and (key == "J" or key == "K" or key == "L" or key == "H") then consume = true elseif key == "SPACE" then -- Only consume SPACE when it would do something here: @@ -3800,7 +4064,7 @@ function UI:CreateSearchFrame() end) -- Tab confirms autocomplete suggestion only. Toolbar nav (clear / - -- filter buttons) is handled by Left/Right and Ctrl+H/Ctrl+L + -- filter buttons) is handled by Left/Right and Alt+H/Alt+L -- elsewhere; routing Tab into it stomped the autocomplete confirm. -- Plain drag moves the bar (no modifier required). Lock Position @@ -3822,12 +4086,7 @@ function UI:CreateSearchFrame() self:UpdateScale() self:UpdateOpacity() - -- Movement fade: reduce opacity while player is moving (like the world map) - local MOVE_FADE_FACTOR = 0.4 - local moveFading = false -- true when alpha is reduced due to movement - local function GetEffectiveAlpha() - if moveFading then return MOVE_FADE_FACTOR end return 1.0 end searchFrame.getEffectiveAlpha = GetEffectiveAlpha @@ -3906,44 +4165,6 @@ function UI:CreateSearchFrame() end end - -- OnUpdate: detect movement and adjust opacity accordingly (throttled to ~10Hz) - local moveCheckAccum = 0 - searchFrame:HookScript("OnUpdate", function(self, elapsed) - moveCheckAccum = moveCheckAccum + elapsed - if moveCheckAccum < 0.1 then return end - moveCheckAccum = 0 - - if EasyFind.db.staticOpacity then - if moveFading then - moveFading = false - self:SetAlpha(1.0) - end - return - end - if EasyFind.db.smartShow and not smartShowVisible then return end - -- While the player is resizing the bar, keep it fully visible so - -- they can see the live size/font changes. - if self.resizing then - if moveFading then - moveFading = false - UIFrameFadeRemoveFrame(self) - self:SetAlpha(1.0) - end - return - end - - local speed = GetUnitSpeed("player") - local hovering = self:IsMouseOver() - or (resultsFrame and resultsFrame:IsShown() and resultsFrame:IsMouseOver()) - local shouldFade = speed > 0 and not hovering - - if shouldFade ~= moveFading then - moveFading = shouldFade - UIFrameFadeRemoveFrame(self) - self:SetAlpha(GetEffectiveAlpha()) - end - end) - -- UI search filter dropdown self:CreateUIFilterDropdown(filterBtn, searchFrame, editBox) @@ -4336,7 +4557,7 @@ end function UI:QuickFilterAllowsData(data, quickFilter) local def = quickFilter or self._quickFilter if not def then return true end - if not data or data.calculatorResult then return false end + if not data or data.calculatorResult or data.calculatorLauncher or data.searchCommand then return false end if data.mapSearchResult then return def.key == "map" end if def.key == "map" then return false end @@ -6895,6 +7116,7 @@ local function IsShortcutEligibleRow(row) and not row.isPinHeader and not row.isSectionHeader and not row.isUnearnedCurrency and not row.data.calculatorResult + and not row.data.calculatorLauncher end local function ClearResultShortcutBindings() @@ -9389,6 +9611,11 @@ StaticPopupDialogs["EASYFIND_ADD_ALIAS"] = { local txt = eb and eb:GetText() or "" if strtrim(txt) == "" then return end if ns.Aliases and ns.Aliases:Add(txt, data) then + local aliasText = strtrim(txt) + local targetName = data and data.name or "this entry" + if EasyFind and EasyFind.Print and EasyFind.db and EasyFind.db.showAliasMessages ~= false then + EasyFind:Print("New alias: " .. aliasText .. " -> " .. targetName .. ". View this and any other existing aliases in the Aliases tab of the options menu.") + end local searchEditBox = searchFrame and searchFrame.editBox local current = searchEditBox and searchEditBox:GetText() or "" if current ~= "" then UI:OnSearchTextChanged(current) end @@ -9403,6 +9630,25 @@ StaticPopupDialogs["EASYFIND_ADD_ALIAS"] = { end, } +StaticPopupDialogs["EASYFIND_RESET_SEARCH_BAR"] = { + text = "Reset the search bar to its default position and size?", + button1 = "Reset", + button2 = CANCEL or "Cancel", + OnShow = function(self) + self:SetFrameStrata("TOOLTIP") + self:SetFrameLevel(1000) + end, + OnAccept = function() + if ns.UI and ns.UI.ResetPositionAndSize then + ns.UI:ResetPositionAndSize() + end + end, + timeout = 0, + whileDead = true, + hideOnEscape = true, + preferredIndex = 3, +} + function UI:PromptForAlias(data) if not data then return end local label = data.name or "this entry" @@ -9487,9 +9733,17 @@ function UI:OnSearchTextChanged(text, force) return end + local commandEntries = (not quickFilter) and self:GetSearchBarCommandSuggestionEntries(text) + if commandEntries then + self:ShowHierarchicalResults(commandEntries) + return + end + wipe(collapsedNodes) local calculatorData = (not quickFilter) and self:EvaluateCalculatorExpression(text) or nil - local needsHeavy = not calculatorData and ( + local calculatorLauncher = (not quickFilter and not calculatorData) + and self:GetCalculatorLauncherMatch(text) or nil + local needsHeavy = not calculatorData and not calculatorLauncher and ( (ns.Database and ns.Database.QueryNeedsHeavySearchData and ns.Database:QueryNeedsHeavySearchData(text)) or self:QuickFilterNeedsHeavyData(quickFilter) @@ -9547,7 +9801,7 @@ function UI:OnSearchTextChanged(text, force) end local _perfT0 = ns.PERF and debugprofilestop() or 0 local results - if calculatorData then + if calculatorData or calculatorLauncher then results = SCRATCH.calculatorResults wipe(results) else @@ -9645,7 +9899,7 @@ function UI:OnSearchTextChanged(text, force) -- Map Search: search static locations and dungeon entrances, merge into results local mapResults - if not calculatorData and ns.MapSearch and ns.MapSearch.SearchForUI + if not calculatorData and not calculatorLauncher and ns.MapSearch and ns.MapSearch.SearchForUI and ((quickFilter and quickFilter.key == "map") or (not quickFilter and filters and filters.map ~= false)) then mapResults = ns.MapSearch:SearchForUI(text) @@ -9655,6 +9909,8 @@ function UI:OnSearchTextChanged(text, force) local combined = flatCombined if calculatorData then combined[#combined + 1] = { data = calculatorData, score = math.huge } + elseif calculatorLauncher then + combined[#combined + 1] = { data = calculatorLauncher, score = math.huge } end for ri = 1, #results do combined[#combined + 1] = results[ri] end if mapResults then @@ -9680,7 +9936,7 @@ function UI:OnSearchTextChanged(text, force) -- ScoreName so they interleave naturally with mount / toy / setting -- hits ranked off the same query, instead of clumping at a fixed -- band. - if not calculatorData and text ~= "" + if not calculatorData and not calculatorLauncher and text ~= "" and ((quickFilter and quickFilter.key == "achievements") or (not quickFilter and (not filters or filters.achievements ~= false))) then local achHits = self:RequestAchievementSearch(text) @@ -10077,6 +10333,10 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local calcRowH = mfloor(86 * fontScale + 0.5) if calcRowH < 76 then calcRowH = 76 end if entryRowH < calcRowH then entryRowH = calcRowH end + elseif data and data.calculatorLauncher and not entry.isPathNode then + local actionRowH = mfloor(30 * fontScale + 0.5) + if actionRowH < 28 then actionRowH = 28 end + if entryRowH < actionRowH then entryRowH = actionRowH end end local rowContentTop = yOffset resultRow:SetSize(resultsFrame:GetWidth() - padL * 2 - scrollInset, entryRowH) @@ -10753,6 +11013,20 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) iconSet = true + elseif data and data.calculatorLauncher and not entry.isPathNode then + resultRow.text:SetText("") + resultRow.amountText:Hide() + if resultRow.pathSubtext then resultRow.pathSubtext:Hide() end + if resultRow.flatCatIcon then resultRow.flatCatIcon:Hide() end + SetRowIcon(resultRow, "hidden", nil, rowIconSize) + + resultRow.calcActionBar:ClearAllPoints() + resultRow.calcActionBar:SetPoint("TOPLEFT", resultRow, "TOPLEFT", 4, -3) + resultRow.calcActionBar:SetPoint("BOTTOMRIGHT", resultRow, "BOTTOMRIGHT", -4, 3) + resultRow.calcActionBar:Show() + + 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 or data.outfitID or data.heirloomItemID or data.transmogSetID or (data.spellID and data.category == "Ability") or (data.spellID and data.category == "Talent") or (data.encounterID and data.category == "Boss") or (data.macroIndex and data.category == "Macro") or (data.bagID and data.category == "Bag")) then local iconFileID = data.icon @@ -11491,7 +11765,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- Re-apply flat anchoring last so layout is consistent across -- all categories and the path subtext is bounded by the rep bar -- when one is shown (so it stays out of the bar's horizontal area). - if entry.isFlat and not (data and data.calculatorResult) then + if entry.isFlat and not (data and (data.calculatorResult or data.calculatorLauncher)) then local catShown = resultRow.flatCatIcon and resultRow.flatCatIcon:IsShown() local d = data local mainIconOnRight = d and (d.mountID or d.toyItemID or d.petID @@ -12205,7 +12479,7 @@ end function UI:MoveSelection(delta) -- CountVisibleResults walks the button pool and trusts each row's -- :IsShown(), but child rows of a hidden resultsFrame still report - -- shown — so a leftover row from a prior search would let Ctrl+J + -- shown — so a leftover row from a prior search would let Alt+J -- yank focus into nothing on an empty bar. Gate on the frame. if not resultsFrame or not resultsFrame:IsShown() then return end local visibleCount = self:CountVisibleResults() @@ -12353,6 +12627,12 @@ function UI:ActivateResultRow(resultRow, source) if resultRow.data.quickFilterDef then return self:ApplyQuickFilter(resultRow.data.quickFilterDef, "") end + if resultRow.data.searchCommand then + return self:RunSearchBarCommand("/" .. resultRow.data.searchCommand) + end + if resultRow.data.calculatorLauncher then + return self:OpenCalculator("") + end if ActivateSettingResult(resultRow.data) then return true end if resultRow.data.calculatorResult then self:ArmCalculatorResultFromRow(resultRow, source or "click") @@ -13452,6 +13732,16 @@ function UI:SelectResult(data, forceGuide) return end + if data.searchCommand then + self:RunSearchBarCommand("/" .. data.searchCommand) + return + end + + if data.calculatorLauncher then + self:OpenCalculator("") + return + end + if data.calculatorResult then self:ArmCalculatorResultForData(data) return @@ -15383,6 +15673,9 @@ function UI:UpdateSmartShow() if searchFrame.cancelSmartShowTimer then searchFrame.cancelSmartShowTimer() end UIFrameFadeRemoveFrame(searchFrame) searchFrame.setSmartShowVisible(true) + if EasyFind.db.autoHide then + return + end if EasyFind.db.visible ~= false and not inCombat then local alpha = searchFrame.getEffectiveAlpha and searchFrame.getEffectiveAlpha() or 1.0 searchFrame:SetAlpha(alpha) @@ -15400,6 +15693,23 @@ function UI:ResetPosition() end end +function UI:ResetPositionAndSize() + if EasyFind and EasyFind.db then + EasyFind.db.uiSearchPosition = nil + EasyFind.db.uiSearchScale = 1.0 + EasyFind.db.uiSearchWidth = 1.54 + EasyFind.db.uiSearchBarHeight = ns.SEARCHBAR_HEIGHT or 30 + EasyFind.db.uiResultsScale = 1.0 + EasyFind.db.uiResultsWidth = 350 + EasyFind.db.uiResultsHeight = 280 + end + self:ResetPosition() + self:UpdateScale() + self:UpdateWidth() + self:UpdateSearchBarHeight() + self:RefreshResults() +end + -- WHAT'S NEW POPUP -- Shown once per version update for returning users. function UI:ShowWhatsNew(version) @@ -15777,23 +16087,6 @@ function UI:ShowFirstTimeSetup() smartDesc:SetJustifyH("LEFT") smartDesc:SetText("|cff999999Bar hides when your mouse moves away and reappears when you hover near it.|r") - -- Fade While Moving checkbox (default checked - staticOpacity defaults to false, meaning fade IS active) - local fadeCheckbox = CreateFrame("CheckButton", nil, panel, "InterfaceOptionsCheckButtonTemplate") - fadeCheckbox:SetPoint("TOPLEFT", smartShowCheckbox, "TOPLEFT", 0, -(26 + smartDesc:GetStringHeight() + 8)) - fadeCheckbox.Text:SetText("|cffFFD100Fade While Moving|r") - fadeCheckbox:SetChecked(true) - fadeCheckbox:SetScript("OnClick", function(self) - -- Update live so the user can see the effect immediately - EasyFind.db.staticOpacity = not self:GetChecked() - end) - - local fadeDesc = fadeCheckbox:CreateFontString(nil, "OVERLAY") - fadeDesc:SetFontObject(fadeCheckbox.Text:GetFontObject()) - fadeDesc:SetPoint("TOPLEFT", fadeCheckbox.Text, "BOTTOMLEFT", 0, -2) - fadeDesc:SetWidth(panelWidth - 60) - fadeDesc:SetJustifyH("LEFT") - fadeDesc:SetText("|cff999999Reduces bar opacity while you're moving.|r") - -- Keybind rows. Each row = [label] [keybind button] [recommended hint]. -- Click the button to capture a key, right-click to clear, Esc to cancel — -- mirrors the keybind UI in /ef Options > Shortcuts. @@ -15822,7 +16115,7 @@ function UI:ShowFirstTimeSetup() -- so it reads as a labeled subsection rather than two loose buttons. local keybindHeader = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight") keybindHeader:SetText("Keybindings") - keybindHeader:SetPoint("TOP", fadeDesc, "BOTTOM", 0, -14) + keybindHeader:SetPoint("TOP", smartDesc, "BOTTOM", 0, -16) local keybindDivider = panel:CreateTexture(nil, "ARTWORK") keybindDivider:SetColorTexture(1, 1, 1, 0.18) @@ -15955,7 +16248,6 @@ function UI:ShowFirstTimeSetup() -- Apply preferences from setup checkboxes EasyFind.db.smartShow = smartShowCheckbox:GetChecked() - EasyFind.db.staticOpacity = not fadeCheckbox:GetChecked() UI:UpdateSmartShow() -- Record current version so What's New won't fire on next login diff --git a/Utils.lua b/Utils.lua index be2d7ce..eb9d2a7 100644 --- a/Utils.lua +++ b/Utils.lua @@ -57,6 +57,7 @@ end --- Usage: Utils.SafeCallMethod(frame, "SetPropagateKeyboardInput", false) function Utils.SafeCallMethod(obj, method, ...) if InCombatLockdown() then return false end + if not obj then return false end local fn = obj[method] if not fn then return false end return pcall(fn, obj, ...) diff --git a/Wizard.lua b/Wizard.lua index 238669d..daf2b52 100644 --- a/Wizard.lua +++ b/Wizard.lua @@ -529,19 +529,17 @@ local function BuildPage3(parent) local title = HeaderText(p, "Pick your hotkeys", "GameFontNormalLarge") title:SetPoint("TOP", p, "TOP", 0, -36) - local sub = BodyText(p, - "These are the key combos that open each search bar.\n" .. - "Ctrl+Space and Ctrl+M are clean defaults, but anything works.") + local sub = BodyText(p, "") sub:SetPoint("TOP", title, "BOTTOM", 0, -10) sub:SetWidth(WIZ_W - 100) local uiKb = CreateKbWidget(p, TOGGLE_ACTION, "Search Bar") - local mapKb = CreateKbWidget(p, MAP_ACTION, "Map Search") + local mapKb = CreateKbWidget(p, MAP_ACTION, "Map Search Tab") kbWidgets = { uiKb, mapKb } -- Stacked layout: search bar on top, map search below. uiKb.label:SetPoint("RIGHT", uiKb.btn, "LEFT", -14, 0) - uiKb.btn:SetPoint("TOP", sub, "BOTTOM", 40, -28) + uiKb.btn:SetPoint("TOP", sub, "BOTTOM", 40, -38) mapKb.label:SetPoint("RIGHT", mapKb.btn, "LEFT", -14, 0) mapKb.btn:SetPoint("TOP", uiKb.btn, "BOTTOM", 0, -16) @@ -556,16 +554,6 @@ local function BuildPage3(parent) p.OnEnter = function() if kbWaitingFor then StopKeybindCapture() end - -- First-time default: if the player hasn't bound the map - -- search yet, suggest CTRL-M. Doesn't override existing - -- bindings or other actions already on that combo. - if not GetBindingKey(MAP_ACTION) then - local ownerOfCtrlM = GetBindingAction and GetBindingAction("CTRL-M") - if not ownerOfCtrlM or ownerOfCtrlM == "" then - SetBinding("CTRL-M", MAP_ACTION) - SaveBindings(GetCurrentBindingSet()) - end - end for i = 1, #kbWidgets do RefreshKbWidget(kbWidgets[i]) end end @@ -628,7 +616,7 @@ local function BuildPage4(parent) local r3 = BulletRow(p, "Reset its spot", - "Type :reset in the search bar to send it back to the top of the screen, or use Reset Positions in Options.") + "Type /reset in the search bar to send it back to the top of the screen, or use Reset Positions in Options.") r3:SetPoint("TOPLEFT", r2, "BOTTOMLEFT", 0, -10) local r4 = BulletRow(p, @@ -795,7 +783,6 @@ local function CreateFrameOnce() BuildPage1(pageHost), BuildPage2(pageHost), BuildPage3(pageHost), - BuildPage4(pageHost), } -- Footer banner: a self-contained rounded panel floating inside diff --git a/textures/CalcCardBorder.tga b/textures/CalcCardBorder.tga new file mode 100644 index 0000000000000000000000000000000000000000..6285ca031c033f62f3fdd4a642faba795ed98be9 GIT binary patch literal 65554 zcmeH}-Er$U423;gflJWiKDvaIkP=dYOHcL!|g_oOO(aQd>05vH$r&iRA+8s9NC!F~L z@8PoFDbV9{YDrDk91BOB?+owZvSk$Lu`D&F)@z9co~4D~tLawTIFILllUFsMHq@xV zf6|llUL6NK@6b6v@s96L@PmAk2GoukQp=iAd%l0Rz76&)@S1fP6!ZC+P*ZAKvw}e~ zc}5(3V4qsfUod`r(ixs{&$aGBjj44sG9jBK&)vmM(J$}@zU01I`@6o>oZ3h0FtCJP zT-O`p?F?UHFReLVS27?AZyh>wYWV3F$20qcm>^rE`pnJF@gf)=|6OP3S7`<9zkMYq(}j$keRIgFoxZUff#t{d+xcUypl? z{qMu4*zY#>$<~a_Ew6hUL|gl9uz70#YPLl!$kwdK1Mkq-z^zs7-!|iWaT$LD$o}2& zzvB7c_j|5)gEpRd3w+3WvM)t)zXxSJ4eQQ9*W(5y)vojvVf8QFAv01@` z|2=p^U;Ov20U4Wl_8b%ceY0n7d;S&&){4LQGe+e5)j;{bU$$fNR{X`^e(z(;*ccN- znzLPgghH%H!yzxc8$*J zo%U;pd+`_lSof{Z*jPi1>I}`pRuur7}p(!IV%5$waE}S;xGPI?_@2LFG9pmCJ{;}>`pRtj-HfFfH z@;`Tsi@*3=y^k$pBSQ_$d`IPf?id$;@sD-i`izatwK2opmH)Y8T>Qn~>V0e(8yRX~ z<~u6?bH}*&i+`;9)@N*Fu8kS)uKdp(w|XC2#zux3nE8&%|J*Sy{^B3&zV#U!nQLQ)yDR^5$GG^5zt#KL zGBz^Qz|414{^yQy@fZJC_pQ&^$XpvU++F#fJI2Ld{H@-{ma&nc24=pa@;`Tsi@*5C zx^I2PM&{a>;qJ=++%Ycx;&1gnwv3GoH8ArXmH)Y8T>Qn~*Zr&OFg7yR#te7&`A_!6 z@jr|I?&F_~%?cjqW-T5vGqst%H`o2N^WTd`JI(kL(-A zpN}VaW_|X2ce3?H_PlTJ2G6&#J?oJ5Ze(lLH z>|XBM+4$SwjeUEkc3WCZWm#MRFlJ7Qfz9OrnUS4Y!Nb`6Z7|n=`-%SXf$zueqj}?* zJfam@k(n7;lIbIBeAvggRx$9HevfE9=iJDi_{eg~9vwe+rWlx};}q?ud{blJf!~z1 z>>GaXO)>C0Jx$lS+a=iB8Ohum`zN@)SNmEm$fCw*PA(nx@ODM#44-okt@XH`WIz_) zI&_xMlIun*7--;2?58z{b*1LiK3a!?C3XA2`Z3;kh70@%Uvh7){h!9vIvSaf%`-G= zuup;4tivFGkDhUkOnXpMYFo2{K{9!^9<|>dJKrZB_+GIvoeQ<2hSaiV%z3_lHg~_@ z5&O4ro@L1RuE05u=Y5k`HJ~=ssK9^Hlk=$kVI|J9@ccbowu}NjmZipP;$Dlt6V7;n t_i)+o6zK6ewWOvst=B%*;N0)<7G7?CueM=IN~0#_=G2OsQM<#E{s*3a>hk~q literal 0 HcmV?d00001 diff --git a/textures/CalcCardFill.tga b/textures/CalcCardFill.tga new file mode 100644 index 0000000000000000000000000000000000000000..51fbbe42acf2c8b6662cceda25245ee34de8b0b5 GIT binary patch literal 65554 zcmeI$+i~JR5QSmYMnDL9+($wo1VV5K%773Q%7ow$CIp9obOv{(iS5NOo@RCRH& z5YyjjWSgq`Q}wHNvWoSX zK-SA`Y11{&!cP19P-PWs8G)?R+St}>nFa5qQE^Qd!i$!zZT{{#@GO%1i$WiH8{0dB zS6>Tiy=PLf)*+Cz4QKL-IXLQl_{>il`zn)dLK&$QFGXux6+h+Fz0cSa925KG8|9Aty z!tXjuXF7ETQsMow{htEPbV~mIK9RLefm`1d@6Kj1c;fU|GHbxp&)fU{5i18&0!IC~c>ra)5L{yvZuQ-JJa=1$iL zAbHywBgaS;dMWkk#fed*A(0`XL&2{v7fi$<;n+ehGW@Iea^3El_}6_+f#GCcHC!~ z?T6mAGrN}Q+@H0fHOp$=SP&gR}+=e(}g z`y>@>9Rf+)a3*sye8#chfkEApJ literal 0 HcmV?d00001 diff --git a/textures/CalcHalfLeft.tga b/textures/CalcHalfLeft.tga new file mode 100644 index 0000000000000000000000000000000000000000..543bd05adc7813d39ff469926102ee03bf94d98c GIT binary patch literal 65554 zcmeI%+ijyj00mIA6>JH7?x#y}2`(We(1A;EDU^^BC?O?eXI8;Nk>dc~NZ@-cjZADH z+;a!~YMNioc6Zs#1)BmLL&UGH<0x4X`o{@W6#-)c4+re>UBhz~}z!ce-vl0@MEMZ#k~} zIo;Q!)Bd}x=+aL5ckX9Shw=RPx}V+ukQqNdbv^$N@d~*Bwf}Q&{P^Ux{~=x>7og3J zE_o3+QivAlqSFiCBZwBze{}tR@5!!TdIAIp5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FijK@D`a`=uw~_7u-h*=s&XdYv}H}WFb-Dm>62-t3Z2Q zvXCgyl+Z(BaGC!J)c(&bVg+jdhuHLj*Z#c*8s nd!4R*?ocStWel05yGp&bvqMk7iHWuCGRsv-nl=a|$)`IGEb~h?8#&!2m-Obss1bqkw_zW(whlBBQH(nRV;tB2J4lTm zUVB&Ecx3S^f$|)f|Ies8Y&U_x5E<*|K!V63Rs!=ipgLqXi9r2(K$6H}RsvJ(U!DQg zu-!xgyYn1K6j{_t;HdwVM!wxi|MN9{mbjmN_cfQmK3806X9DG2kS(vkm^ZrQA)x=c zr$KoaY$HH`009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2xJm?&lFbHg}}HNF_1|>|1))anRQqW8A&9tPn1>Er9i(NGLl4Kikw}NtYR(&j{3hu zNi?$4|3uyX9CbSP-unO9CCj&0{m;_sMb7fh_eK8~E?EZ3JD;-dUn)y(IX^z;_rB+U zu31q(_uc=wdOt?oOV8`iTnahUmUX|^|D4T_)Q2m*FYAD0!e zzIH6H+V+!|dw%Vm?zLX8jc;EeUxL#2A*%jA?pkjj8Ox`)-NaK|db!`9(f7;T*sey7 lE1x3fF=`ie>De Date: Tue, 12 May 2026 00:32:31 -0400 Subject: [PATCH 090/103] Fix map alias handoff to map tab --- MapTab.lua | 67 ++++++++++++++++++++++++++++-------------------------- UI.lua | 9 +++++++- 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/MapTab.lua b/MapTab.lua index 5df719c..0baf50f 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -1448,6 +1448,10 @@ function MapTab:RunSearch(text) local scrollFrame = panel.scrollFrame local MapSearch = ns.MapSearch local pinned = BuildPinnedSection() + local aliasMatches + if ns.Aliases and text ~= "" then + aliasMatches = ns.Aliases:GetMatches(text:lower()) + end -- Preserve scroll when the query is unchanged (refresh path: pin -- toggles, group collapse, filter changes). Reset to the top on a @@ -1470,7 +1474,7 @@ function MapTab:RunSearch(text) sessionCollapsedQuery = text end - if #text < 2 then + if #text < 2 and not aliasMatches then local showRecent = EasyFind.db.mapTabShowRecent local recentList = showRecent and EasyFind.db.mapTabRecentSearches local limit = MapTab.GetRecentLimit and MapTab.GetRecentLimit() or 3 @@ -1525,11 +1529,39 @@ function MapTab:RunSearch(text) -- results are filtered out of local via isLocal=true so they -- don't pollute "This Zone" on a world/continent map. local localFiltered = FilterAndDedupe(localRaw, seen, true) - local localEntries = GroupBySharedParent(localFiltered) + local localEntries + local globalRaw + if multiToken then + globalRaw = BuildMultiTokenResults(tokens, true) + else + globalRaw = MapSearch:BuildResults(text, true, true) + end + if myGen ~= lastQueryGen then return end + local globalFiltered = FilterAndDedupe(globalRaw, seen, false) + + if aliasMatches then + local viewedMapID = WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID() + for i = #aliasMatches, 1, -1 do + local data = aliasMatches[i] and aliasMatches[i].data + if IsMapTabAliasData(data) then + local key = ResultDedupeKey(data) + if not seen[key] then + seen[key] = true + if ResultIsOnViewedMap(data, viewedMapID) then + tinsert(localFiltered, 1, data) + else + tinsert(globalFiltered, 1, data) + end + end + end + end + end + + localEntries = GroupBySharedParent(localFiltered) -- A continent/world-type zone match (e.g. "Eastern Kingdoms" while -- viewing EK) is dropped from local by EXCLUDE_FROM_LOCAL_MAPTYPES, -- so its "This Zone" group ends up with synthesized navigateData - -- and parentMatched stays false — auto-expand never fires. Promote + -- and parentMatched stays false - auto-expand never fires. Promote -- the synthesized header back to the real result so the renderer's -- parentMatched check sees it. for i = 1, #localRaw do @@ -1544,35 +1576,6 @@ function MapTab:RunSearch(text) end end end - local globalRaw - if multiToken then - globalRaw = BuildMultiTokenResults(tokens, true) - else - globalRaw = MapSearch:BuildResults(text, true, true) - end - if myGen ~= lastQueryGen then return end - local globalFiltered = FilterAndDedupe(globalRaw, seen, false) - - if ns.Aliases then - local aliasMatches = ns.Aliases:GetMatches(text:lower()) - if aliasMatches then - local viewedMapID = WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID() - for i = #aliasMatches, 1, -1 do - local data = aliasMatches[i] and aliasMatches[i].data - if IsMapTabAliasData(data) then - local key = ResultDedupeKey(data) - if not seen[key] then - seen[key] = true - if ResultIsOnViewedMap(data, viewedMapID) then - tinsert(localFiltered, 1, data) - else - tinsert(globalFiltered, 1, data) - end - end - end - end - end - end -- Pull duplicate-named zones out of globalFiltered into version groups. -- Names matching 2+ entries collapse into one header that lists every diff --git a/UI.lua b/UI.lua index bb8802b..31ec9dd 100644 --- a/UI.lua +++ b/UI.lua @@ -9822,7 +9822,14 @@ function UI:OnSearchTextChanged(text, force) for i = #aliasMatches, 1, -1 do local hit = aliasMatches[i] if not seen[hit.data] then - tinsert(results, 1, { data = hit.data, score = math.huge, isAlias = true }) + local data = hit.data + if data and data.mapSearchResult then + local wrapped = {} + for k, v in pairs(data) do wrapped[k] = v end + wrapped.query = (hit.alias and hit.alias.text) or text + data = wrapped + end + tinsert(results, 1, { data = data, score = math.huge, isAlias = true }) seen[hit.data] = true end end From 0f2287ba4bfd8d4496b70e741b6580a7d6ef46b8 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 12 May 2026 00:59:55 -0400 Subject: [PATCH 091/103] Reset EasyFind settings for 2.0 migration --- Core.lua | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++-- Options.lua | 2 +- Wizard.lua | 5 +- 3 files changed, 162 insertions(+), 8 deletions(-) diff --git a/Core.lua b/Core.lua index c669d5e..699efc3 100644 --- a/Core.lua +++ b/Core.lua @@ -23,7 +23,10 @@ EasyFind.db = {} -- SavedVariables version. Increment when changing DB schema. -- Each migration runs once: if saved dbVersion < DB_VERSION, run all steps in order. -local DB_VERSION = 15 +local DB_VERSION = 17 +local REVAMPED_TUTORIAL_VERSION = "2.0.0" +local FRESH_SETTINGS_VERSION = "2.0.0" +ns.REVAMPED_TUTORIAL_VERSION = REVAMPED_TUTORIAL_VERSION -- SavedVariables defaults - new keys are auto-merged for existing users local DB_DEFAULTS = { @@ -152,6 +155,136 @@ local DB_DEFAULTS = { uiSearchHistoryLimit = 500, -- Bash HISTSIZE default } +local function RequireRevampedTutorial(db) + if db.revampedTutorialVersion ~= REVAMPED_TUTORIAL_VERSION then + db.tutorialDone = false + db.lastSeenVersion = REVAMPED_TUTORIAL_VERSION + end +end + +local function CloneDefaultValue(value) + if type(value) ~= "table" then return value end + + local copy = {} + for k, v in pairs(value) do + copy[k] = CloneDefaultValue(v) + end + return copy +end + +local FRESH_SETTINGS_KEYS = { + "visible", + "enableMapSearch", + "iconScale", + "nativePinScale", + "uiSearchScale", + "uiSearchWidth", + "uiResultsScale", + "uiResultsWidth", + "uiSearchBarHeight", + "fontSize", + "uiSearchPosition", + "localMapDirectOpen", + "globalMapDirectOpen", + "autoHide", + "smartShow", + "lockPosition", + "resultsTheme", + "font", + "indicatorStyle", + "indicatorColor", + "uiResultsHeight", + "showTruncationMessage", + "hardResultsCap", + "pinnedUIItems", + "pinnedUIItemsPerChar", + "pinnedMapItems", + "mapPinsCollapsed", + "showLoginMessage", + "showAliasMessages", + "blinkingPins", + "mapPinHighlight", + "autoPinClear", + "autoTrackPins", + "uiResultsAbove", + "showResultShortcutHints", + "showMinimapButton", + "minimapButtonAngle", + "globalSearchFilters", + "localSearchFilters", + "mapTabFilters", + "mapTabShowRecent", + "mapTabRecentCount", + "mapTabAutoExpand", + "alwaysShowRares", + "uiSearchFilters", + "lootSpecs", + "lootSearchSlots", + "lootSearchStats", + "lootUpgradesOnly", + "lootDifficulty", + "hideTooltips", + "currencyFilterMode", + "reputationFilterMode", + "showLegacyReputations", + "abilityHidePassives", + "appearanceSetClass", + "appearanceSetCollected", + "appearanceSetNotCollected", + "appearanceSetPvE", + "appearanceSetPvP", + "uiMapSearchLocal", +} + +local RETIRED_SETTINGS_KEYS = { + "enableUISearch", + "mapSearchScale", + "mapSearchWidth", + "mapResultsScale", + "mapResultsWidth", + "mapFontSize", + "mapResultsHeight", + "mapResultsAbove", + "mapSearchPosition", + "globalSearchPosition", + "mapSearchPositionMax", + "globalSearchPositionMax", + "mapSearchYOffset", + "hideSearchBarsMaximized", + "directOpen", + "mapSmartShow", + "pinsCollapsed", + "arrivalDistance", + "minimapArrowGlow", + "glowOnlyEasyFind", + "minimapGuideCircle", + "circleOnlyEasyFind", + "guideCircleScale", + "minimapPinGlow", + "panelOpacity", + "searchBarOpacity", + "staticOpacity", + "suggestedKeybindsApplied", + "optionsPosition", + "lootFilter", +} + +local function ApplyFreshSettingsFor2(db) + if db.freshSettingsVersion == FRESH_SETTINGS_VERSION then return end + + for i = 1, #FRESH_SETTINGS_KEYS do + local key = FRESH_SETTINGS_KEYS[i] + db[key] = CloneDefaultValue(DB_DEFAULTS[key]) + end + + for i = 1, #RETIRED_SETTINGS_KEYS do + db[RETIRED_SETTINGS_KEYS[i]] = nil + end + + db.freshSettingsVersion = FRESH_SETTINGS_VERSION + RequireRevampedTutorial(db) +end + local DB_MIGRATIONS = { -- [1] = Consolidate ad-hoc migrations (maxResults rename, uiResultsWidth reset) [1] = function(db) @@ -222,8 +355,7 @@ local DB_MIGRATIONS = { -- [11] = 2.0.0 is a full onboarding reset. Existing users should -- see the tutorial once instead of the legacy What's New popup. [11] = function(db) - db.tutorialDone = false - db.lastSeenVersion = "2.0.0" + RequireRevampedTutorial(db) end, -- [12] = Search window background is always solid now. [12] = function(db) @@ -233,8 +365,7 @@ local DB_MIGRATIONS = { -- once for existing account-wide SavedVariables, and mark 2.0.0 -- as seen so the legacy What's New popup cannot compete with it. [13] = function(db) - db.tutorialDone = false - db.lastSeenVersion = "2.0.0" + RequireRevampedTutorial(db) end, -- [14] = Finalize search opacity and map-tab defaults for 2.0. -- Search opacity is no longer user-controlled, and flight paths start @@ -251,6 +382,24 @@ local DB_MIGRATIONS = { [15] = function(db) db.showLoginMessage = false end, + -- [16] = Backfill the "already acknowledged revamped tutorial" + -- marker for accounts that completed the 2.0 tutorial on a dev + -- build before the marker existed. Accounts still pending the + -- tutorial keep tutorialDone=false and will see it once. + [16] = function(db) + if db.tutorialDone == true + and db.lastSeenVersion == REVAMPED_TUTORIAL_VERSION + and db.revampedTutorialVersion ~= REVAMPED_TUTORIAL_VERSION then + db.revampedTutorialVersion = REVAMPED_TUTORIAL_VERSION + end + end, + -- [17] = 2.0 is a release-scale redesign, not a normal incremental + -- update. Reset the released-era layout/options/filter/pin state to the + -- new defaults and remove retired settings so old SavedVariables cannot + -- keep stale search bars, opacity controls, or disabled categories. + [17] = function(db) + ApplyFreshSettingsFor2(db) + end, } -- Fields that are runtime-only and must not persist in SavedVariables @@ -625,7 +774,9 @@ local function OnPlayerLogin() local lastSeen = EasyFind.db.lastSeenVersion if currentVersion and currentVersion ~= lastSeen then if currentVersion == "2.0.0" then - EasyFind.db.tutorialDone = false + if EasyFind.db.revampedTutorialVersion ~= REVAMPED_TUTORIAL_VERSION then + EasyFind.db.tutorialDone = false + end elseif lastSeen ~= nil or EasyFind.db.setupComplete then SafeAfter(1.5, function() if ns.UI then ns.UI:ShowWhatsNew(currentVersion) end diff --git a/Options.lua b/Options.lua index e424af8..b95703a 100644 --- a/Options.lua +++ b/Options.lua @@ -18,7 +18,7 @@ local blizzardRegistered = false local NIL = {} local DEFAULT_UI_FILTERS = { - ui = true, achievements = true, statistics = false, currencies = true, + achievements = true, statistics = false, currencies = true, reputations = true, collections = true, gameOptions = true, addonOptions = true, mounts = true, toys = true, pets = true, outfits = true, heirlooms = true, loot = true, appearanceSets = true, diff --git a/Wizard.lua b/Wizard.lua index daf2b52..4430613 100644 --- a/Wizard.lua +++ b/Wizard.lua @@ -163,8 +163,11 @@ local function FinishWizard(openBar) if not frame then return end EasyFind.db.tutorialDone = true EasyFind.db.setupComplete = true - if EasyFind.db.lastSeenVersion == nil then + if ns.version then EasyFind.db.lastSeenVersion = ns.version + if ns.version == (ns.REVAMPED_TUTORIAL_VERSION or "2.0.0") then + EasyFind.db.revampedTutorialVersion = ns.REVAMPED_TUTORIAL_VERSION or "2.0.0" + end end SafeCallMethod(frame, "EnableKeyboard", false) frame:Hide() From 750444cd1ff2fa81db124bc09978376bf7a51b9d Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 12 May 2026 01:20:18 -0400 Subject: [PATCH 092/103] Simplify 2.0 settings reset migration Replace the 60-entry FRESH_SETTINGS_KEYS allowlist with a 6-entry PRESERVED_KEYS denylist and iterate DB_DEFAULTS, so the reset stays correct automatically when new defaults are added. Drop the redundant FRESH_SETTINGS_VERSION constant and freshSettingsVersion DB field since the migration framework already gates run-once. Flatten the nested version check in OnPlayerLogin and use the REVAMPED_TUTORIAL_VERSION constant instead of the "2.0.0" literal. --- Core.lua | 93 +++++++++++------------------------------------------- Wizard.lua | 4 +-- 2 files changed, 21 insertions(+), 76 deletions(-) diff --git a/Core.lua b/Core.lua index 699efc3..74b0999 100644 --- a/Core.lua +++ b/Core.lua @@ -25,7 +25,6 @@ EasyFind.db = {} -- Each migration runs once: if saved dbVersion < DB_VERSION, run all steps in order. local DB_VERSION = 17 local REVAMPED_TUTORIAL_VERSION = "2.0.0" -local FRESH_SETTINGS_VERSION = "2.0.0" ns.REVAMPED_TUTORIAL_VERSION = REVAMPED_TUTORIAL_VERSION -- SavedVariables defaults - new keys are auto-merged for existing users @@ -172,68 +171,16 @@ local function CloneDefaultValue(value) return copy end -local FRESH_SETTINGS_KEYS = { - "visible", - "enableMapSearch", - "iconScale", - "nativePinScale", - "uiSearchScale", - "uiSearchWidth", - "uiResultsScale", - "uiResultsWidth", - "uiSearchBarHeight", - "fontSize", - "uiSearchPosition", - "localMapDirectOpen", - "globalMapDirectOpen", - "autoHide", - "smartShow", - "lockPosition", - "resultsTheme", - "font", - "indicatorStyle", - "indicatorColor", - "uiResultsHeight", - "showTruncationMessage", - "hardResultsCap", - "pinnedUIItems", - "pinnedUIItemsPerChar", - "pinnedMapItems", - "mapPinsCollapsed", - "showLoginMessage", - "showAliasMessages", - "blinkingPins", - "mapPinHighlight", - "autoPinClear", - "autoTrackPins", - "uiResultsAbove", - "showResultShortcutHints", - "showMinimapButton", - "minimapButtonAngle", - "globalSearchFilters", - "localSearchFilters", - "mapTabFilters", - "mapTabShowRecent", - "mapTabRecentCount", - "mapTabAutoExpand", - "alwaysShowRares", - "uiSearchFilters", - "lootSpecs", - "lootSearchSlots", - "lootSearchStats", - "lootUpgradesOnly", - "lootDifficulty", - "hideTooltips", - "currencyFilterMode", - "reputationFilterMode", - "showLegacyReputations", - "abilityHidePassives", - "appearanceSetClass", - "appearanceSetCollected", - "appearanceSetNotCollected", - "appearanceSetPvE", - "appearanceSetPvP", - "uiMapSearchLocal", +-- Keys preserved across the 2.0 settings reset: schema version, +-- onboarding state, and user-authored data (history, aliases, recents). +-- Everything else in DB_DEFAULTS gets restored to its default value. +local PRESERVED_KEYS = { + dbVersion = true, + tutorialDone = true, + mapTabRecentSearches = true, + aliases = true, + uiSearchHistory = true, + uiSearchHistoryLimit = true, } local RETIRED_SETTINGS_KEYS = { @@ -270,18 +217,16 @@ local RETIRED_SETTINGS_KEYS = { } local function ApplyFreshSettingsFor2(db) - if db.freshSettingsVersion == FRESH_SETTINGS_VERSION then return end - - for i = 1, #FRESH_SETTINGS_KEYS do - local key = FRESH_SETTINGS_KEYS[i] - db[key] = CloneDefaultValue(DB_DEFAULTS[key]) + for key, defaultValue in pairs(DB_DEFAULTS) do + if not PRESERVED_KEYS[key] then + db[key] = CloneDefaultValue(defaultValue) + end end for i = 1, #RETIRED_SETTINGS_KEYS do db[RETIRED_SETTINGS_KEYS[i]] = nil end - db.freshSettingsVersion = FRESH_SETTINGS_VERSION RequireRevampedTutorial(db) end @@ -773,11 +718,11 @@ local function OnPlayerLogin() local currentVersion = ns.version local lastSeen = EasyFind.db.lastSeenVersion if currentVersion and currentVersion ~= lastSeen then - if currentVersion == "2.0.0" then - if EasyFind.db.revampedTutorialVersion ~= REVAMPED_TUTORIAL_VERSION then - EasyFind.db.tutorialDone = false - end - elseif lastSeen ~= nil or EasyFind.db.setupComplete then + if currentVersion == REVAMPED_TUTORIAL_VERSION + and EasyFind.db.revampedTutorialVersion ~= REVAMPED_TUTORIAL_VERSION then + EasyFind.db.tutorialDone = false + elseif currentVersion ~= REVAMPED_TUTORIAL_VERSION + and (lastSeen ~= nil or EasyFind.db.setupComplete) then SafeAfter(1.5, function() if ns.UI then ns.UI:ShowWhatsNew(currentVersion) end end) diff --git a/Wizard.lua b/Wizard.lua index 4430613..4de8dc7 100644 --- a/Wizard.lua +++ b/Wizard.lua @@ -165,8 +165,8 @@ local function FinishWizard(openBar) EasyFind.db.setupComplete = true if ns.version then EasyFind.db.lastSeenVersion = ns.version - if ns.version == (ns.REVAMPED_TUTORIAL_VERSION or "2.0.0") then - EasyFind.db.revampedTutorialVersion = ns.REVAMPED_TUTORIAL_VERSION or "2.0.0" + if ns.version == ns.REVAMPED_TUTORIAL_VERSION then + EasyFind.db.revampedTutorialVersion = ns.REVAMPED_TUTORIAL_VERSION end end SafeCallMethod(frame, "EnableKeyboard", false) From 15385b5cd9fd722451a18e2f0c9df7ee3a38aa3e Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 12 May 2026 01:34:14 -0400 Subject: [PATCH 093/103] Clean up luacheck warnings across the branch Add missing WoW API globals (securecallfunction, IsKeyDown, GetCurrentKeyBoardFocus, GetCVarBool, OpenBackpack/Bag, GetItemSpell, StaticPopup_Visible, IsGraphicsSettingValueSupported, QuestScrollFrame, LibStub, CURRENCY_FILTER_TYPE_*, SETTINGS_*, FACTION_ALLIANCE/HORDE, GameFontHighlightLarge) to read_globals. Move SettingsPanel and TokenFrame to globals so field writes pass. Remove dead code: BuildPage4 and BulletRow in Wizard.lua, ClearBinding in Options.lua, FlagsFor in BlizzOptionsSearch.lua, unused willScroll local in UI.lua. Drop unused imports (sformat, tonumber, tconcat in Options.lua; tinsert in Rescaler.lua). Branch luacheck now reports 0 warnings (was 47). --- .luacheckrc | 24 ++++++++++--- BlizzOptionsSearch.lua | 14 -------- Options.lua | 10 ++---- Rescaler.lua | 1 - UI.lua | 1 - Wizard.lua | 77 ------------------------------------------ 6 files changed, 22 insertions(+), 105 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 654218d..0f4cd57 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -20,6 +20,8 @@ globals = { "BINDING_NAME_EASYFIND_MAP_FOCUS", "EncounterJournal", "TransmogFrame", + "SettingsPanel", + "TokenFrame", } -- WoW API globals (read-only) @@ -55,7 +57,7 @@ read_globals = { "CharacterStatsPane", "PaperDollTitlesPane", "PaperDollEquipmentManagerPane", "CurrencyFrame", "SpellBookFrame", "PlayerSpellsFrame", "CollectionsJournal", - "PVEFrame", "ReputationFrame", "TokenFrame", "TokenFramePopup", + "PVEFrame", "ReputationFrame", "TokenFramePopup", "GroupFinderFrame", "LFGListFrame", "HelpFrame", "ClassTalentFrame", "GameMenuFrame", "MacroFrame", "GuildMicroButton", "StoreMicroButton", "PlayerFrame", "StoreFrame", @@ -125,7 +127,7 @@ read_globals = { "EasyFindDevDB", -- Blizzard settings frames - "SettingsPanel", "InterfaceOptionsFrame", + "InterfaceOptionsFrame", -- Constants, Enums, Mixins "Enum", "Settings", "BackdropTemplateMixin", @@ -135,8 +137,22 @@ read_globals = { -- Font objects "Game15Font_Shadow", "GameFontNormal", "GameFontNormalSmall", - "GameFontHighlight", "GameFontHighlightSmall", "GameFontDisable", - "GameFontDisableSmall", "GameFontNormalLarge", + "GameFontHighlight", "GameFontHighlightSmall", "GameFontHighlightLarge", + "GameFontDisable", "GameFontDisableSmall", "GameFontNormalLarge", + + -- Misc WoW API + "securecallfunction", "GetCVarBool", "GetCurrentKeyBoardFocus", + "IsKeyDown", "OpenBackpack", "OpenBag", "GetItemSpell", + "StaticPopup_Visible", "IsGraphicsSettingValueSupported", + "QuestScrollFrame", "LibStub", + + -- Localization / faction strings + "FACTION_ALLIANCE", "FACTION_HORDE", + "SETTINGS_CONFIRM_DISCARD", "SETTINGS_UNAPPLIED_EXIT", + "SETTINGS_UNAPPLIED_APPLY_AND_EXIT", "SETTINGS_UNAPPLIED_CANCEL", + + -- Currency filter enums + "CURRENCY_FILTER_TYPE_TRANSFERABLE", "CURRENCY_FILTER_TYPE_CHARACTER", } -- WoW callbacks have fixed signatures; unused args are normal diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index 32a97dc..1eb64fb 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -820,20 +820,6 @@ function BlizzOptionsSearch:HasPendingChange(variable) return settObj.pendingValue ~= nil end -local function FlagsFor(setting) - local saveBindings, gxRestart, windowUpdate - if setting.HasCommitFlag and Settings and Settings.CommitFlag then - local function has(flag) - local ok, v = pcall(setting.HasCommitFlag, setting, flag) - return ok and v - end - saveBindings = has(Settings.CommitFlag.SaveBindings) - gxRestart = has(Settings.CommitFlag.GxRestart) - windowUpdate = has(Settings.CommitFlag.UpdateWindow) - end - return saveBindings, gxRestart, windowUpdate -end - -- Apply / revert one specific setting (per-row Apply/Reset buttons). -- PROXY_ANTIALIASING is a "view" setting whose own SetValue closure -- only zeros the OTHER mode's CVar (fxaa or msaa); it assumes the diff --git a/Options.lua b/Options.lua index b95703a..77b94a4 100644 --- a/Options.lua +++ b/Options.lua @@ -4,10 +4,9 @@ local Options = {} ns.Options = Options local Utils = ns.Utils -local sformat = Utils.sformat local mfloor, mmin, mmax = Utils.mfloor, Utils.mmin, Utils.mmax -local tonumber, tostring = Utils.tonumber, Utils.tostring -local tinsert, tconcat = Utils.tinsert, Utils.tconcat +local tostring = Utils.tostring +local tinsert = Utils.tinsert local IsMouseButtonDown = IsMouseButtonDown local OPTIONS_PANEL_ALPHA = 0.9 @@ -166,11 +165,6 @@ local function RefreshMapRuntime() if uiInd then uiInd:SetScale(EasyFind.db.iconScale or 0.8) end end -local function ClearBinding(action) - local old1, old2 = GetBindingKey(action) - if old1 then SetBinding(old1) end - if old2 then SetBinding(old2) end -end local function SyncOptionControls() if not optionsFrame then return end diff --git a/Rescaler.lua b/Rescaler.lua index 4a57ee4..17f94cc 100644 --- a/Rescaler.lua +++ b/Rescaler.lua @@ -5,7 +5,6 @@ ns.Rescaler = Rescaler local Utils = ns.Utils local mmax, mmin, mfloor = Utils.mmax, Utils.mmin, Utils.mfloor -local tinsert = Utils.tinsert local SafeCallMethod = Utils.SafeCallMethod local GOLD_COLOR = ns.GOLD_COLOR diff --git a/UI.lua b/UI.lua index 31ec9dd..a3e4d72 100644 --- a/UI.lua +++ b/UI.lua @@ -10276,7 +10276,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end local maxVisibleHeight = EasyFind.db.uiResultsHeight or 280 - local willScroll = visibleN * rowH > maxVisibleHeight local scrollInset = 0 wipe(SCRATCH.isLastChild) diff --git a/Wizard.lua b/Wizard.lua index 4de8dc7..e9fbc2f 100644 --- a/Wizard.lua +++ b/Wizard.lua @@ -563,83 +563,6 @@ local function BuildPage3(parent) return p end -local function BulletRow(parent, header, body) - local row = CreateFrame("Frame", nil, parent) - row:SetSize(WIZ_W - 110, 1) - - local dot = row:CreateTexture(nil, "OVERLAY") - dot:SetSize(6, 6) - dot:SetTexture(DOT_FILLED) - dot:SetVertexColor(GOLD[1], GOLD[2], GOLD[3], 1) - dot:SetPoint("TOPLEFT", row, "TOPLEFT", 0, -6) - - local h = row:CreateFontString(nil, "OVERLAY", "GameFontNormal") - h:SetText(header) - h:SetTextColor(GOLD[1], GOLD[2], GOLD[3], 1) - h:SetPoint("TOPLEFT", dot, "TOPRIGHT", 10, 6) - ApplyInter(h, "semibold") - - local b = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") - b:SetText(body) - b:SetTextColor(TEXT_BODY[1], TEXT_BODY[2], TEXT_BODY[3], 1) - b:SetJustifyH("LEFT") - b:SetJustifyV("TOP") - b:SetSpacing(2) - b:SetPoint("TOPLEFT", h, "BOTTOMLEFT", 0, -2) - b:SetPoint("RIGHT", row, "RIGHT", -4, 0) - ApplyInter(b, "regular") - - row:SetHeight(h:GetStringHeight() + b:GetStringHeight() + 10) - row.header = h - row.body = b - return row -end - -local function BuildPage4(parent) - local p = MakePage(parent) - - local title = HeaderText(p, "You're all set", "GameFontNormalLarge") - title:SetPoint("TOP", p, "TOP", 0, -28) - - local sub = BodyText(p, "A few last things to know.") - sub:SetPoint("TOP", title, "BOTTOM", 0, -6) - - local rows = {} - - local r1 = BulletRow(p, - "Open it", - "Press your hotkey to open the search bar from anywhere. Click outside (or pick a result) and it tucks away.") - r1:SetPoint("TOPLEFT", p, "TOPLEFT", 50, -84) - rows[1] = r1 - - local r2 = BulletRow(p, - "Move it", - "Drag the bar anywhere on the screen. Lock it from Options if you don't want it moving.") - r2:SetPoint("TOPLEFT", r1, "BOTTOMLEFT", 0, -10) - - local r3 = BulletRow(p, - "Reset its spot", - "Type /reset in the search bar to send it back to the top of the screen, or use Reset Positions in Options.") - r3:SetPoint("TOPLEFT", r2, "BOTTOMLEFT", 0, -10) - - local r4 = BulletRow(p, - "Tweak everything", - "Type /ef to open Options: themes, indicator style, hotkeys, filters, map pins, and more.") - r4:SetPoint("TOPLEFT", r3, "BOTTOMLEFT", 0, -10) - - p.OnEnter = function() - local current = GetBindingKey(TOGGLE_ACTION) - local bindLabel = current - and ("|cffFFD100" .. current .. "|r") - or "|cffff6e6e(no key bound; set one in Options)|r" - rows[1].body:SetText( - "Press " .. bindLabel .. " to open the search bar from anywhere. Click outside (or pick a result) and it tucks away.") - rows[1]:SetHeight(rows[1].header:GetStringHeight() + rows[1].body:GetStringHeight() + 10) - end - - return p -end - local BANNER_H = 38 local function CreateFrameOnce() From 65785826d00bbc79aae0699e135cce3643064209 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 12 May 2026 01:40:04 -0400 Subject: [PATCH 094/103] Replace em dashes with project punctuation across the branch Project style forbids em/en dashes in prose and comments. Sweep all remaining hits and replace each with a context-appropriate colon, comma, period, or parentheses. All edits are comment-only; no code behavior changes. Branch luacheck still reports 0 warnings. --- BlizzOptionsSearch.lua | 6 ++--- Database.lua | 14 +++++------ DatabaseDynamic.lua | 4 ++-- MapSearch.lua | 40 +++++++++++++++---------------- MapTab.lua | 48 ++++++++++++++++++------------------- UI.lua | 54 +++++++++++++++++++++--------------------- Utils.lua | 2 +- 7 files changed, 84 insertions(+), 84 deletions(-) diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index 1eb64fb..2637291 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -1318,7 +1318,7 @@ local function CollectEntries() local entries = {} -- Best-effort: resolve catIDs against the live category tree. - -- Safe to call even if SettingsPanel hasn't been opened yet — + -- Safe to call even if SettingsPanel hasn't been opened yet: -- it just leaves the lookup tables empty. HandleStep retries -- on demand when the user clicks an entry. ResolveCategoryIDs() @@ -1328,7 +1328,7 @@ local function CollectEntries() -- variable is a phantom entry (CVar removed from the live panel in -- this client version). Skip those instead of injecting dead rows -- whose click does nothing. If nothing resolved, the registry - -- isn't ready yet — fall back to emitting all curated entries and + -- isn't ready yet, so fall back to emitting all curated entries and -- let the late re-pass at 3.0s prune. local registryReady = false for var in pairs(categoryIDByVariable) do @@ -1725,7 +1725,7 @@ local function CollectAddonCategories() if not catName or catName == "" then return end local catNameLower = slower(catName) -- Path is rooted at " Settings" (e.g. "BugSack Settings") - -- instead of "AddOn Settings > " — the latter wastes a + -- instead of "AddOn Settings > ". The latter wastes a -- whole row level on a constant string. For nested categories -- the subcategory name follows: "BugSack Settings > Tooltip". local rootName = (parentName or catName) .. " Settings" diff --git a/Database.lua b/Database.lua index 33a1815..c6debec 100644 --- a/Database.lua +++ b/Database.lua @@ -319,7 +319,7 @@ function Database:PopulateDynamicCurrencies() -- player has on OTHER characters (or has never touched on any -- character but still exist account-wide). Without this, the -- "All Warband Transferable" filter mode only shows currencies - -- the current character has interacted with — defeating the point. + -- the current character has interacted with, defeating the point. -- Enumerate via the modern C_CurrencyInfo accessor when available; -- fall back to a bounded ID scan only if nothing else works. do @@ -2050,7 +2050,7 @@ function Database:PopulateDynamicAbilities() and GetFlyoutInfoFn and GetFlyoutSlotInfoFn then -- Flyouts are containers (Switch Flight Style → Skyriding / -- Steady Flight, etc.). The flyout itself isn't castable, - -- but its slot spells are — scan and inject each, and use + -- but its slot spells are. Scan and inject each, and use -- the flyout's own name as a keyword so "switch flight" -- finds the slot spells. local flyoutID = itemInfo.actionID @@ -2087,7 +2087,7 @@ end -- Common community abbreviations for dungeons/raids whose initials -- skip non-leading letters (e.g. "BWL" picks the W from blackWing) so -- the standard initials/prefix scoring can't reach them. Listed as --- per-instance keyword aliases — typing "bwl" gets the same 2-3 char +-- per-instance keyword aliases: typing "bwl" gets the same 2-3 char -- exact-match boost (140) that "icc" already gets via the prefix path. -- Keys are lowercased instance names returned by EJ_GetInstanceByIndex. -- Exposed on ns so MapSearch can inject the same aliases onto its @@ -3773,7 +3773,7 @@ function Database:ScoreInitials(text, query) -- queries like "sound" wrongly initial-match "Shurrai, Atrocity of -- the Undersea" via S(hurrai) o(f) und(ersea), skipping the content -- word "atrocity" silently. Stopwords (of/the/and/...) may still be - -- skipped — that's what lets "tot" → "Throne of Thunder" work. + -- skipped: that's what lets "tot" → "Throne of Thunder" work. local qi = 1 local wordsMatched = 0 for wi = 1, numWords do @@ -3824,7 +3824,7 @@ function Database:ScoreFuzzy(text, query, queryLen) local word = textWords[wi] -- First-letter constraint (Algolia default). "easter" vs -- "master" is a 1-edit match technically, but they're - -- semantically unrelated — typos almost never change the + -- semantically unrelated: typos almost never change the -- first character, and this rule kills the false-positive -- flood without dropping real typos like "achievmnts" → -- "achievements". @@ -3919,7 +3919,7 @@ end -- -- Builds a small byte-set from the text per call. For stable text -- (POI names), the caller should cache result at a higher level; for --- one-off scoring this remains cheap — O(|text| + |query|). +-- one-off scoring this remains cheap: O(|text| + |query|). local reuseCouldMatchSet = {} function Database:CouldMatch(text, query) local tlen, qlen = #text, #query @@ -4168,7 +4168,7 @@ function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) -- unmatched word zeroes the total to prevent common words like "of" from -- producing false positives. Single-character words are SKIPPED (not -- failed) so a mid-type query like "feral a" still passes through entries - -- that match "feral" — otherwise the prevCandidates incremental narrowing + -- that match "feral", otherwise the prevCandidates incremental narrowing -- chain inherits an empty set and "feral abil" silently returns nothing. local total = 0 for qwi = 1, #queryWords do diff --git a/DatabaseDynamic.lua b/DatabaseDynamic.lua index 2332ae1..71c913d 100644 --- a/DatabaseDynamic.lua +++ b/DatabaseDynamic.lua @@ -172,7 +172,7 @@ end function Database:LoadDeferredSyncProvidersStaggered() -- Batch flag suppresses per-provider ResetSearchCache (which rebuilds -- the prefix index over the entire uiSearchData each time). Without - -- this each of the ~12 providers triggers a full rebuild — by the + -- this each of the ~12 providers triggers a full rebuild. By the -- end the prefix index has been rebuilt 12 times. We rebuild once -- at the end of the chain instead. self._dynamicBatchLoading = true @@ -207,7 +207,7 @@ function Database:LoadDeferredSyncProvidersStaggered() end -- Synchronous heavy-data load. Used at PLAYER_LOGIN so the load --- screen absorbs the cost. Only loot (current spec) is scanned here — +-- screen absorbs the cost. Only loot (current spec) is scanned here: -- it's small and matches a common search ("ring", "haste ring"). Boss -- scanning iterates ~1000+ encounters across every expansion tier, so -- it runs through its async provider after login or on boss-related diff --git a/MapSearch.lua b/MapSearch.lua index bf5cd44..3a949a4 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -602,7 +602,7 @@ ns.MapSearch = ns.MapSearch or MapSearch ns.MapSearch.GetCategoryIcon = GetCategoryIcon -- GetFilterBucket lives below the CATEGORIES table so it can reference --- it as an upvalue (Lua resolves at definition time — declaring this +-- it as an upvalue (Lua resolves at definition time, so declaring this -- function above CATEGORIES would silently treat the name as a global -- and every parent-based lookup would return nil). @@ -882,7 +882,7 @@ function MapSearch:CreateHighlightFrame() highlightFrame:SetSize(64, 64) highlightFrame:SetFrameStrata("TOOLTIP") highlightFrame:SetFrameLevel(2000) - -- Decoration only — never absorb clicks even if it ends up over a + -- Decoration only, never absorb clicks even if it ends up over a -- clickable region in some map mode (maximized canvas, etc.). highlightFrame:EnableMouse(false) highlightFrame:Hide() @@ -905,7 +905,7 @@ function MapSearch:CreateHighlightFrame() -- Indicator pointing down to the location. Parented to the canvas -- (sibling of waypointPin / highlightFrame) and anchored explicitly - -- on every show — never reparented. Reparenting between hovers had + -- on every show, never reparented. Reparenting between hovers had -- inconsistent timing on some result rows where the indicator would -- inherit a stale Hidden state and never repaint. indicatorFrame = CreateFrame("Frame", "EasyFindMapIndicator", WorldMapFrame.ScrollContainer.Child) @@ -1015,7 +1015,7 @@ function MapSearch:CreateZoneHighlightFrame() zoneHighlightFrame:SetFrameStrata("TOOLTIP") -- High strata to be visible zoneHighlightFrame:SetFrameLevel(400) zoneHighlightFrame:SetAllPoints(WorldMapFrame.ScrollContainer.Child) - -- Decorative overlay only — must never absorb clicks. At TOOLTIP + -- Decorative overlay only, must never absorb clicks. At TOOLTIP -- strata it's the topmost frame in WorldMapFrame; if the canvas -- extents reach under the MapTab side panel (maximized map), a -- mouse-enabled overlay there would eat row clicks. @@ -1349,7 +1349,7 @@ function MapSearch:SearchZones(query) -- Ancestor matching is intentionally NOT done here. If the -- parent zone matches the query, the renderer expands ALL of - -- its children via GetWorldChildren — no need to inject + -- its children via GetWorldChildren, no need to inject -- partial children into the results, which produced the -- inconsistent "only some children show" behavior where -- whether a child surfaced depended on whether its name @@ -1657,7 +1657,7 @@ function MapSearch:PreviewZoneHighlight(mapID) local clampedT = mmax(0, top) local clampedB = mmin(1, bottom) if (clampedR - clampedL) < 0.01 or (clampedB - clampedT) < 0.01 then return end - -- Clamped rect covers essentially the whole canvas — it's a + -- Clamped rect covers essentially the whole canvas: it's a -- "this zone is the entire view" case. No useful preview. if (clampedR - clampedL) >= 0.95 and (clampedB - clampedT) >= 0.95 then return end @@ -3251,7 +3251,7 @@ end -- Local-scope caches (SearchZones local_, SearchPOIs local_, -- localScanCache) are tied to whichever map WorldMapFrame currently --- shows — its direct child zones, dungeon entrances, etc. The +-- shows: its direct child zones, dungeon entrances, etc. The -- character-zone events above DON'T fire when the player navigates -- the world map UI to another zone, so without this hook a previous -- map's child zones would bleed into "This Zone (currentMap)" results. @@ -3279,7 +3279,7 @@ end -- Runs every scan needed for local-mode BuildResults in one shot and -- caches the result keyed on mapID. Typing `delve` used to rescan the -- current map's POIs, dungeon entrances, flight masters, and vignettes --- on every keystroke — each scan calls Blizzard APIs repeatedly and +-- on every keystroke: each scan calls Blizzard APIs repeatedly and -- adds up to hundreds of milliseconds per search in a busy zone. A -- 1-second TTL is long enough to coalesce a typing burst but short -- enough that a freshly-placed pin shows up on the user's next natural @@ -3560,7 +3560,7 @@ function MapSearch:ScanAllFlightMasters() -- Zones only. A continent scan returns the same nodes -- as its child zones (just expressed in continent -- coordinates), and the recursion below already covers - -- every zone — so scanning continents would just + -- every zone, so scanning continents would just -- produce a duplicate of every FM with a coordMapID -- that's wrong for whichever map the player is viewing. if mt == Enum.UIMapType.Zone then @@ -4230,7 +4230,7 @@ function MapSearch:BuildResults(text, isGlobal, skipPins) -- the global instance cache invalidates). SearchPOIs mutates -- poi.score / duplicateKey / allInstances on every match, and -- the first-pass pass-through (`if poi.isZone and poi.score`) - -- blindly trusts the score field on the next call — so a prior + -- blindly trusts the score field on the next call, so a prior -- "raid" or "dungeon" search leaves every instance scored 150, -- and an unrelated query like "tol" inherits the lot. Clear -- before each scan to force fresh scoring and dedup. @@ -4525,7 +4525,7 @@ function MapSearch:SearchPOIs(pois, query, noCache) end -- Cached candidates carry poi.score from the previous query's -- scoring run. The first-pass pass-through (`if poi.isZone and - -- poi.score`) would treat that as the current score — so typing + -- poi.score`) would treat that as the current score, so typing -- "to" then "tol" pulls Cape of Stranglethorn (which scored on -- "to" via initials) into "tol" with the old score intact. Clear -- per-entry score so the narrowed pass re-scores fresh. @@ -4720,7 +4720,7 @@ function MapSearch:SelectResult(data, directOverride) elseif directMode then -- Direct (Fast) mode: every zone click zooms straight into the -- zone's own map. Skipping NavigateToEntrance keeps behavior - -- uniform — without this, sub-zones with entrance coords + -- uniform: without this, sub-zones with entrance coords -- (Vale of Eternal Blossoms, etc.) would pin on the parent -- and require a second click on the pin to actually enter. DebugPrint("[EasyFind] SelectResult → ZONE DIRECT branch, zoneMapID=", data.zoneMapID) @@ -5094,7 +5094,7 @@ function MapSearch:ShowWaypointAt(x, y, icon, category, arrowOnly) -- by zoneHighlightFrame, so pin chrome (icon, glow, highlight box) -- would clutter it. Hide pin and highlight, then anchor the -- indicator at the zone center on the canvas. The indicator is a - -- permanent canvas child — no reparenting between calls. + -- permanent canvas child, no reparenting between calls. if arrowOnly then waypointPin:Hide() highlightFrame:Hide() @@ -5258,7 +5258,7 @@ function MapSearch:HighlightPin(pin, x, y, icon, category) end -- Shared hover-preview entry point. Shows the hovered pin ALONGSIDE --- any pin the user already clicked, by reusing ShowMultipleWaypoints — +-- any pin the user already clicked, by reusing ShowMultipleWaypoints, -- same mechanism that handles multi-instance results like auction -- houses. Saves activePinState on first preview so EndHoverPreview can -- cleanly restore to the clicked-only state when the cursor moves off. @@ -5275,7 +5275,7 @@ function MapSearch:RunHoverPreview(data) -- Zone-area preview: when hovering a zone result, draw a translucent -- rect where the zone sits on the currently-viewed map. Strictly - -- visible-only — PreviewZoneHighlight bails when the zone isn't on + -- visible-only: PreviewZoneHighlight bails when the zone isn't on -- this map, when we're already inside it, and never changes maps. self._previewingZone = nil if data.isZone and data.zoneMapID and self.PreviewZoneHighlight then @@ -5336,7 +5336,7 @@ function MapSearch:RunHoverPreview(data) end end - -- Always restore activePinState — even to nil — so hover never + -- Always restore activePinState (even to nil) so hover never -- persists as the "active" clicked pin. Without unconditional -- restoration, hovering when nothing is clicked would silently -- promote the previewed pin into the real active state, which @@ -5437,7 +5437,7 @@ function MapSearch:GetPreviewCoords(data) -- Filter instances to those whose (x,y) are valid on the -- currently viewed map. FMs scanned at both continent and -- zone level merge into a single result with multiple - -- instances; without this filter, both render — and one is + -- instances; without this filter, both render, and one is -- always at the wrong pixel for whichever map you're on. local valid = {} for i = 1, #data.allInstances do @@ -5758,7 +5758,7 @@ function MapSearch:SearchForUI(query) local searchMapID = GetBestMapForUnit("player") or (WorldMapFrame and WorldMapFrame:GetMapID()) -- Gather POIs from both local and global sources in a single pass - -- so the UI bar shows results regardless of zone scope — matching + -- so the UI bar shows results regardless of zone scope, matching -- how the MapTab surfaces both "This Zone" and "Across the World" -- content without asking the user to pick. wipe(reuseUISearchPOIs) @@ -5915,7 +5915,7 @@ function MapSearch:SearchForUI(query) -- same buckets the user picked there. Mirrors FilterAndDedupe in -- MapTab.lua: any POI whose bucket is explicitly disabled -- (filters[bucket] == false) drops out. Buckets without a saved - -- value default to enabled — same convention DB_DEFAULTS uses. + -- value default to enabled, same convention DB_DEFAULTS uses. do local mtFilters = EasyFind.db.mapTabFilters if mtFilters then @@ -6036,7 +6036,7 @@ function MapSearch:HandleUISearchClick(data, forceGuide) else -- Local POI: open the world map at the POI's zone and show the -- visual pin on the canvas. We deliberately do NOT call - -- SetUserWaypoint / SetSuperTrackedUserWaypoint here — the user + -- SetUserWaypoint / SetSuperTrackedUserWaypoint here: the user -- has to click the on-canvas pin's tracking icon to actually -- start tracking, matching the way clicking the small map pin -- works elsewhere in the addon (and Blizzard's own UI). diff --git a/MapTab.lua b/MapTab.lua index 0baf50f..eb074ca 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -63,7 +63,7 @@ local function GetWorldChildren(mapID) -- Blizzard's mapType. The API marks instanced cities (Dalaran, -- etc.) as Dungeon, so an mt-based dungeon glyph mis-fires on -- cities. Synthesized expansions of a continent never contain - -- real dungeons anyway — those live inside zones, not directly + -- real dungeons anyway: those live inside zones, not directly -- under the continent. local children = GetMapChildrenInfo(mapID, nil, false) if children then @@ -404,7 +404,7 @@ local function CreateResultRow(parent) row.text = text -- Single highlight texture covers mouse hover AND keyboard - -- selection via LockHighlight, so both paths look identical — + -- selection via LockHighlight, so both paths look identical: -- Blizzard's tapered quest-log row glow atlas. Mouse hover shows -- it automatically; UpdateNavHighlight calls LockHighlight on the -- keyboard-selected row to pin it on. @@ -437,7 +437,7 @@ end -- Timestamp of the most recent keystroke in the search box. Hover -- previews are suppressed for a short window afterwards, because every -- keystroke re-renders the result rows and WoW fires OnEnter for any --- row that appears under a stationary cursor — which was showing preview +-- row that appears under a stationary cursor, which was showing preview -- pins and highlighting the map without the user moving their mouse. local lastTypeTime = 0 local HOVER_PREVIEW_TYPING_GUARD = 0.3 @@ -723,7 +723,7 @@ local function AcquireGroupHeader(parent, labelText, groupKey, count, collapsed, hdr.label:SetText(labelText or "") hdr.toggleBtn.icon:SetAtlas(collapsed and "QuestLog-icon-expand" or "QuestLog-icon-shrink") -- Hide the toggle button entirely when there are no children to - -- expand — clicking it would otherwise look broken. + -- expand. Clicking it would otherwise look broken. hdr.toggleBtn:SetShown(hasChildren and true or false) hdr.groupKey = groupKey hdr.navigateData = navigateData @@ -742,7 +742,7 @@ local function AcquireGroupHeader(parent, labelText, groupKey, count, collapsed, end end) hdr:SetScript("OnClick", function(self, button) - -- Body click ONLY navigates — never toggles. Skip entirely if + -- Body click ONLY navigates, never toggles. Skip entirely if -- the toggle button is under the cursor (WoW has occasionally -- surprising click routing with nested clickable children). if self.toggleBtn and self.toggleBtn:IsMouseOver() then return end @@ -882,8 +882,8 @@ local function GroupBySharedParent(results) end end -- Demotion rule: a group keeps its header whenever it has a parent - -- zone (navigateData) — even with zero children in the current - -- result set — so a continent with children in the world hierarchy + -- zone (navigateData), even with zero children in the current + -- result set, so a continent with children in the world hierarchy -- never displays as a solitary flat row. Single-child groups -- without a matching parent still collapse to flat so standalone -- leaves don't gain a useless wrapping header. @@ -924,7 +924,7 @@ local function GroupBySharedParent(results) -- Any subgroup with at least one child gets a collapsible -- header (synthesized if the zone itself wasn't matched). -- A zone match with zero children renders as a plain loose - -- row — no point in a collapsible header for nothing. + -- row. No point in a collapsible header for nothing. local keptOrder = {} for _, sub in ipairs(subgroupOrder) do if #sub.items == 0 and sub.headerData then @@ -1067,8 +1067,8 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece elseif e.type == "version" then -- Version group: same name, multiple mapIDs (e.g. Dalaran -- exists in Northrend and Broken Isles). Default-collapsed - -- so the bare name shows; expanding lists each variant — - -- their pathPrefix already disambiguates ("Crystalsong + -- so the bare name shows; expanding lists each variant. + -- Their pathPrefix already disambiguates ("Crystalsong -- Forest" / "Broken Isles"). Header navigates to newest. local groupKey = sectionKey .. ":version:" .. e.name local stored = collapsedDb[groupKey] @@ -1094,7 +1094,7 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece -- synthesized, navigateData): show ALL world- -- hierarchy children. "I asked for Eastern -- Kingdoms, give me the whole continent." - -- (2) Parent didn't match — group exists only because + -- (2) Parent didn't match: group exists only because -- multiple children matched (or one child + a -- synthesized parent header for click-to-navigate): -- show just the matched children. Surfacing every @@ -1349,7 +1349,7 @@ local function FilterAndDedupe(results, seen, isLocal) if not excludeLocal then -- FMs are now scanned both locally (current map) and globally -- (every zone). The local scan uses the viewed map's coords - -- while the global scan uses each home zone's own coords — + -- while the global scan uses each home zone's own coords, -- so the same FM has different (x,y) in each set. Key FMs -- by name alone so the local pass claims it first and the -- global pass dedupes cleanly. @@ -1369,7 +1369,7 @@ end -- Does a single token match a POI via any available facet? Token must -- already be lowercase. Checks name, keywords, category (with plural/ --- singular flex), pathPrefix, ancestor zone names, and — for ancestors — +-- singular flex), pathPrefix, ancestor zone names, and (for ancestors) -- falls back to expanding the token through ZONE_ABBREVIATIONS so "nr" -- resolves against "northrend". local function POIMatchesToken(poi, t) @@ -1643,7 +1643,7 @@ function MapTab:RunSearch(text) -- Stash top N result names (locals first, then globals) for -- autocomplete. Ghost/Tab picks the first name whose lowercase - -- prefix matches the typed text — so a fuzzy-winning non-prefix + -- prefix matches the typed text, so a fuzzy-winning non-prefix -- match doesn't prevent a slightly-lower-scored prefix match from -- being suggested. Covers cases like typing "dragon i" when the -- top scorer is "Dragonblight" (no space) but "Dragon Isles" is @@ -1718,7 +1718,7 @@ local function UpdateNavHighlight() if f then local selected = (i == navRowIndex) -- Group headers (hoverOverlay present): mirror mouse hover - -- exactly — show the same hoverOverlay atlas and brighten + -- exactly: show the same hoverOverlay atlas and brighten -- the label. Same pattern UI.lua uses for its headerTab. if f.hoverOverlay then f.hoverOverlay:SetShown(selected) @@ -1893,7 +1893,7 @@ HandleNavKey = function(key, keepSearchFocus) -- the next Esc would clear editbox focus, then navFrame -- (still keyboard-enabled with navRowIndex > 0) would -- pick up the propagated key and re-focus the search - -- bar via the Esc branch — instead of closing the map. + -- bar via the Esc branch, instead of closing the map. SetNavRowIndex(0) SetNavFrameCapture(false) if panel and panel.searchBox then panel.searchBox:ClearFocus() end @@ -1976,7 +1976,7 @@ local function CreateSearchBox(parent) end -- Walks panel.topResultCandidates in scoring order and returns the - -- first name whose lowercase prefix matches the query — lets + -- first name whose lowercase prefix matches the query. Lets -- autocomplete fall back past a fuzzy winner that doesn't prefix- -- match (e.g. "dragon i" skipping "Dragonblight" to suggest -- "Dragon Isles"). @@ -2019,7 +2019,7 @@ local function CreateSearchBox(parent) -- Keyboard nav: consume arrow / Alt+J/K / Esc while the editbox -- is focused. WoW editboxes default to propagating every keystroke -- to the binding system, which fires player keybinds while the user - -- is typing — so we unconditionally clamp propagation to false at + -- is typing, so we unconditionally clamp propagation to false at -- the end. ENTER routes through HandleNavKey, which activates a -- highlighted row or falls through to push-to-recents below. editBox:HookScript("OnKeyDown", function(self, key) @@ -2307,7 +2307,7 @@ local function CreatePanel(qmf) -- p is the ListContainer equivalent. Sized like WQT_WorldQuestFrame: -- ContentsAnchor with insets y=-29 top, x=-22 right. This is a -- larger rect than QuestScrollFrame, which is why WQT's paper - -- pattern renders at the same scale as Blizzard's — the atlas + -- pattern renders at the same scale as Blizzard's: the atlas -- stretches over a larger area. local p = CreateFrame("Frame", "EasyFindMapSearchPanel", outer) p:SetPoint("TOPLEFT", anchor, "TOPLEFT", 0, -29) @@ -2316,7 +2316,7 @@ local function CreatePanel(qmf) p.outer = outer outer:Hide() - -- Paper backdrop fills p exactly — matches WQT_ListContainer.Background + -- Paper backdrop fills p exactly, matches WQT_ListContainer.Background -- (TOPLEFT + BOTTOMRIGHT to ListContainer). No bleed past the border. local paper = p:CreateTexture(nil, "BACKGROUND", nil, -1) paper:SetAtlas("QuestLog-main-background", true) @@ -2335,7 +2335,7 @@ local function CreatePanel(qmf) -- The template inherits NineSlicePanel which spans the full rect with -- mouse enabled. Sitting two levels above the result rows it ate the -- first click on every row (loss of editbox focus + border absorption - -- meant the user had to click twice). Border is decorative chrome — it + -- meant the user had to click twice). Border is decorative chrome, it -- never needs mouse input. border:EnableMouse(false) p.border = border @@ -2402,7 +2402,7 @@ local function CreatePanel(qmf) -- Copy QuestScrollFrame.SearchBox's anchors AND explicit size onto -- our search box. Frame inspector confirms Blizzard uses a fixed -- 301x20 SetSize, so we re-apply it after ClearAllPoints to - -- guarantee our frame matches — otherwise the last flex-anchored + -- guarantee our frame matches, otherwise the last flex-anchored -- width lingers and we resolve to a different rect. p.AlignToBlizzardSearch = function() local qsb = _G["QuestScrollFrame"] and QuestScrollFrame.SearchBox @@ -2505,7 +2505,7 @@ local function CreatePanel(qmf) -- "Auto expand headers" checkbox to the right of the recent toggle. -- When on (default), a matched zone header lists every child the -- world hierarchy says lives under it. When off, only children - -- whose names actually match the query show up — handy when you + -- whose names actually match the query show up. Handy when you -- want a focused list instead of a continent's full roster. local expandCheck = CreateFrame("CheckButton", "EasyFindMapTabAutoExpandCheck", p, "UICheckButtonTemplate") expandCheck:SetSize(20, 20) @@ -2602,7 +2602,7 @@ function MapTab:OpenWithQuery(query) -- a synchronous RunSearch on every show, so seeding the text first -- means the panel's first render already uses our query instead of -- briefly flashing an empty / recent-searches state. ClearFocus - -- prevents the editbox from grabbing keyboard input — the user + -- prevents the editbox from grabbing keyboard input. The user -- triggered this by clicking a result, so they expect to keep -- moving with WASD. if panel.searchBox then diff --git a/UI.lua b/UI.lua index a3e4d72..fb67210 100644 --- a/UI.lua +++ b/UI.lua @@ -2260,7 +2260,7 @@ end -- Hint shown only on the currently-selected row, replacing the normal -- subtext so the user knows what Enter / left-click will do without -- cluttering every other row. Returns nil for entries whose action --- isn't worth labelling (UI navigation, settings — the row name itself +-- isn't worth labelling (UI navigation, settings: the row name itself -- already tells you what happens). -- Default tooltip placement for non-gear results. The default UI sets -- the tooltip's bottom-right corner just up-and-left of the cursor @@ -3284,7 +3284,7 @@ function UI:CreateSearchFrame() -- swallows the Enter when there's a text selection (treating -- it like a "deselect" rather than confirm), so without this -- the user's first Enter visually clears the highlight but - -- doesn't focus the result row — they'd have to press Enter + -- doesn't focus the result row, they'd have to press Enter -- a second time. Stripping here puts the text back to what -- the user typed before WoW's default handler runs. if self.StripAutocomplete then self:StripAutocomplete() end @@ -3469,7 +3469,7 @@ function UI:CreateSearchFrame() -- (dark hover bg, blue ADD highlight) and just clips the shape. if theme.searchBarRounded and filterBtn.CreateMaskTexture then local CIRCLE_TEX = "Interface\\AddOns\\EasyFind\\Textures\\FilterButtonCircle" - -- Inner circle (clips hover bg + highlight) — keep the same radius + -- Inner circle (clips hover bg + highlight): keep the same radius -- as the gold ring's inner edge so the ring sits flush around it. local innerInset = 6 local circleMask = filterBtn:CreateMaskTexture(nil, "ARTWORK") @@ -3861,7 +3861,7 @@ function UI:CreateSearchFrame() -- Toggling also rebuilds the result list (via -- ShowHierarchicalResults), which wipes selectedIndex. -- Snapshot the row's identity first so the same header can - -- be re-selected after the rebuild — letting the user spam + -- be re-selected after the rebuild, letting the user spam -- Space to collapse/expand without losing selection. if selectedIndex > 0 then local row = resultButtons[selectedIndex] @@ -3990,7 +3990,7 @@ function UI:CreateSearchFrame() -- Secure-action rows: let Enter propagate to the override -- binding so the secure click dispatch fires (same as a mouse -- click). Without this navFrame swallows Enter and the - -- override binding never sees the key — abilities, mounts, + -- override binding never sees the key. Abilities, mounts, -- macros, bag items, toys, outfits all need this gate. if key == "ENTER" and selectedIndex > 0 and not InCombatLockdown() then local selRow = resultButtons[selectedIndex] @@ -6961,7 +6961,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) if fb.UnlockHighlight then fb:UnlockHighlight() end end end - -- Skip the ClearFocus when HandleEscape is driving the close — + -- Skip the ClearFocus when HandleEscape is driving the close, -- it intentionally refocuses the editbox so the user can keep -- typing. ClearFocus + same-frame SetFocus can lose to internal -- editbox state, hence the flag instead of relying on order. @@ -6973,7 +6973,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end) -- Close when clicking outside (but not when interacting with sub-filter popups). - -- Both LeftButton AND RightButton trigger close — without the right-button + -- Both LeftButton AND RightButton trigger close: without the right-button -- check, right-clicking outside dismisses the search bar (whose handler -- listens for GLOBAL_MOUSE_DOWN regardless of button) but leaves the -- filter dropdown stuck open. @@ -7270,7 +7270,7 @@ function UI:CreateResultsFrame() -- Click-outside-to-close: hides the results frame on any click that -- isn't on the search bar, results frame, or one of its associated -- popups (filter dropdown, pin/right-click menu, gear/collections - -- option popups). Hover-out doesn't close — that's too sensitive. + -- option popups). Hover-out doesn't close: that's too sensitive. resultsFrame:SetScript("OnShow", function(self) self:RegisterEvent("GLOBAL_MOUSE_DOWN") end) @@ -7722,7 +7722,7 @@ local function WriteSettingVariable(variable, value) end end -- CVar fallback for raw CVars not registered with the Settings panel. - -- Booleans need explicit "1"/"0" — tostring(true) gives "true", which + -- Booleans need explicit "1"/"0": tostring(true) gives "true", which -- a CVar slot would store literally and break the next read. if SetCVar then local cvarVal @@ -7757,7 +7757,7 @@ local function ActivateSettingResult(data, ctrlHeld) end -- Within-group ordering for flat-list mode. Score-first so the best --- matches stay at the top — alphabetical was burying high-scoring +-- matches stay at the top. Alphabetical was burying high-scoring -- prefix matches (e.g. "Skull Bash" for query "skull") below low- -- scoring fuzzy matches (e.g. "Armor Skills" via "skill"). Path/name -- only break ties between equally-scored results so siblings under @@ -8894,7 +8894,7 @@ function UI:CreateResultButton(index) -- position, OnUpdate watches for movement, and the actual -- Pickup* call happens once the cursor has moved past the -- 5px drag threshold. Plain shift+click without movement - -- does nothing — matches Blizzard's action-bar drag feel. + -- does nothing, matches Blizzard's action-bar drag feel. -- C_Spell.PickupSpell is preferred over the legacy global since -- Midnight phased PickupSpell out for some spells. local function PickupSpellCompat(spellID) @@ -8985,7 +8985,7 @@ function UI:CreateResultButton(index) local d = self.data -- Shift held: kill the cast for this click. The pickup (if any) - -- happens via the OnMouseDown / OnUpdate drag detection above — + -- happens via the OnMouseDown / OnUpdate drag detection above: -- this branch only ensures the secure handler is a no-op so -- nothing fires when the user hasn't moved yet. Setting the -- skip-navigation flag here (not waiting for OnUpdate) is what @@ -9404,7 +9404,7 @@ function UI:CreateResultButton(index) -- mount / toy / pet / outfit / heirloom / appearance set -- group; loot (the gear flyout's "Hide tooltips") covers -- itemized gear results AND any bag item that's actual - -- gear — the entry being a "Bag" by category is incidental + -- gear. The entry being a "Bag" by category is incidental -- to where it lives, the user-facing class of thing is -- "gear" either way. local ht = EasyFind.db.hideTooltips @@ -9703,7 +9703,7 @@ function UI:OnSearchTextChanged(text, force) -- A pending OnTextChanged timer can fire after focus has shifted -- away from the editbox (user clicked outside, OR clicked an -- inline child widget like a slider that StripAutocomplete then - -- triggers a SetText on via the focus-loss hook). Just bail — + -- triggers a SetText on via the focus-loss hook). Just bail: -- don't re-render, but also don't hide. The outside-click paths -- (GLOBAL_MOUSE_DOWN, OnEditFocusLost) decide whether to actually -- hide based on cursor position. Calling HideResults here also @@ -10095,12 +10095,12 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- data refs and same depth at every index) AND the relevant view -- state (theme, collapse state, results-above) hasn't -- changed since the last render, the visible output would be byte- - -- for-byte identical. Skip the entire per-row layout pass — this is + -- for-byte identical. Skip the entire per-row layout pass: this is -- the typical case during typing once the top results stabilize. do -- collapsedNodes is wiped to a fresh empty table on every -- search, so identity comparison would always miss during - -- typing. Snapshot a single key (or nil if empty) — a click on + -- typing. Snapshot a single key (or nil if empty): a click on -- a collapse toggle adds or removes a key, which we'll see. local theme = EasyFind.db.resultsTheme local above = EasyFind.db.uiResultsAbove @@ -10381,7 +10381,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.data = data -- Reset every icon.* tooltip-identifier the OnEnter handler -- looks at. Without this, rows recycled from a previous - -- render leak their old category's tooltip — e.g. a row + -- render leak their old category's tooltip, e.g. a row -- that was "Felfire Hawk" (mount) becoming "Bronze Bullion" -- (currency) keeps icon.mountID set, so OnEnter shows the -- mount tooltip instead. Only the mount/toy/.../bag branch @@ -12485,7 +12485,7 @@ end function UI:MoveSelection(delta) -- CountVisibleResults walks the button pool and trusts each row's -- :IsShown(), but child rows of a hidden resultsFrame still report - -- shown — so a leftover row from a prior search would let Alt+J + -- shown, so a leftover row from a prior search would let Alt+J -- yank focus into nothing on an empty bar. Gate on the frame. if not resultsFrame or not resultsFrame:IsShown() then return end local visibleCount = self:CountVisibleResults() @@ -13889,7 +13889,7 @@ function UI:SelectResult(data, forceGuide) return end - -- Gear set: equip via Equipment Manager. Skipped in combat — the + -- Gear set: equip via Equipment Manager. Skipped in combat, the -- API silently fails there, so no point trying. if data.gearSetID then if InCombatLockdown() then return end @@ -13959,7 +13959,7 @@ function UI:SelectResult(data, forceGuide) end -- Bag item: usable items (consumables, equippables) fire /use via the - -- SecureActionButton on click — no bag UI needed. Non-usable items + -- SecureActionButton on click: no bag UI needed. Non-usable items -- open the bag(s) containing them and highlight the slot. if data.itemID and data.category == "Bag" then if useFast then @@ -13981,7 +13981,7 @@ function UI:SelectResult(data, forceGuide) end -- Macro: default click runs the macro (handled by the row's secure - -- macro attribute). Ctrl+click opens MacroFrame for editing — + -- macro attribute). Ctrl+click opens MacroFrame for editing: -- PreClick clears the secure type when Ctrl is held so the macro -- doesn't also fire. Right-click → Guide opens MacroFrame as a -- guide via forceGuide=true. @@ -14594,7 +14594,7 @@ function UI:DirectOpen(data) end -- Hand off remaining steps to the guided highlight. DirectOpen - -- is left-click "open + show me where" — not the right-click + -- is left-click "open + show me where", not the right-click -- guided walkthrough, so flag the data so the highlight ticker -- cancels (instead of rewinding to step 1 and reopening) if the -- user closes the parent window with the highlight still active. @@ -14984,7 +14984,7 @@ function UI:IsCurrencyOnBackpack(currencyID) if not currencyID or currencyID == 0 or not C_CurrencyInfo then return false end -- The enumeration list is authoritative. The CurrencyInfo struct's -- `isShowInBackpack` flag in modern builds indicates *capability* - -- (the currency is allowed to be tracked), not current state — so + -- (the currency is allowed to be tracked), not current state, so -- using it always read as on, the toggle always tried to add, and -- removal silently no-op'd. local getInfo = C_CurrencyInfo.GetBackpackCurrencyInfo @@ -14999,7 +14999,7 @@ function UI:IsCurrencyOnBackpack(currencyID) -- name="" / nil quantity instead of returning nil; without -- this guard, comparing against currencyID still works but -- we'd false-positive if the caller ever passed 0 or nil - -- through (defended above) — keep the explicit check so a + -- through (defended above): keep the explicit check so a -- future mistake at a call site can't bite. if id and id ~= 0 and id == currencyID then return true end end @@ -15294,7 +15294,7 @@ function UI:Show(andFocus) searchFrame:Show() -- Belt-and-suspenders: OnShow hook also calls this, but if searchFrame -- was already shown the hook didn't fire and escCatcher would be left - -- hidden — leaving ESC without a target when the editbox is unfocused. + -- hidden, leaving ESC without a target when the editbox is unfocused. if escCatcher then escCatcher:Show() end EasyFind.db.visible = true if EasyFind.db.smartShow and not EasyFind.db.autoHide then @@ -16037,7 +16037,7 @@ function UI:ShowFirstTimeSetup() -- Set the initial resizer size. scaleResizerVisual() - -- Fixed panel width — comfortably holds the keybind rows and + -- Fixed panel width: comfortably holds the keybind rows and -- two-button bottom strip without wrapping. local panelWidth = 290 @@ -16094,7 +16094,7 @@ function UI:ShowFirstTimeSetup() smartDesc:SetText("|cff999999Bar hides when your mouse moves away and reappears when you hover near it.|r") -- Keybind rows. Each row = [label] [keybind button] [recommended hint]. - -- Click the button to capture a key, right-click to clear, Esc to cancel — + -- Click the button to capture a key, right-click to clear, Esc to cancel, -- mirrors the keybind UI in /ef Options > Shortcuts. local function GetKeybindLabel(action) local k1 = GetBindingKey(action) diff --git a/Utils.lua b/Utils.lua index eb9d2a7..befbac8 100644 --- a/Utils.lua +++ b/Utils.lua @@ -95,7 +95,7 @@ end --- a key fires the action immediately, waits INITIAL seconds, then --- ticks at a rate that accelerates from INITIAL toward FAST over --- ACCEL seconds. Attach OnKeyUp to `Stop(key)` so releasing the key ---- stops the repeat — pass the key so other keys pressed concurrently +--- stops the repeat. Pass the key so other keys pressed concurrently --- don't cancel each other. --- --- Returns a table: { Start(key, action), Stop(key?), IsKey(key) }. From 1021a6a330ea7293525520de0e0483658e37d323 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 12 May 2026 02:43:23 -0400 Subject: [PATCH 095/103] Strip narration and banner comments across the branch Default to zero comments per project style. Remove WHAT-narration, section banners, restated-name comments, and pre-function description blocks. Keep load-bearing WHY: WoW API quirks, taint and combat-lockdown rationale, bug workarounds, performance invariants, non-obvious algorithmic choices. 3142 comment lines removed across 15 files. Luacheck and lua syntax both clean. --- Aliases.lua | 31 -- BlizzOptionsSearch.lua | 356 ++++---------------- Core.lua | 229 ++++--------- Database.lua | 731 +++++++++------------------------------- DatabaseDynamic.lua | 32 +- Highlight.lua | 554 ++++++------------------------ MapSearch.lua | 659 ++++++++++-------------------------- MapTab.lua | 739 +++++++++++------------------------------ Options.lua | 49 +-- Perf.lua | 81 +---- Rescaler.lua | 53 +-- UI.lua | 142 -------- UIPins.lua | 8 +- Utils.lua | 197 ++--------- Wizard.lua | 109 +----- 15 files changed, 828 insertions(+), 3142 deletions(-) diff --git a/Aliases.lua b/Aliases.lua index 78e7305..eefe3cf 100644 --- a/Aliases.lua +++ b/Aliases.lua @@ -1,9 +1,3 @@ --- User-defined search aliases. Each alias maps a short user-typed --- string to a stable identifier that points at one Database entry. --- Aliases survive reloads/relogs (stored in EasyFindDB.aliases) and --- inject the matching entry into the search results when the user --- types text that prefix-matches the alias. - local _, ns = ... local Aliases = {} ns.Aliases = Aliases @@ -32,10 +26,6 @@ local function MapAliasKey(data) .. ":" .. tostring(math.floor(y * 10000 + 0.5)) end --- Build a stable, type-prefixed key for a Database entry. Used both --- to record an alias target and to find the matching entry later. --- Returns nil when the entry doesn't expose a stable identifier --- (e.g. transient pin headers). function Aliases:GetEntryKey(data) if not data then return nil end if data.mountID then return "mount:" .. data.mountID end @@ -55,8 +45,6 @@ function Aliases:GetEntryKey(data) if IsMapAliasTarget(data) then return MapAliasKey(data) end - -- UI Elements: identify by full path + name. Path may be empty - -- for top-level entries, so fall back to the bare name. if data.path and #data.path > 0 then return "ui:" .. table.concat(data.path, ">") .. ">" .. (data.name or "") end @@ -64,11 +52,6 @@ function Aliases:GetEntryKey(data) return nil end --- Find the live Database entry that matches a stored alias key. The --- live entry is what the renderer needs (icons, tooltip targets, --- secure attributes) so the alias hit looks identical to a normal --- search hit. Returns nil if the entry has been removed (e.g. mount --- relearned with a different ID). function Aliases:FindEntryByKey(key) if not key or not ns.Database or not ns.Database.uiSearchData then return nil end local data = ns.Database.uiSearchData @@ -76,9 +59,6 @@ function Aliases:FindEntryByKey(key) local entry = data[i] if Aliases:GetEntryKey(entry) == key then return entry end end - -- Map results don't live in uiSearchData (they come from - -- MapSearch:SearchForUI). Fall back to the snapshot stored when - -- the alias was added so the alias hit still renders. if EasyFind and EasyFind.db and EasyFind.db.aliases then for _, info in pairs(EasyFind.db.aliases) do if info.key == key and info.snapshot then @@ -89,9 +69,6 @@ function Aliases:FindEntryByKey(key) return nil end --- Persist an alias. The text is trimmed and lowercased so search --- matching can stay case-insensitive without per-keystroke lower() --- calls. Replaces any prior alias with the same text. function Aliases:Add(aliasText, data) if not EasyFind or not EasyFind.db then return false end aliasText = strtrim(aliasText or "") @@ -101,9 +78,6 @@ function Aliases:Add(aliasText, data) if type(EasyFind.db.aliases) ~= "table" then EasyFind.db.aliases = {} end - -- Map results aren't in uiSearchData, so FindEntryByKey can't - -- recover them later. Snapshot the renderable fields so the alias - -- hit still works after a /reload. local snapshot if IsMapAliasTarget(data) then local currentMapID = WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID() @@ -140,7 +114,6 @@ function Aliases:Add(aliasText, data) return true end --- Remove a previously stored alias by its (case-insensitive) text. function Aliases:Remove(aliasText) if not EasyFind or not EasyFind.db or not EasyFind.db.aliases then return end EasyFind.db.aliases[slower(strtrim(aliasText or ""))] = nil @@ -151,7 +124,6 @@ function Aliases:ClearAll() EasyFind.db.aliases = {} end --- Walk all stored aliases. cb(aliasText, info) for each entry. function Aliases:ForEach(cb) if not EasyFind or not EasyFind.db or not EasyFind.db.aliases then return end for _, info in pairs(EasyFind.db.aliases) do @@ -159,9 +131,6 @@ function Aliases:ForEach(cb) end end --- Look up entries whose alias prefix-matches the lowercase query. --- Returns a list of `{ data = liveEntry, alias = info }` so callers --- can highlight the alias text in the result row if they want. function Aliases:GetMatches(queryLower) if not queryLower or queryLower == "" then return nil end if not EasyFind or not EasyFind.db or not EasyFind.db.aliases then return nil end diff --git a/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua index 2637291..37f5ef2 100644 --- a/BlizzOptionsSearch.lua +++ b/BlizzOptionsSearch.lua @@ -1,9 +1,3 @@ --- Blizzard Settings panel search. Walks the live SettingsPanel --- category tree (top-level categories AND subcategories) at register --- time, caches each category id by name and by setting variable, then --- registers searchable entries pointing at the cached id. Click = --- ShowUIPanel(SettingsPanel) followed by Settings.OpenToCategory. - local _, ns = ... local BlizzOptionsSearch = {} ns.BlizzOptionsSearch = BlizzOptionsSearch @@ -24,9 +18,8 @@ local function SecureCall(fn, ...) return true end --- Modern WoW exposes the categories via SettingsPanel:GetAllCategories. --- The legacy Settings.GetCategoryList was removed in Midnight (12.0), --- so callers that walked it returned nil and silently failed. +-- Settings.GetCategoryList was removed in Midnight (12.0); the modern +-- entry point is SettingsPanel:GetAllCategories. local function GetSettingsCategoryList() if SettingsPanel and SettingsPanel.GetAllCategories then local ok, list = pcall(SettingsPanel.GetAllCategories, SettingsPanel) @@ -43,11 +36,8 @@ local function HasSettingsCategoryAccess() or (Settings and Settings.GetCategoryList) ~= nil end --- Curated list of individual settings for direct search. --- Format: { display name, CVar/variable, category name, type code, [min, max, step] } +-- { display name, CVar/variable, category name, type code, [min, max, step] } -- type: c=checkbox, d=dropdown, s=slider --- For sliders, min/max/step let the inline slider widget render --- correctly. Step is the value increment per slider position. local SETTINGS_DATA = { -- Controls {"Sticky Targeting","deselectOnClick","Controls","c"}, @@ -234,13 +224,9 @@ local SETTINGS_DATA = { local TYPE_MAP = { c = "checkbox", d = "dropdown", s = "slider" } --- Hardcoded option lists for CVar-based dropdowns the live SettingsPanel --- can't enumerate (these aren't registered as Setting objects, so --- GetOptionsForVariable's layout walk returns nil). Listed in the order --- the in-game dropdown shows them so the inline picker reads naturally. --- Each entry: { value, label }. Hardware-specific dropdowns (monitors, --- sound devices, GPUs, resolutions) are intentionally omitted -- their --- options are machine-dependent and can't be hardcoded sensibly. +-- Hardcoded option lists for CVar dropdowns SettingsPanel cannot +-- enumerate (not registered as Setting objects). Each entry: +-- { value, label }. Hardware-dependent dropdowns are omitted. local CVAR_DROPDOWN_OPTIONS = { AUTOLOOTTOGGLE = { { value = "NONE", label = "None" }, @@ -413,20 +399,13 @@ local CVAR_DROPDOWN_OPTIONS = { }, } --- Resolved tables, populated once at register time. --- categoryIDByName[lowercaseName] = catID --- categoryIDByVariable[variable] = catID (variable -> owning category) local categoryIDByName = {} local categoryIDByVariable = {} --- Tooltip cache per variable. Filled lazily from three sources in --- order of preference: the live SettingsPanel initializer's tooltip, --- the Setting object's GetTooltip(), and OPTION_TOOLTIP_* globals. local settingTooltips = {} --- Resolve a Blizzard OPTION_TOOLTIP_* global. Tries display name and --- CVar name, both converted to SCREAMING_SNAKE_CASE. PROXY_ vars --- never have tooltip globals, so skip them. +-- Tries OPTION_TOOLTIP_ and OPTION_TOOLTIP_. PROXY_ vars +-- never have tooltip globals. local function ResolveTooltipGlobal(displayName, cvar) if displayName then local fromName = "OPTION_TOOLTIP_" .. displayName:upper():gsub("[%s%-]+", "_"):gsub("[^A-Z0-9_]", "") @@ -440,9 +419,6 @@ local function ResolveTooltipGlobal(displayName, cvar) end end --- Look up cached tooltip; if nothing cached yet, try the live --- SettingsPanel initializer for this variable. Falls back to nil --- (caller should have its own fallback like the display name). local function GetTooltipForVariable(variable, displayName) if not variable then return nil end local cached = settingTooltips[variable] @@ -450,7 +426,6 @@ local function GetTooltipForVariable(variable, displayName) return cached ~= false and cached or nil end - -- Try Setting:GetTooltip() first if Settings and Settings.GetSetting then local sok, settObj = pcall(Settings.GetSetting, variable) if sok and settObj and settObj.GetTooltip then @@ -462,24 +437,19 @@ local function GetTooltipForVariable(variable, displayName) end end - -- Try the OPTION_TOOLTIP_* global local fromGlobal = ResolveTooltipGlobal(displayName, variable) if fromGlobal then settingTooltips[variable] = fromGlobal return fromGlobal end - -- Cache "no tooltip" so we don't re-walk on every hover settingTooltips[variable] = false return nil end BlizzOptionsSearch.GetTooltipForVariable = GetTooltipForVariable --- Resolve dropdown options for `variable` at click time by scanning --- live SettingsPanel layouts. Returns a normalized array of --- { value, label } or nil if the variable isn't a dropdown / can't be --- enumerated. Cached per variable since layouts don't change after --- registration. False sentinel = "we looked, none available". +-- Returns { value, label } array or nil. Cached per variable; false +-- sentinel means "looked, none available". local optionsByVariable = {} local function NormalizeOptionTable(opts, setting) if type(opts) == "function" then @@ -563,9 +533,8 @@ local function GetOptionsForVariable(variable) if cached ~= nil then return cached ~= false and cached or nil end - -- CVar dropdowns (AUTOLOOTTOGGLE, raidFramesHealthText, etc.) aren't - -- registered as Setting objects, so the SettingsPanel walker below - -- never finds them. Hardcoded list catches them first. + -- CVar dropdowns aren't registered as Setting objects; the + -- SettingsPanel walk can't find them. Hardcoded list first. local hardcoded = CVAR_DROPDOWN_OPTIONS[variable] if hardcoded then optionsByVariable[variable] = hardcoded @@ -614,15 +583,13 @@ local function GetOptionsForVariable(variable) end BlizzOptionsSearch.GetOptionsForVariable = GetOptionsForVariable --- Pull the slider's display formatter from the live registry so curated --- SETTINGS_DATA entries (Mouse Look Speed, Camera Distance, etc.) show --- the same value Blizzard's panel does instead of the raw CVar number --- (e.g., PROXY_MOUSE_LOOK_SPEED raw 180 -> displayed "5.5"). +-- Slider display formatter from the live registry so curated entries +-- render the same value Blizzard's panel does (e.g., raw 180 -> "5.5"). local formatterByVariable = {} local function PickFormatter(formatters) if type(formatters) ~= "table" then return nil end - -- MinimalSliderWithSteppersMixin.Label = MakeEnum("Left","Right","Top","Min","Max") - -- Top (2) mirrors the live value above the thumb best. + -- MinimalSliderWithSteppersMixin.Label enum: Top (2) mirrors the + -- live value above the thumb best. local f = formatters[2] or formatters[1] or formatters[0] or formatters.Top or formatters.Right if type(f) == "function" then return f end @@ -689,16 +656,14 @@ local function GetFormatterForVariable(variable) end BlizzOptionsSearch.GetFormatterForVariable = GetFormatterForVariable --- Pending-apply tracking. Settings with CommitFlag.Apply (graphics --- options, resolution, etc.) stage their value into setting.pendingValue --- instead of writing through, so we mirror Blizzard's bottom-bar UX: --- accumulate pending changes and let the user Apply / Revert as a batch. +-- Apply-flagged settings (graphics, resolution, etc.) stage into +-- setting.pendingValue. Mirror Blizzard's bottom-bar batch UX. local pendingApplySettings = {} local pendingChangeCallbacks = {} local function PendingCount() - -- Prune entries whose pendingValue cleared out from under us (user - -- opened Blizzard's panel and Applied / Cancelled there directly). + -- Prune entries whose pendingValue cleared from under us when the + -- user Applied / Cancelled in Blizzard's panel directly. local n = 0 for setting in pairs(pendingApplySettings) do if setting.pendingValue ~= nil then @@ -726,10 +691,6 @@ function BlizzOptionsSearch:GetPendingApplyCount() return PendingCount() end --- Called by the inline editor right after Setting:SetValue. If the --- setting has the Apply commit flag, the new value is staged in --- setting.pendingValue and needs the user to commit; otherwise the --- value is already live and we drop the entry. function BlizzOptionsSearch:NotePendingApply(variable) if not variable or not Settings or not Settings.GetSetting then return end local sok, settObj = pcall(Settings.GetSetting, variable) @@ -743,16 +704,12 @@ function BlizzOptionsSearch:NotePendingApply(variable) pendingApplySettings[settObj] = settObj FirePendingChanged() elseif pendingApplySettings[settObj] then - -- Setting reverted to original via repeat-edit: drop it. pendingApplySettings[settObj] = nil FirePendingChanged() end end --- Find the StaticPopup slot currently displaying the named popup. --- Blizzard reuses StaticPopup1..4 across all popups; when one is busy --- (e.g., our unapplied-changes popup), the next StaticPopup_Show lands --- in a different slot. Walk all slots to find the one we want. +-- Blizzard reuses StaticPopup1..4 slots; walk them to find one matching name. local function FindStaticPopupSlot(popupName) for i = 1, 4 do local p = _G["StaticPopup" .. i] @@ -778,19 +735,11 @@ local function LiftPopupAndRefresh(popup) end) end --- Apply all pending changes at once. Push them into SettingsPanel.modified --- and run its CommitSettings(false) so Blizzard's full pipeline fires: --- gx restart / window update / save bindings AND the --- GAME_SETTINGS_TIMED_CONFIRMATION popup for any Revertable settings --- in the batch (monitor, resolution, etc.). function BlizzOptionsSearch:ApplyPendingChanges() if not next(pendingApplySettings) then return end - -- Apply each pending setting via the same path the per-row Apply - -- button uses (ApplyVariable). That path is proven to actually - -- commit the value -- per-setting Commit() and SettingsPanel - -- writes both silently no-op on some build paths from addon code. - -- Snapshot variables first so wiping pendingApplySettings inside - -- ApplyVariable doesn't break iteration. + -- Route every pending setting through ApplyVariable (the proven + -- path). Snapshot the variable list first so wiping the table + -- mid-iteration doesn't break the loop. local vars = {} for setting in pairs(pendingApplySettings) do if setting.GetVariable then @@ -812,7 +761,6 @@ function BlizzOptionsSearch:RevertPendingChanges() FirePendingChanged() end --- True when a single variable has a staged-but-not-applied change. function BlizzOptionsSearch:HasPendingChange(variable) if not variable or not Settings or not Settings.GetSetting then return false end local sok, settObj = pcall(Settings.GetSetting, variable) @@ -820,13 +768,10 @@ function BlizzOptionsSearch:HasPendingChange(variable) return settObj.pendingValue ~= nil end --- Apply / revert one specific setting (per-row Apply/Reset buttons). --- PROXY_ANTIALIASING is a "view" setting whose own SetValue closure --- only zeros the OTHER mode's CVar (fxaa or msaa); it assumes the --- chosen mode's CVar is already non-zero (Blizzard's UI relies on a --- secondary "quality" dropdown the user fills in). For our inline --- editor without that dropdown, default the dependent CVar to a --- baseline so applying the parent actually turns AA on. +-- PROXY_ANTIALIASING's SetValue zeros the OTHER mode's CVar and +-- assumes the chosen mode's CVar is already non-zero. Without +-- Blizzard's quality dropdown to pre-fill, default the dependent CVar +-- to a baseline so applying the parent actually turns AA on. local PROXY_DEPENDENT_DEFAULTS = { PROXY_ANTIALIASING = function(value) if value == 1 and GetCVar and tonumber(GetCVar("ffxAntiAliasingMode")) == 0 then @@ -842,10 +787,8 @@ local PROXY_DEPENDENT_DEFAULTS = { end, } --- Settings whose own SetValue closure stages dependent Apply-flagged --- settings (e.g., PROXY_ANTIALIASING's closure calls --- aaSettings.fxaa:SetValue / aaSettings.msaa:SetValue). When we Apply --- the parent, those dependents stay staged unless we also commit them. +-- Parents whose SetValue closure stages Apply-flagged dependents; we +-- must commit the dependents along with the parent. local PROXY_DEPENDENTS = { PROXY_ANTIALIASING = { "PROXY_FXAA", "PROXY_MSAA", "PROXY_MSAA_ALPHA" }, } @@ -879,14 +822,10 @@ function BlizzOptionsSearch:ApplyVariable(variable) local depsFn = PROXY_DEPENDENT_DEFAULTS[variable] if depsFn then pcall(depsFn, pending) end - -- Settings flagged Revertable (monitor, resolution, display mode) - -- need Blizzard's CommitSettings pipeline so the - -- GAME_SETTINGS_TIMED_CONFIRMATION popup ("Accept new options? - -- Reverting in 8 seconds.") fires. The popup is the user's only - -- escape hatch when a change leaves the screen unusable. Our - -- direct SetValue(immediate=true) path skips that, so route - -- through Blizzard for these specifically. Push parent + any - -- staged dependents into SettingsPanel.modified first. + -- Revertable settings (monitor, resolution, display mode) need + -- Blizzard's CommitSettings pipeline so GAME_SETTINGS_TIMED_CONFIRMATION + -- fires; without it the user has no escape if the change breaks the + -- screen. SetValue(immediate=true) skips that popup. local revertable = HasFlag(settObj, Settings.CommitFlag.Revertable) if revertable and SettingsPanel and SettingsPanel.CommitSettings and SettingsPanel.modified then @@ -916,22 +855,15 @@ function BlizzOptionsSearch:ApplyVariable(variable) if settObj.SetValue then pcall(settObj.SetValue, settObj, pending, true) end - -- Parent's SetValue closure may have staged dependents (e.g., - -- AA_NONE clears both fxaa and msaa via dep:SetValue, which stages - -- when the dep itself is Apply-flagged). Commit those too. CommitStagedDependents(variable) pendingApplySettings[settObj] = nil FirePendingChanged() end --- Mirrors Blizzard's GAME_SETTINGS_CONFIRM_DISCARD popup so closing --- our search panel with pending Apply-flagged settings prompts the --- same three-button choice. Defined once at file load; the StaticPopup --- registry keeps it across reloads. --- Do NOT add `StaticPopupDialogs = StaticPopupDialogs or {}` here. The --- write to the global slot taints StaticPopupDialogs even when the --- value identity is preserved, and that taint propagates into --- ShowUIPanel / UIParentPanelManager, crashing later panel opens. +-- Mirror Blizzard's GAME_SETTINGS_CONFIRM_DISCARD popup. +-- DO NOT write `StaticPopupDialogs = StaticPopupDialogs or {}`: any +-- assignment to that global slot taints it (even preserving identity) +-- and the taint propagates into ShowUIPanel / UIParentPanelManager. if StaticPopupDialogs and not StaticPopupDialogs["EASYFIND_UNAPPLIED_SETTINGS"] then StaticPopupDialogs["EASYFIND_UNAPPLIED_SETTINGS"] = { text = SETTINGS_CONFIRM_DISCARD or @@ -953,8 +885,6 @@ if StaticPopupDialogs and not StaticPopupDialogs["EASYFIND_UNAPPLIED_SETTINGS"] end, OnButton3 = function() end, OnHide = function() - -- Restore search bar focus when the popup dismisses, no - -- matter the path (button click, ESC, or click-away). if ns.UI and ns.UI.RefocusSearchEditBox then ns.UI:RefocusSearchEditBox() end @@ -972,7 +902,6 @@ function BlizzOptionsSearch:RevertVariable(variable) if not sok or not settObj or settObj.pendingValue == nil then return end if settObj.Revert then pcall(settObj.Revert, settObj) end pendingApplySettings[settObj] = nil - -- Discard any dependents the parent staged. local deps = PROXY_DEPENDENTS[variable] if deps then for i = 1, #deps do @@ -986,9 +915,6 @@ function BlizzOptionsSearch:RevertVariable(variable) FirePendingChanged() end --- Walk one category and its subcategories, recording id by name and --- (where possible) by variable. SettingsPanel exposes the layout per --- category; the layout has GetInitializers which return setting rows. local function CrawlCategory(cat) if not cat or not cat.GetID or not cat.GetName then return end local catID = cat:GetID() @@ -997,14 +923,12 @@ local function CrawlCategory(cat) categoryIDByName[slower(catName)] = catID end - -- Walk initializers to discover which variables this category owns. if SettingsPanel and SettingsPanel.GetLayout then local lok, layout = pcall(SettingsPanel.GetLayout, SettingsPanel, cat) if lok and layout and layout.GetInitializers then local iok, inits = pcall(layout.GetInitializers, layout) if iok and inits then for _, init in ipairs(inits) do - -- Try several access paths since the mixin shape varies. local setting if init.GetSetting then local sok, s = pcall(init.GetSetting, init) @@ -1023,13 +947,8 @@ local function CrawlCategory(cat) if not categoryIDByVariable[v] then categoryIDByVariable[v] = catID end - -- Pull tooltip text from the initializer's - -- data.tooltip option (modern WoW stores - -- it there) if not already cached. Wrap - -- in pcall: data.tooltip can be a function - -- generator (returns string) or a string, - -- and data.options can be a function too, - -- so blind indexing raises in some clients. + -- data.tooltip / data.options can be string or + -- function; blind indexing raises in some clients. if settingTooltips[v] == nil then local tip pcall(function() @@ -1066,7 +985,6 @@ local function CrawlCategory(cat) end end - -- Recurse into subcategories. if cat.GetSubcategories then local sok, subs = pcall(cat.GetSubcategories, cat) if sok and type(subs) == "table" then @@ -1077,11 +995,7 @@ local function CrawlCategory(cat) end end --- One-time crawl of the live category tree. Idempotent: re-crawling --- after addons register late just fills in any new entries. Each --- CrawlCategory call is pcalled so a misbehaving category (third- --- party addon's setting registration, weird initializer shape) can't --- abort the entire crawl and starve SETTINGS_DATA of category ids. +-- Idempotent: re-crawl after late addon registrations fills in new entries. local function ResolveCategoryIDs() local list = GetSettingsCategoryList() if type(list) == "table" then @@ -1099,10 +1013,8 @@ local function ResolveCategoryIDs() end end --- Open the SettingsPanel (and load it lazily if needed). This is the --- prerequisite for Settings.OpenToCategory navigation actually showing --- the panel; on rare clients OpenToCategory alone doesn't trigger the --- frame to be shown if the panel was never visible this session. +-- Open SettingsPanel first; on some clients OpenToCategory alone +-- doesn't show the frame if the panel hasn't been visible this session. local function ShowSettings() if not SettingsPanel then return end if SettingsPanel:IsShown() then return end @@ -1113,8 +1025,6 @@ local function ShowSettings() end end --- Find category id by name (case-insensitive). If we haven't crawled --- yet, do so on demand. local function GetCategoryID(name) if not name or name == "" then return nil end local cached = categoryIDByName[slower(name)] @@ -1123,7 +1033,6 @@ local function GetCategoryID(name) return categoryIDByName[slower(name)] end --- Find category id that owns a given setting variable. local function GetCategoryIDForVariable(variable) if not variable then return nil end local cached = categoryIDByVariable[variable] @@ -1133,7 +1042,6 @@ local function GetCategoryIDForVariable(variable) end BlizzOptionsSearch.GetCategoryIDForVariable = GetCategoryIDForVariable --- Open settings panel to the named category. Returns true on success. local function OpenSettingsByName(name) local id = GetCategoryID(name) if not id then return false end @@ -1153,9 +1061,6 @@ local function GetSettingsScrollBox() and SettingsPanel.Container.SettingsList.ScrollBox end --- Highlight the visible row for an elementData in the settings list, so --- the user lands on a clearly-marked row instead of having to scan the --- whole panel for the thing they searched. local function HighlightFoundElement(scrollBox, elementData) if not scrollBox or not elementData then return end if not ns.Highlight or not ns.Highlight.HighlightFrame then return end @@ -1194,7 +1099,6 @@ local function FindSettingElement(dp, variable) return nil end --- Scroll to and highlight the row for a setting variable. local function ScrollToSettingVariable(variable) local scrollBox = GetSettingsScrollBox() if not scrollBox then return false end @@ -1208,7 +1112,6 @@ local function ScrollToSettingVariable(variable) end BlizzOptionsSearch.ScrollToSettingVariable = ScrollToSettingVariable --- Look up a binding's index from its action name (e.g., ACTIONBUTTON1). local function GetBindingIndexForAction(action) if not action or not GetNumBindings or not GetBinding then return nil end for i = 1, GetNumBindings() do @@ -1218,10 +1121,7 @@ local function GetBindingIndexForAction(action) return nil end --- Walk a SettingsKeybindingSection's child binding frames for one whose --- bindingIndex matches. KeyBindingFrameBindingTemplateMixin:Init stashes --- the initializer on self.initializer, so binding index lives at --- frame.initializer.data.bindingIndex. +-- Binding index lives at frame.initializer.data.bindingIndex. local function FindBindingFrameInSection(sectionFrame, bindingIndex) if not sectionFrame or not sectionFrame.Controls then return nil end for _, frame in ipairs(sectionFrame.Controls) do @@ -1237,8 +1137,6 @@ local function FindBindingFrameInSection(sectionFrame, bindingIndex) return nil end --- Scroll to a keybind: find the section by header name, expand it if --- collapsed, then highlight the binding row inside. local function ScrollToBindingAction(action, headerName) if not action then return false end local scrollBox = GetSettingsScrollBox() @@ -1248,8 +1146,7 @@ local function ScrollToBindingAction(action, headerName) local bindingIdx = GetBindingIndexForAction(action) - -- Locate the section element. Match by header name first; fall back - -- to any section whose bindingsCategories contains our index. + -- Match by header name first; fall back to bindingsCategories. local headerLower = headerName and slower(headerName) or nil local function matchSection(elementData) local inner = elementData and (elementData.data or elementData) @@ -1281,10 +1178,8 @@ local function ScrollToBindingAction(action, headerName) local alignBegin = ScrollBoxConstants and ScrollBoxConstants.AlignBegin scrollBox:ScrollToElementData(section, alignBegin) - -- Expand if collapsed, wait for the section's child frames to lay - -- out, then highlight the actual binding row. The section frame may - -- be recycled across passes, so re-fetch it inside the deferred - -- callback rather than capturing the first reference. + -- Re-fetch the section frame inside the deferred callback; it may + -- be recycled across passes. local function expandThenHighlight() local sectionFrame = scrollBox.FindFrame and scrollBox:FindFrame(section) if not sectionFrame then return end @@ -1309,36 +1204,20 @@ local function ScrollToBindingAction(action, headerName) end BlizzOptionsSearch.ScrollToBindingAction = ScrollToBindingAction --- Collect name/path entries. SETTINGS_DATA always produces entries --- regardless of whether the live Settings tree is available yet (the --- category id resolves lazily at click time). Top-level / subcategory --- entries from Settings.GetCategoryList are added on top when the --- registry is reachable. local function CollectEntries() local entries = {} - -- Best-effort: resolve catIDs against the live category tree. - -- Safe to call even if SettingsPanel hasn't been opened yet: - -- it just leaves the lookup tables empty. HandleStep retries - -- on demand when the user clicks an entry. ResolveCategoryIDs() - -- Treat the live registry as authoritative: if at least one curated - -- variable resolves, assume the registry is up and any unresolved - -- variable is a phantom entry (CVar removed from the live panel in - -- this client version). Skip those instead of injecting dead rows - -- whose click does nothing. If nothing resolved, the registry - -- isn't ready yet, so fall back to emitting all curated entries and - -- let the late re-pass at 3.0s prune. + -- If any curated variable resolves, treat the registry as ready + -- and skip unresolved variables (phantom CVars removed from this + -- client). If nothing resolved, emit all curated entries; the + -- 3.0s late re-pass will prune. local registryReady = false for var in pairs(categoryIDByVariable) do if var then registryReady = true break end end - -- Curated individual settings (Auto Loot, Sticky Targeting, etc.). - -- These run unconditionally so they're always searchable, even on - -- a clean install where Settings.GetCategoryList isn't ready until - -- the user opens the panel. for si = 1, #SETTINGS_DATA do local row = SETTINGS_DATA[si] local name, var, catName, typeCode = row[1], row[2], row[3], row[4] @@ -1357,7 +1236,6 @@ local function CollectEntries() keywordsLower = kw, category = "Game Settings", path = { "Game Settings", catName }, - -- Icon comes from category="Game Settings" cogwheel routing in UI.lua. settingsCategory = catName, settingCategoryID = catID, settingVariable = var, @@ -1376,9 +1254,6 @@ local function CollectEntries() end end - -- Top-level + subcategory entries from the live registry. Optional: - -- if the categories aren't ready yet, the curated entries above are - -- still present. local list = GetSettingsCategoryList() if type(list) ~= "table" then return entries end @@ -1396,7 +1271,6 @@ local function CollectEntries() keywords = kw, keywordsLower = kw, category = "Game Settings", - -- Icon comes from category="Game Settings" cogwheel routing in UI.lua. settingsCategory = catName, settingCategoryID = catID, steps = { { settingsCategory = catName, settingCategoryID = catID } }, @@ -1421,11 +1295,8 @@ local function CollectEntries() return entries end --- Walk WoW's binding table and emit one search entry per binding. --- WoW exposes bindings via GetNumBindings + GetBinding(index): each --- row is either a header (skip) or a real binding (command, category, --- key1, key2, ...). The localized display name lives in the global --- BINDING_NAME_; the localized category in BINDING_HEADER_. +-- Localized name lives at _G["BINDING_NAME_"..action]; category at +-- _G["BINDING_HEADER_"..category]. local function CollectKeybindings() local entries = {} if not GetNumBindings or not GetBinding then return entries end @@ -1436,8 +1307,6 @@ local function CollectKeybindings() for i = 1, n do local action, category = GetBinding(i) if action and (action == "HEADER_BLANK" or action:find("^HEADER_")) then - -- Header rows: stash the localized header text; falls back - -- to the raw category if the global isn't populated. local headerKey = "BINDING_HEADER_" .. (category or action:sub(8)) local headerLoc = _G[headerKey] if type(headerLoc) == "string" and headerLoc ~= "" then @@ -1477,12 +1346,6 @@ local function CollectKeybindings() end BlizzOptionsSearch.CollectKeybindings = CollectKeybindings --- Inspect a category's initializers and emit one inline entry per --- setting it owns. Returns inline entries for sliders / checkboxes / --- dropdowns and a flag when the category had no inline-friendly --- settings (so the caller still emits the category entry as a --- fallback). The mixin shape varies between addons; everything is --- pcalled so a malformed initializer doesn't abort the walk. local function WalkCategorySettings(cat, catName, catID, pathPrefix) local out = {} if not (cat and SettingsPanel and SettingsPanel.GetLayout) then @@ -1493,12 +1356,9 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) local iok, inits = pcall(layout.GetInitializers, layout) if not iok or not inits then return out end - -- Combined initializers from CreateSettingsCheckboxSliderInitializer - -- ("Use UI Scale" with the checkbox + companion slider in one row) - -- bundle two settings into one initializer at init.data.cbSetting + - -- init.data.sliderSetting. We emit a single "checkboxSlider" entry - -- so the row mirrors Blizzard's combined widget instead of showing - -- two separate searchable rows. + -- Combined checkbox+slider initializers (e.g. "Use UI Scale") + -- bundle two settings under init.data.cbSetting/sliderSetting. + -- Emit a single entry instead of two. local function inspectInit(init) local d = init.data if type(d) == "table" and d.cbSetting and d.sliderSetting then @@ -1535,9 +1395,7 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) if type(opts) == "table" then sMin = opts.minValue sMax = opts.maxValue - -- Settings.CreateSliderOptions stores steps as the - -- NUMBER OF STEPS across the range, not the per-tick - -- delta. Convert to step size: (max - min) / steps. + -- opts.steps = number of steps, not per-tick delta. if opts.stepSize then sStep = opts.stepSize elseif opts.steps and opts.steps > 0 and sMax > sMin then @@ -1588,10 +1446,6 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) local vok, variable = pcall(setting.GetVariable, setting) local nok, settingName = pcall(setting.GetName, setting) if vok and variable and nok and settingName and settingName ~= "" then - -- Detect type from initializer shape and setting metadata. - -- Slider initializers expose a SliderOptions table on - -- init.data.options; checkbox initializers have a boolean - -- variable type; everything else falls into "dropdown". local resolvedType, sMin, sMax, sStep, sFmt local d = init.data local opts = (type(d) == "table") and d.options or nil @@ -1606,9 +1460,7 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) resolvedType = "slider" sMin = opts.minValue sMax = opts.maxValue - -- Settings.CreateSliderOptions stores steps as the - -- NUMBER OF STEPS across the range, not the per-tick - -- delta. Convert to step size: (max - min) / steps. + -- opts.steps = number of steps, not per-tick delta. if opts.stepSize then sStep = opts.stepSize elseif opts.steps and opts.steps > 0 and sMax > sMin then @@ -1616,11 +1468,8 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) else sStep = 1 end - -- Slider display value goes through Blizzard's - -- formatter (uiScale 0.64 -> "64%", etc.). Labels - -- are keyed by MinimalSliderWithSteppersMixin.Label - -- (Left=0, Right=1, Top=2, Min=3, Max=4); Top - -- mirrors the live value above the thumb best. + -- Formatter label keys: Left=0, Right=1, Top=2, + -- Min=3, Max=4. Top mirrors the live value best. if type(opts.formatters) == "table" then sFmt = opts.formatters[2] or opts.formatters[1] or opts.formatters[0] or opts.formatters.Top @@ -1640,11 +1489,6 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) end if not resolvedType then resolvedType = "dropdown" end - -- Capture dropdown option list so the row can cycle - -- through values inline. Shape varies: array of - -- { value, label } / { value, text } / Selections-style - -- objects. We normalize to { value, label } pairs and - -- skip if the table doesn't look enumerable. local settingOptions if resolvedType == "dropdown" then settingOptions = GetInitializerOptions(init, setting) @@ -1687,9 +1531,6 @@ local function WalkCategorySettings(cat, catName, catID, pathPrefix) return out end --- True iff cat belongs to the AddOns tab. Modern Settings categories --- expose GetCategorySet() returning a Settings.CategorySet enum value; --- if that's missing we fall back to checking a categorySet field. local function IsAddonCategory(cat) if not cat then return false end local addonSet = Settings and Settings.CategorySet and Settings.CategorySet.AddOns @@ -1701,15 +1542,9 @@ local function IsAddonCategory(cat) if set == nil then set = cat.categorySet end if set == nil then return false end if addonSet ~= nil then return set == addonSet end - -- Older clients use a string or numeric tag return set == "AddOns" or set == 2 end --- Walk the AddOns tab of the SettingsPanel and emit: --- 1. one navigable entry per addon category (opens the panel), and --- 2. one inline entry per individual setting inside each category --- (so addon checkboxes / sliders are toggleable from the search --- results just like Game Options). local function CollectAddonCategories() local entries = {} if not Settings then return entries end @@ -1724,10 +1559,6 @@ local function CollectAddonCategories() local catName = cat:GetName() if not catName or catName == "" then return end local catNameLower = slower(catName) - -- Path is rooted at " Settings" (e.g. "BugSack Settings") - -- instead of "AddOn Settings > ". The latter wastes a - -- whole row level on a constant string. For nested categories - -- the subcategory name follows: "BugSack Settings > Tooltip". local rootName = (parentName or catName) .. " Settings" local pathPrefix = parentName and { rootName, catName } or { rootName } @@ -1739,8 +1570,6 @@ local function CollectAddonCategories() keywords = kw, keywordsLower = kw, category = "AddOn Settings", - -- Top-level addon: no path (the name itself reads as the - -- addon). Subcategory: path is the parent's "X Settings". path = parentName and { rootName } or nil, settingsCategory = catName, settingCategoryID = catID, @@ -1751,9 +1580,6 @@ local function CollectAddonCategories() for _, e in ipairs(inline) do tinsert(entries, e) end end - -- Try the typed accessor first (modern WoW exposes a category-set - -- arg on GetCategoryList). Fall back to walking everything and - -- filtering via IsAddonCategory. local gotTyped = false if Settings.CategorySet and Settings.GetCategoryList then local ok, list = pcall(Settings.GetCategoryList, Settings.CategorySet.AddOns) @@ -1794,11 +1620,8 @@ local function CollectAddonCategories() end BlizzOptionsSearch.CollectAddonCategories = CollectAddonCategories --- Hardcoded option lists for graphics-quality dropdowns whose options --- live inside SettingsAdvancedQualityControlsMixin's private closures --- (GetShadowQualityOptions etc.). Mirror Blizzard's Graphics.lua: same --- value sets, same localized label globals, same hardware-validity --- filter via IsGraphicsSettingValueSupported. +-- Graphics-quality dropdown options live in private closures +-- (GetShadowQualityOptions etc.). Mirror Blizzard's Graphics.lua. local function MakeQualityOpts(cvar, raid, optionDefs) return { cvar = cvar, raid = raid, optionDefs = optionDefs } end @@ -1853,12 +1676,6 @@ local function BuildQualityOptions(spec) return out end --- Walk Game (non-AddOn) categories for individual settings the curated --- SETTINGS_DATA list doesn't surface (Use UI Scale, vsync, etc.). The --- live registry is authoritative for what's actually in the panel --- this build, so this captures settings Blizzard added without us --- touching the curated list. Skip variables already in SETTINGS_DATA --- so we don't double-emit. local function BuildCuratedVariableSet() local set = {} for i = 1, #SETTINGS_DATA do @@ -1906,13 +1723,9 @@ local function CollectGameSettings() end end end - -- Fallback pass: SettingsPanel.settings holds every Setting object - -- registered via Settings.RegisterCVar/Proxy/AddOn. Custom container - -- initializers (Graphics Quality's BaseQualityControls, which bundles - -- Shadow Quality / Liquid Detail / etc. into one frame) hide their - -- child settings from layout:GetInitializers, so the walk above - -- misses them. Pull them straight from the registry as basic - -- navigation entries: click the row, panel opens to the category. + -- Custom container initializers (e.g. BaseQualityControls) hide + -- their child settings from layout:GetInitializers. Pull them + -- straight from SettingsPanel.settings as navigation entries. if SettingsPanel and SettingsPanel.settings then for setting, cat in pairs(SettingsPanel.settings) do if cat and not IsAddonCategory(cat) and setting.GetVariable then @@ -1920,12 +1733,8 @@ local function CollectGameSettings() if vok and variable and not emittedVars[variable] then local nok, settingName = pcall(setting.GetName, setting) if nok and settingName and settingName ~= "" then - -- Base + Raid variants of each Graphics Quality - -- setting share the same display name with - -- different proxy variables (PROXY_SHADOW_QUALITY - -- vs the raid one). Suffix the raid version so - -- the search results don't show two identical - -- rows. + -- Base + Raid graphics quality settings share the + -- same display name; suffix the raid version. local displayName = settingName local lowerVar = slower(variable) if lowerVar:find("raid", 1, true) then @@ -1935,12 +1744,8 @@ local function CollectGameSettings() local catID = cat.GetID and cat:GetID() local nameLower = slower(displayName) local kw = { "setting", "option", "config", nameLower, slower(catName) } - -- Booleans render with the inline checkbox row. - -- Known graphics-quality dropdowns get a hardcoded - -- option list so the inline dropdown widget works. - -- Other non-boolean orphans fall back to "open the - -- panel on click" since we can't enumerate their - -- options from outside the custom mixin. + -- Non-boolean orphans without a hardcoded option + -- list fall back to "open the panel on click". local resolvedType, settingOptions local qspec = HARDCODED_OPTIONS[variable] if qspec then @@ -1977,8 +1782,6 @@ local function CollectGameSettings() end BlizzOptionsSearch.CollectGameSettings = CollectGameSettings --- Register the collected entries into the Database. Called once --- after PLAYER_LOGIN so Settings.* is fully populated. function BlizzOptionsSearch:Populate() if not ns.Database or not ns.Database.uiSearchData then return end local entries = CollectEntries() @@ -2001,13 +1804,10 @@ function BlizzOptionsSearch:Populate() if ns.Database.ResetSearchCache then ns.Database:ResetSearchCache() end end --- Step handler: open the Settings panel to the cached category id and --- (when given) scroll to the specific setting row. function BlizzOptionsSearch:HandleStep(step) if not step then return false end - -- Prefer the cached id baked into the entry. Fall back to live - -- lookup so old SavedVariables-pinned entries still work. + -- Prefer the cached id; live lookup is fallback for old saves. local catID = step.settingCategoryID if not catID and step.settingVariable then catID = GetCategoryIDForVariable(step.settingVariable) @@ -2016,9 +1816,6 @@ function BlizzOptionsSearch:HandleStep(step) catID = GetCategoryID(step.settingsCategory) end - -- Show the panel first. OpenToCategory in modern WoW is supposed - -- to do this itself, but doing it explicitly first ensures the - -- frame is up before navigation runs. ShowSettings() if catID and Settings and Settings.OpenToCategory then @@ -2029,8 +1826,7 @@ function BlizzOptionsSearch:HandleStep(step) end end - -- Belt-and-suspenders scroll: some clients accept the second arg - -- to OpenToCategory, others ignore it. Scroll manually next frame. + -- Some clients ignore the second arg to OpenToCategory; scroll manually. if step.settingVariable then SafeAfter(0, function() ScrollToSettingVariable(step.settingVariable) end) SafeAfter(0.1, function() ScrollToSettingVariable(step.settingVariable) end) @@ -2043,9 +1839,6 @@ function BlizzOptionsSearch:HandleStep(step) return catID ~= nil end --- Schedule registration after PLAYER_LOGIN so Settings.GetCategoryList --- has the full tree (some addons register late). Two passes catch --- any stragglers that register on first frame. local registered = false local function Register() if registered then return end @@ -2058,9 +1851,7 @@ f:RegisterEvent("PLAYER_LOGIN") f:SetScript("OnEvent", function() SafeAfter(0.5, Register) SafeAfter(3.0, function() - -- Re-collect after a longer delay to pick up addons that - -- register their settings categories during the first few - -- seconds. Uses a name-based dedupe so we don't double up. + -- Re-collect to pick up addons that register categories late. local seen = {} for _, e in ipairs(ns.Database.uiSearchData or {}) do if e.settingsCategory and not e.settingVariable then @@ -2070,8 +1861,7 @@ f:SetScript("OnEvent", function() ResolveCategoryIDs() local fresh = CollectEntries() for _, e in ipairs(fresh) do - -- Skip individual settings: they were already injected - -- on the first pass and CollectEntries always re-emits them. + -- Skip per-variable entries already injected in pass 1. if not e.settingVariable and not seen[e.settingsCategory] then tinsert(ns.Database.uiSearchData, e) end diff --git a/Core.lua b/Core.lua index 74b0999..4b5ea7d 100644 --- a/Core.lua +++ b/Core.lua @@ -10,7 +10,7 @@ local ErrorHandler = Utils.ErrorHandler EasyFind = {} ns.EasyFind = EasyFind -EasyFind._ns = ns -- Expose namespace for dev tools (EasyFindDev) +EasyFind._ns = ns BINDING_NAME_EASYFIND_TOGGLE_FOCUS = "Toggle Search Bar" BINDING_NAME_EASYFIND_CLEAR = "Clear All Highlights" @@ -21,60 +21,58 @@ ns.eventFrame = eventFrame EasyFind.db = {} --- SavedVariables version. Increment when changing DB schema. --- Each migration runs once: if saved dbVersion < DB_VERSION, run all steps in order. +-- Increment when DB schema changes; migrations [N] run when savedVersion < N. local DB_VERSION = 17 local REVAMPED_TUTORIAL_VERSION = "2.0.0" ns.REVAMPED_TUTORIAL_VERSION = REVAMPED_TUTORIAL_VERSION --- SavedVariables defaults - new keys are auto-merged for existing users local DB_DEFAULTS = { dbVersion = DB_VERSION, visible = true, enableMapSearch = true, iconScale = 0.8, - nativePinScale = 1.5, -- Multiplier applied to a Blizzard map pin while EasyFind is glowing it + nativePinScale = 1.5, uiSearchScale = 1.0, - uiSearchWidth = 1.54, -- 0.88 * 1.75: results dropdown matches bar width now + uiSearchWidth = 1.54, uiResultsScale = 1.0, uiResultsWidth = 350, uiSearchBarHeight = ns.SEARCHBAR_HEIGHT or 30, - fontSize = 0.9, -- UI search font size multiplier (0.5-2.0) - uiSearchPosition = nil, -- {point, relPoint, x, y} + fontSize = 0.9, + uiSearchPosition = nil, localMapDirectOpen = true, globalMapDirectOpen = true, - autoHide = true, -- Raycast-style: bar starts hidden; bind opens, click-out hides - smartShow = false, -- Hide search bar until mouse hovers nearby (legacy alternate to autoHide) - lockPosition = false, -- Disable drag-to-move on the search bar - tutorialDone = false, -- True once the user has finished the onboarding wizard - resultsTheme = "Modern", -- legacy; only "Modern" ships right now - font = "Default", -- "Default" (Friz Quadrata) or "Inter" - indicatorStyle = "EasyFind Arrow", -- Indicator texture style - indicatorColor = "Yellow", -- Indicator color preset - uiResultsHeight = 280, -- Visible height of UI search results panel in pixels - showTruncationMessage = true, -- Show "more results available" message when truncated - hardResultsCap = false, -- Hard cap on results (no "more results" message) - 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) - mapPinsCollapsed = false, -- Whether the map search "Pinned" header is collapsed - showLoginMessage = false, -- Show "EasyFind loaded!" message on login - showAliasMessages = true, -- Show a short chat note when adding a search alias - blinkingPins = false, -- Pulse map pins and highlights in sync with indicator bob - mapPinHighlight = true, -- Show yellow highlight box around map pins - autoPinClear = true, -- Auto-clear map pin when player arrives - autoTrackPins = true, -- Auto super-track newly placed map pins - uiResultsAbove = false, -- Show UI search results above the search bar - showResultShortcutHints = true, -- Show Alt+number hints on UI search results - showMinimapButton = true, -- Show toggle button on minimap - minimapButtonAngle = 200, -- Position angle (degrees) around minimap edge - globalSearchFilters = { -- Global search category filters (all enabled by default) + autoHide = true, + smartShow = false, + lockPosition = false, + tutorialDone = false, + resultsTheme = "Modern", + font = "Default", + indicatorStyle = "EasyFind Arrow", + indicatorColor = "Yellow", + uiResultsHeight = 280, + showTruncationMessage = true, + hardResultsCap = false, + pinnedUIItems = {}, + pinnedUIItemsPerChar = {}, + pinnedMapItems = {}, + mapPinsCollapsed = false, + showLoginMessage = false, + showAliasMessages = true, + blinkingPins = false, + mapPinHighlight = true, + autoPinClear = true, + autoTrackPins = true, + uiResultsAbove = false, + showResultShortcutHints = true, + showMinimapButton = true, + minimapButtonAngle = 200, + globalSearchFilters = { zones = true, dungeons = true, raids = true, delves = true, }, - localSearchFilters = { -- Local (zone) search category filters (all enabled by default) + localSearchFilters = { instances = true, travel = true, services = true, @@ -84,16 +82,16 @@ local DB_DEFAULTS = { zones = true, instances = true, flightpath = false, - travel = true, -- Portals, ships, zeppelins, trams (separate from flight paths) + travel = true, services = true, rares = true, }, - mapTabRecentSearches = {}, -- Most-recent-first list of past map search queries - mapTabShowRecent = true, -- Toggle for showing recent searches when idle - mapTabRecentCount = 3, -- Number of recent searches to keep / display (1-20) - mapTabAutoExpand = true, -- Auto-expand a matched parent header to show all its world-hierarchy children - alwaysShowRares = false, -- Persistent rare tracking: show active rares on map without searching - uiSearchFilters = { -- UI search category filters (all enabled by default) + mapTabRecentSearches = {}, + mapTabShowRecent = true, + mapTabRecentCount = 3, + mapTabAutoExpand = true, + alwaysShowRares = false, + uiSearchFilters = { achievements = true, statistics = false, currencies = true, @@ -118,40 +116,28 @@ local DB_DEFAULTS = { titles = true, map = true, }, - 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 + lootSpecs = nil, + lootSearchSlots = true, + lootSearchStats = true, + lootUpgradesOnly = false, lootDifficulty = "normal", - -- Per-flyout "Hide tooltips" toggles. Keys mirror filter top-level - -- groups; when true, OnEnter on rows in that group skips the - -- gear/item tooltip entirely. hideTooltips = { - collections = false, -- mounts/toys/pets/outfits/heirlooms/appearance sets - loot = false, -- gear search results + collections = false, + loot = false, }, - -- Currencies filter mode (mirrors the in-game CurrencyFrame - -- dropdown). "all" = show every currency the character has; - -- "warband" = only surface warband-transferable currencies. currencyFilterMode = "all", - -- Reputation filter mode (mirrors ReputationFrame's dropdown). - -- "all" / "warband" / "char". Persisted independently from - -- showLegacyReputations because Blizzard treats them as separate - -- filter axes. reputationFilterMode = "all", showLegacyReputations = false, - -- Spellbook ability filter: when true, hides passive abilities in - -- both EasyFind's results and Blizzard's spellbook page. abilityHidePassives = false, - appearanceSetClass = nil, -- nil = player class, "all" = all, {classID=N} = specific - appearanceSetCollected = true, -- Show collected sets - appearanceSetNotCollected = true, -- Show uncollected sets - appearanceSetPvE = true, -- Show PvE sets (Dungeon/Raid) - appearanceSetPvP = true, -- Show PvP sets - uiMapSearchLocal = true, -- Map search in UI bar: true = local zone only, false = global - aliases = {}, -- User-defined search aliases: { [aliasText] = { kind, id, name } } - uiSearchHistory = {}, -- Shell-style search history (most recent at index 1, capped at uiSearchHistoryLimit) - uiSearchHistoryLimit = 500, -- Bash HISTSIZE default + appearanceSetClass = nil, + appearanceSetCollected = true, + appearanceSetNotCollected = true, + appearanceSetPvE = true, + appearanceSetPvP = true, + uiMapSearchLocal = true, + aliases = {}, + uiSearchHistory = {}, + uiSearchHistoryLimit = 500, } local function RequireRevampedTutorial(db) @@ -171,9 +157,7 @@ local function CloneDefaultValue(value) return copy end --- Keys preserved across the 2.0 settings reset: schema version, --- onboarding state, and user-authored data (history, aliases, recents). --- Everything else in DB_DEFAULTS gets restored to its default value. +-- Keys preserved across the 2.0 settings reset; all others restored to defaults. local PRESERVED_KEYS = { dbVersion = true, tutorialDone = true, @@ -231,7 +215,6 @@ local function ApplyFreshSettingsFor2(db) end local DB_MIGRATIONS = { - -- [1] = Consolidate ad-hoc migrations (maxResults rename, uiResultsWidth reset) [1] = function(db) if db.maxResults then if not db.uiMaxResults then db.uiMaxResults = db.maxResults end @@ -240,11 +223,9 @@ local DB_MIGRATIONS = { end if db.uiResultsWidth == 1.0 then db.uiResultsWidth = 300 end end, - -- [2] = Widen default UI results panel from 300 to 350 [2] = function(db) if db.uiResultsWidth == 300 then db.uiResultsWidth = 350 end end, - -- [3] = Replace row-count settings with pixel height [3] = function(db) if not db.uiResultsHeight then db.uiResultsHeight = db.uiMaxResults and (db.uiMaxResults * 28) or 280 @@ -252,69 +233,41 @@ local DB_MIGRATIONS = { db.uiMaxResults = nil db.mapMaxResults = nil end, - -- [4] = Combined search bar + results dropdown silhouette. The - -- results panel now matches the bar's width directly, so the - -- old 0.88 default would render the bar (and therefore the - -- dropdown) too narrow. Bump uiSearchWidth ~1.75x for everyone - -- whose width is at or below the old default; users who have - -- explicitly widened it past the old default keep their value. [4] = function(db) local w = db.uiSearchWidth if w == nil or w <= 0.88 then db.uiSearchWidth = 1.54 end end, - -- [5] = Restore direct map navigation defaults. [5] = function(db) if db.localMapDirectOpen == false then db.localMapDirectOpen = true end if db.globalMapDirectOpen == false then db.globalMapDirectOpen = true end end, - -- [6] = Populate the split flightpath filter key. Flight paths default - -- off in the Map tab because they are noisy compared to deliberate - -- travel/location searches. [6] = function(db) if db.mapTabFilters and db.mapTabFilters.flightpath == nil then db.mapTabFilters.flightpath = false end end, - -- [8] = Theme rename: "Classic" and "Retail" renamed to - -- "Modern" (the new default), and "Retail" reused for the parchment - -- variant. Existing saves on the old "Retail" or "Classic" values - -- get pointed at "Modern" so nothing changes for them visually until - -- they pick the new "Retail" themselves. [8] = function(db) if db.resultsTheme == "Retail" or db.resultsTheme == "Classic" then db.resultsTheme = "Modern" end end, - -- [9] = Remove options menu opacity setting. The redesigned options - -- panel uses a fixed alpha to keep the Raycast-style shell consistent. [9] = function(db) db.panelOpacity = nil end, - -- [10] = Persist search bar height separately from font size so - -- window resizing can scale the bar strip without changing text size. [10] = function(db) if not db.uiSearchBarHeight then db.uiSearchBarHeight = ns.SEARCHBAR_HEIGHT or 30 end end, - -- [11] = 2.0.0 is a full onboarding reset. Existing users should - -- see the tutorial once instead of the legacy What's New popup. [11] = function(db) RequireRevampedTutorial(db) end, - -- [12] = Search window background is always solid now. [12] = function(db) db.searchBarOpacity = nil end, - -- [13] = Final 2.0 onboarding gate. Force the revamped tutorial - -- once for existing account-wide SavedVariables, and mark 2.0.0 - -- as seen so the legacy What's New popup cannot compete with it. [13] = function(db) RequireRevampedTutorial(db) end, - -- [14] = Finalize search opacity and map-tab defaults for 2.0. - -- Search opacity is no longer user-controlled, and flight paths start - -- hidden in the Map tab unless the user turns them back on. [14] = function(db) db.staticOpacity = nil db.searchBarOpacity = nil @@ -323,14 +276,9 @@ local DB_MIGRATIONS = { db.mapTabFilters.flightpath = false end end, - -- [15] = Make the login chat message opt-in for 2.0. [15] = function(db) db.showLoginMessage = false end, - -- [16] = Backfill the "already acknowledged revamped tutorial" - -- marker for accounts that completed the 2.0 tutorial on a dev - -- build before the marker existed. Accounts still pending the - -- tutorial keep tutorialDone=false and will see it once. [16] = function(db) if db.tutorialDone == true and db.lastSeenVersion == REVAMPED_TUTORIAL_VERSION @@ -338,16 +286,11 @@ local DB_MIGRATIONS = { db.revampedTutorialVersion = REVAMPED_TUTORIAL_VERSION end end, - -- [17] = 2.0 is a release-scale redesign, not a normal incremental - -- update. Reset the released-era layout/options/filter/pin state to the - -- new defaults and remove retired settings so old SavedVariables cannot - -- keep stale search bars, opacity controls, or disabled categories. [17] = function(db) ApplyFreshSettingsFor2(db) end, } --- Fields that are runtime-only and must not persist in SavedVariables local RUNTIME_FIELDS = { "firstInstall", } @@ -432,7 +375,6 @@ local function OnInitialize() end end - -- Run sequential migrations for v = savedVersion + 1, DB_VERSION do if DB_MIGRATIONS[v] then DB_MIGRATIONS[v](EasyFindDB) @@ -440,7 +382,6 @@ local function OnInitialize() end EasyFindDB.dbVersion = DB_VERSION - -- Reset values whose type doesn't match the default for k, v in pairs(EasyFindDB) do local default = DB_DEFAULTS[k] if default ~= nil and type(v) ~= type(default) then @@ -450,10 +391,8 @@ local function OnInitialize() EasyFind.db = EasyFindDB - -- Read version from TOC for What's New detection ns.version = C_AddOns.GetAddOnMetadata(ADDON_NAME, "Version") - -- Primary slash command SLASH_EASYFIND1 = "/ef" SlashCmdList["EASYFIND"] = function(msg) msg = msg and msg:lower():trim() or "" @@ -465,7 +404,6 @@ local function OnInitialize() elseif msg == "c" or msg == "clear" then EasyFind:ClearAll() elseif msg:find("^test ") then - -- /ef test Interface\\Path\\To\\Texture local texture = msg:match("^test%s+(.+)") if texture then EasyFind:TestIndicatorTexture(texture) @@ -605,12 +543,8 @@ local function InstallTransmogClassFilterHook() end) end --- Lazy dynamic load: pulled the moment the user opens the search bar (or any --- entry point that surfaces dynamic results). Other search-style addons follow --- the same shape: nothing is scanned at PLAYER_LOGIN, so the player never sees --- a post-load-screen stutter from us. --- Calling repeatedly is safe: LoadDeferredSyncProvidersStaggered skips --- providers that are loaded-and-clean, so re-entry only refreshes dirty ones. +-- Lazy dynamic load pulled when the user opens the search bar. Safe to +-- call repeatedly; loaded-and-clean providers are skipped. function EasyFind:EnsureDynamicLoaded() if not ns.Database then return end if ns.Database.LoadDeferredSyncProvidersStaggered then @@ -623,8 +557,7 @@ function EasyFind:EnsureDynamicLoaded() ns.Database:LoadHeavyDynamicSearchDataSync() end) end - -- Late-arriving APIs (Wardrobe, Heirlooms) sometimes aren't ready in - -- the first pass. Re-trigger after they've had time to populate. + -- Wardrobe / Heirlooms APIs sometimes aren't ready first pass. SafeAfter(3.0, function() if ns.Database.LoadDeferredSyncProvidersStaggered then ns.Database:LoadDeferredSyncProvidersStaggered() @@ -634,9 +567,6 @@ function EasyFind:EnsureDynamicLoaded() end local function OnPlayerLogin() - -- Kick off the time-sliced statistics enumeration immediately - -- (2ms-per-tick budget, no load-screen block). Run it through the - -- provider manager so later heavy-load requests don't restart it. if ns.Database and ns.Database.EnsureDynamicProviderLoaded then ns.Database:EnsureDynamicProviderLoaded("statistics", function(changed) if changed and ns.Database.MarkDynamicProviderLoaded then @@ -674,11 +604,6 @@ local function OnPlayerLogin() end InstallTransmogClassFilterHook() - -- Pre-warm the search hot path eagerly: build the prefix index over - -- the static dataset immediately, then kick off the deferred dynamic - -- providers in the background so by the time the user opens the - -- search bar (post-login UI fade-in, minimap button click, etc.) the - -- index is ready and no first-keystroke build cost remains. if ns.Database then if ns.Database.WarmSearchHotPath then xpcall(ns.Database.WarmSearchHotPath, ErrorHandler, ns.Database) @@ -686,35 +611,29 @@ local function OnPlayerLogin() EasyFind:EnsureDynamicLoaded() end - -- Background-load boss entries directly shortly after - -- login so individual encounter names ("Professor Putricide") match - -- on the first search. Do not route this through the generic heavy - -- chain; boss results should not sit behind Statistics/Loot. + -- Load bosses directly (not behind the heavy chain) so single + -- encounter names match on the first search. SafeAfter(1.0, function() if ns.Database and ns.Database.EnsureDynamicProviderLoaded then ns.Database:EnsureDynamicProviderLoaded("bosses", function() end) end end) - -- Pre-warm Blizzard's achievement search index off the user's typing - -- path. The index build is the lag source we used to hit on the - -- first achievement-related search; doing it once in the background - -- here makes per-keystroke achievement results instant later. + -- Pre-warm Blizzard's achievement search index off the typing path. SafeAfter(2.0, function() if ns.UI and ns.UI.PrewarmAchievementSearch then xpcall(ns.UI.PrewarmAchievementSearch, ErrorHandler, ns.UI) end end) - -- Minimap button (delayed slightly so Minimap frame is ready) + -- Delay so Minimap is ready. SafeAfter(0.6, function() if EasyFind.db.showMinimapButton then EasyFind:UpdateMinimapButton() end end) - -- What's New popup: show once per version for returning users. - -- 2.0.0 uses the tutorial instead because the whole experience was rebuilt. + -- 2.0.0 routes returning users to the tutorial instead of What's New. local currentVersion = ns.version local lastSeen = EasyFind.db.lastSeenVersion if currentVersion and currentVersion ~= lastSeen then @@ -755,9 +674,7 @@ eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2) self.loginHandled = true self:UnregisterEvent("PLAYER_LOGIN") elseif event == "PLAYER_ENTERING_WORLD" then - -- arg1 = isInitialLogin, arg2 = isReloadingUI - -- PLAYER_LOGIN does not fire on UI reloads, so use PLAYER_ENTERING_WORLD - -- as a fallback to ensure modules initialize after /reload. + -- PLAYER_LOGIN does not fire on /reload; arg2 = isReloadingUI. if arg2 and not self.loginHandled then OnPlayerLogin() end @@ -798,7 +715,6 @@ eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2) MarkDynamicCategoryDirty("gearSets") end) elseif event == "PLAYER_LOGOUT" then - -- Strip runtime-only fields before SavedVariables serialization if EasyFindDB then for _, field in ipairs(RUNTIME_FIELDS) do EasyFindDB[field] = nil @@ -858,7 +774,6 @@ function EasyFind:Print(msg) end function EasyFind:TestIndicatorTexture(texturePath) - -- Create a test frame to preview the texture local testFrame = _G["EasyFindTextureTest"] or CreateFrame("Frame", "EasyFindTextureTest", UIParent, "BackdropTemplate") testFrame:SetSize(256, 256) testFrame:SetPoint("CENTER") @@ -887,7 +802,6 @@ function EasyFind:TestIndicatorTexture(texturePath) testFrame.closeBtn:SetPoint("TOPRIGHT", -5, -5) end - -- Try to load the texture testFrame.texture:SetTexture(texturePath) testFrame.texture:SetVertexColor(ns.YELLOW_HIGHLIGHT[1], ns.YELLOW_HIGHLIGHT[2], ns.YELLOW_HIGHLIGHT[3], 1) testFrame.title:SetText("Testing: " .. texturePath) @@ -899,7 +813,6 @@ end local minimapButton --- Minimap shape quadrant table for non-round minimap support local minimapShapes = { ["ROUND"] = {true, true, true, true}, ["SQUARE"] = {false, false, false, false}, @@ -922,7 +835,6 @@ local function PositionMinimapButton(angle) local rad = mrad(angle) local cx, cy = mcos(rad), msin(rad) - -- Determine quadrant (1-4) local q = 1 if cx < 0 then q = q + 1 end if cy > 0 then q = q + 2 end @@ -935,10 +847,8 @@ local function PositionMinimapButton(angle) local x, y if quadTable[q] then - -- Rounded quadrant - place on circle x, y = cx * w, cy * h else - -- Squared quadrant - clamp to rectangle edge local dw = msqrt(2 * w * w) - 10 local dh = msqrt(2 * h * h) - 10 x = mmax(-w, mmin(cx * dw, w)) @@ -975,11 +885,9 @@ local function CreateMinimapButton() mmBtn:SetHighlightTexture(136477) mmBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp") - -- The search bar's autoHide handler fires on GLOBAL_MOUSE_DOWN before - -- our OnClick (which runs on mouseUp). Without these flags it would - -- close the bar first, then OnClick's toggle would see a hidden bar - -- and re-open it -- net effect: the click does nothing. Setting the - -- flag synchronously in OnMouseDown lets autoHide skip this click. + -- autoHide fires on GLOBAL_MOUSE_DOWN before our OnClick (mouseUp). + -- Set the flag synchronously in OnMouseDown so autoHide can skip + -- this click, otherwise toggle re-opens what autoHide just closed. mmBtn:HookScript("OnMouseDown", function(self, button) if button == "LeftButton" then EasyFind._minimapClickActive = true @@ -1041,7 +949,6 @@ function EasyFind:UpdateMinimapButton() end end --- Addon compartment button (## AddonCompartmentFunc in TOC) function EasyFind_OnAddonCompartmentClick(_, button) if button == "LeftButton" then EasyFind:ToggleSearchUI() diff --git a/Database.lua b/Database.lua index c6debec..30e0531 100644 --- a/Database.lua +++ b/Database.lua @@ -12,12 +12,7 @@ local mmin, mmax, mabs = Utils.mmin, Utils.mmax, Utils.mabs local C_CurrencyInfo = C_CurrencyInfo local band, lshift = bit.band, bit.lshift --- Word split cache: avoids per-call gmatch + table creation in scoring hot path. --- Key = lowercase string, value = array of words split on [%w']+. --- FIFO-bounded so per-keystroke prefixes ("a", "ac", "ach", ...) do not --- accumulate forever. Most recent prefixes stay in cache; oldest evict --- when the ring buffer wraps. Cap of 256 covers normal typing patterns --- with room to spare while keeping retained memory in the low-KB range. +-- FIFO-bounded; oldest prefixes evict when the ring buffer wraps. local wordCache = {} local wordCacheKeys = {} local wordCacheHead = 1 @@ -39,21 +34,15 @@ local function GetWords(str) return words end --- Reusable row tables for DamerauLevenshtein (avoids 3 table allocs per call) local dlPrev2, dlPrev, dlCurr = {}, {}, {} --- Reusable scratch for ScoreName's per-word matching path. Was allocated --- per-entry per-search in the multi-word query branch (3700+ entries x --- many keystrokes per second = major GC pressure during typing). local scoreNameUsedWords = {} --- Reusable sort comparator (avoids closure creation per SearchUI call) local function scoreDescending(a, b) return a.score > b.score end local uiSearchData = {} -Database.uiSearchData = uiSearchData -- exposed for container expansion -Database._wordCache = wordCache -- exposed for DevMem diagnostics --- Track which currencyIDs are already in the static database +Database.uiSearchData = uiSearchData +Database._wordCache = wordCache local knownCurrencyIDs = {} local function RemoveEntriesByCategory(category) @@ -92,11 +81,8 @@ function Database:Initialize() self:BuildUIDatabase() end --- Defensive multi-spelling check for "is this currency warband-shared". --- WoW has shipped at least three names / shapes for this concept across --- builds; check all of them so detection doesn't regress when Blizzard --- renames a field. Reused by both the per-currency populator and the --- warband-fill pass. +-- WoW has shipped three names/shapes for warband-shared currency; check all so +-- detection doesn't regress when Blizzard renames a field. function Database:IsCurrencyAccountTransferable(currencyID) if not currencyID or not C_CurrencyInfo then return false end local fns = { @@ -121,16 +107,12 @@ function Database:IsCurrencyAccountTransferable(currencyID) return false end --- Called after PLAYER_LOGIN when C_CurrencyInfo is available --- Scans the WoW currency list and injects any currencies not already in the static database function Database:PopulateDynamicCurrencies() if not C_CurrencyInfo or not C_CurrencyInfo.GetCurrencyListSize then return false end RemoveEntriesByCategory("Currency") wipe(knownCurrencyIDs) - -- Expand all collapsed headers so we can see every currency - -- Track which ones we expand so we can collapse them back afterward local headersWeExpanded = {} for pass = 1, 50 do local size = C_CurrencyInfo.GetCurrencyListSize() @@ -147,36 +129,23 @@ function Database:PopulateDynamicCurrencies() if not didExpand then break end end - -- Read the full flat list and inject any missing currencies local size = C_CurrencyInfo.GetCurrencyListSize() local injected = 0 - -- The steps that every currency inherits (open Character frame + Currency tab) local baseSteps = { { buttonFrame = "CharacterMicroButton" }, { waitForFrame = "CharacterFrame", tabIndex = 3 }, } - -- Track the nested header stack so we generate correct multi-level steps. - -- Each element: { name = "Legacy", depth = 0 } - -- The currencyListDepth field (11.0.0+) controls indentation: - -- 0 = top-level header (Dungeon and Raid, Legacy, etc.) - -- 1 = sub-header (War Within under Legacy) - -- etc. - -- For currencies (non-header), depth indicates which header they belong to. - local headerStack = {} -- ordered list, headerStack[1] = shallowest - - -- Helper: trim the stack so it only contains entries shallower than `depth`, - -- then push a new header at that depth. + local headerStack = {} + local function pushHeader(name, depth) - -- Remove anything at depth >= the new header's depth while #headerStack > 0 and headerStack[#headerStack].depth >= depth do headerStack[#headerStack] = nil end headerStack[#headerStack + 1] = { name = name, depth = depth } end - -- Helper: build currencyHeader steps for the full header chain local function buildHeaderSteps() local steps = {} for _, s in ipairs(baseSteps) do steps[#steps + 1] = s end @@ -186,7 +155,6 @@ function Database:PopulateDynamicCurrencies() return steps end - -- Helper: build the path array for the current header chain local function buildPath() local path = {"Character Info", "Currency"} for _, h in ipairs(headerStack) do @@ -195,7 +163,6 @@ function Database:PopulateDynamicCurrencies() return path end - -- Build a currencyID → icon map from the list scan (GetCurrencyListInfo is reliable) local currencyIconMap = {} for i = 1, size do @@ -203,7 +170,6 @@ function Database:PopulateDynamicCurrencies() if info then local depth = info.currencyListDepth or 0 - -- Capture icon for every currency we see if info.currencyID and info.iconFileID then currencyIconMap[info.currencyID] = info.iconFileID end @@ -211,7 +177,6 @@ function Database:PopulateDynamicCurrencies() if info.isHeader then pushHeader(info.name, depth) - -- Also ensure the header group itself is searchable local headerKey = "header_" .. slower(info.name) if not knownCurrencyIDs[headerKey] then knownCurrencyIDs[headerKey] = true @@ -226,8 +191,6 @@ function Database:PopulateDynamicCurrencies() steps = buildHeaderSteps(), flashLabel = "Currency", } - -- The path for a header entry doesn't include itself - -- (buildPath includes it, so remove the last element) entry.path[#entry.path] = nil entry.nameLower = slower(entry.name) entry.keywordsLower = {} @@ -238,16 +201,13 @@ function Database:PopulateDynamicCurrencies() injected = injected + 1 end elseif info.currencyID and not knownCurrencyIDs[info.currencyID] then - -- This currency isn't in our static database - inject it local currName = info.name local immediateHeader = headerStack[#headerStack] local immediateHeaderName = immediateHeader and immediateHeader.name or "Unknown" - -- Build steps: base + expand all parent headers + scroll to currency local currSteps = buildHeaderSteps() currSteps[#currSteps + 1] = { waitForFrame = "CharacterFrame", currencyID = info.currencyID } - -- Generate keywords: currency name words + "header currname" local words = {} local currNameLower = slower(currName) for word in currNameLower:gmatch("%S+") do @@ -286,7 +246,6 @@ function Database:PopulateDynamicCurrencies() Utils.DebugPrint("Injected", injected, "dynamic currency entries from C_CurrencyInfo") end - -- Resolve icons for ALL currency entries (static + dynamic) using the map we just built for _, item in ipairs(uiSearchData) do if not item.icon and item.steps then for _, step in ipairs(item.steps) do @@ -298,8 +257,7 @@ function Database:PopulateDynamicCurrencies() end end - -- Collapse back any headers we expanded during scanning - -- Collapse from deepest first: iterate in reverse through the list + -- Collapse from deepest first (indices shift after each collapse). for pass = 1, 50 do local sz = C_CurrencyInfo.GetCurrencyListSize() local didCollapse = false @@ -315,13 +273,9 @@ function Database:PopulateDynamicCurrencies() if not didCollapse then break end end - -- Warband-fill pass: surface warband-transferable currencies the - -- player has on OTHER characters (or has never touched on any - -- character but still exist account-wide). Without this, the - -- "All Warband Transferable" filter mode only shows currencies - -- the current character has interacted with, defeating the point. - -- Enumerate via the modern C_CurrencyInfo accessor when available; - -- fall back to a bounded ID scan only if nothing else works. + -- Surface warband-transferable currencies the player has on OTHER characters. + -- Without this, the "All Warband Transferable" filter only shows currencies + -- the current character has touched. do local extraIDs if C_CurrencyInfo.GetAccountCurrencyTypes then @@ -366,14 +320,11 @@ function Database:PopulateDynamicCurrencies() return true end --- Called after PLAYER_LOGIN when C_Reputation is available --- Scans the WoW reputation list and injects factions as searchable entries function Database:PopulateDynamicReputations() if not C_Reputation or not C_Reputation.GetNumFactions then return false end RemoveEntriesByCategory("Reputation") - -- Expand all collapsed headers so we can see every faction local headersWeExpanded = {} for pass = 1, 50 do local numFactions = C_Reputation.GetNumFactions() @@ -381,7 +332,6 @@ function Database:PopulateDynamicReputations() for i = 1, numFactions do local factionData = C_Reputation.GetFactionDataByIndex(i) if factionData and factionData.isHeader then - -- Check if header is collapsed using both new and old property names local isCollapsed = false if factionData.isHeaderExpanded ~= nil then isCollapsed = not factionData.isHeaderExpanded @@ -400,17 +350,14 @@ function Database:PopulateDynamicReputations() if not didExpand then break end end - -- Read the full flat list and build entries local numFactions = C_Reputation.GetNumFactions() local injected = 0 - -- Base steps that every reputation inherits (open Character frame + Reputation tab) local baseSteps = { { buttonFrame = "CharacterMicroButton" }, { waitForFrame = "CharacterFrame", tabIndex = 2 }, } - -- Track both expansion headers and faction-group headers (they do nest!) local currentExpansion = nil local currentFactionGroup = nil @@ -418,7 +365,6 @@ function Database:PopulateDynamicReputations() local function buildHeaderSteps() local steps = {} for _, s in ipairs(baseSteps) do steps[#steps + 1] = s end - -- Navigate through the header hierarchy: first expansion, then faction group if currentExpansion then steps[#steps + 1] = { waitForFrame = "CharacterFrame", factionHeader = currentExpansion } end @@ -430,7 +376,6 @@ function Database:PopulateDynamicReputations() local function buildPath() local path = {"Character Info", "Reputation"} - -- Build hierarchical path: Expansion -> Faction Group (if exists) if currentExpansion then path[#path + 1] = currentExpansion end @@ -440,9 +385,6 @@ function Database:PopulateDynamicReputations() return path end - -- Localized "Alliance" / "Horde" header names from globals when available; - -- fall back to lowercase string comparison for non-English clients that - -- don't expose them under these IDs. local ALLIANCE_HEADER = (FACTION_ALLIANCE or "Alliance"):lower() local HORDE_HEADER = (FACTION_HORDE or "Horde"):lower() @@ -465,9 +407,6 @@ function Database:PopulateDynamicReputations() end keywords[#keywords + 1] = factionNameLower - -- Faction side: inferred from the parent group header. Factions - -- under an Alliance/Horde sub-header are faction-locked; the rest - -- are either-faction reputations. local factionSide if currentFactionGroup then local groupLower = slower(currentFactionGroup) @@ -508,9 +447,7 @@ function Database:PopulateDynamicReputations() currentExpansion = factionData.name currentFactionGroup = nil else - -- Clear previous sibling faction group before processing currentFactionGroup = nil - -- Header-factions: inject all with factionID so they're searchable. if factionData.factionID and factionData.factionID > 0 then injectFaction(factionData) end @@ -526,14 +463,12 @@ function Database:PopulateDynamicReputations() Utils.DebugPrint("Injected", injected, "dynamic reputation entries from C_Reputation") end - -- Collapse back any headers we expanded during scanning for pass = 1, 50 do local numFactionsPost = C_Reputation.GetNumFactions() local didCollapse = false for i = numFactionsPost, 1, -1 do local factionData = C_Reputation.GetFactionDataByIndex(i) if factionData and factionData.isHeader and headersWeExpanded[factionData.name] then - -- Check if header is expanded using both property names local isExpanded = false if factionData.isHeaderExpanded ~= nil then isExpanded = factionData.isHeaderExpanded @@ -554,10 +489,6 @@ function Database:PopulateDynamicReputations() return true end --- Called after PLAYER_LOGIN when C_MountJournal is available --- Scans the player's collected mounts and injects them into the search database --- Shared prototypes for mount/toy entries via __index. --- Eliminates 5 hash slots per entry (~320 bytes each × ~1300 entries). local MOUNT_PROTO = { keywords = {"mount", "ride"}, keywordsLower = {"mount", "ride"}, @@ -637,7 +568,6 @@ local GEAR_SET_PROTO = { local GEAR_SET_MT = { __index = GEAR_SET_PROTO } --- Map equip location strings to user-friendly search keywords local SLOT_KEYWORDS = { INVTYPE_HEAD = {"helm", "helmet", "head"}, INVTYPE_NECK = {"neck", "necklace", "amulet"}, @@ -662,7 +592,6 @@ local SLOT_KEYWORDS = { 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"}, @@ -740,7 +669,6 @@ function Database:QueryNeedsHeavySearchData(text) return false end --- 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", @@ -752,30 +680,28 @@ local SLOT_DISPLAY = { INVTYPE_HOLDABLE = "Off Hand", } --- Lowercased slot display names for category score boosting (query "legs" → loot first) +-- Powers loot-category boost for slot queries (e.g. "legs"). 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(). +-- C_EncounterJournal functions may not exist until EncounterJournal_LoadUI(), +-- so resolve at call time. Prefer the C_ namespace over stale EJ_* globals. 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 +local lootEntries = {} +local lootScanGeneration = 0 local bossScanGeneration = 0 -local lootItemCache = {} -- itemID -> entry (persists across spec/diff toggles) -local lootSpecsScanned = {} -- ["classID-specID"] = true -Database._lootItemCache = lootItemCache -- exposed for DevMem diagnostics -Database._lootEntries = lootEntries -- exposed for DevMem diagnostics -Database._lootSpecsScanned = lootSpecsScanned -- exposed for DevMem diagnostics +local lootItemCache = {} +local lootSpecsScanned = {} +Database._lootItemCache = lootItemCache +Database._lootEntries = lootEntries +Database._lootSpecsScanned = lootSpecsScanned --- Maps user-facing difficulty keys to EJ difficulty IDs per source type local LOOT_DIFF_IDS = { lfr = { raid = 17 }, normal = { dungeon = 1, raid = 14 }, @@ -783,7 +709,6 @@ local LOOT_DIFF_IDS = { mythic = { dungeon = 23, raid = 16 }, } --- Get the EJ difficulty ID for the current loot difficulty setting. function Database:GetEJDifficultyID(sourceType) local diffKey = EasyFind.db.lootDifficulty or "normal" local diffIDs = LOOT_DIFF_IDS[diffKey] @@ -798,9 +723,8 @@ function Database:SetEJDifficulty(diffID) if setDiff then setDiff(diffID) end end --- Sync the EJ's difficulty for both dungeon and raid tabs. --- EJ_SetDifficulty requires an instance of the matching type to be selected first. --- Saves and restores the current instance selection to avoid side effects. +-- EJ_SetDifficulty requires an instance of the matching type to be selected +-- first, so this saves and restores the current selection. function Database:SyncEJDifficulty() local selectInst = EJ("SelectInstance") local getInst = EJ("GetInstanceByIndex") @@ -808,9 +732,7 @@ function Database:SyncEJDifficulty() local getInstInfo = EJ("GetInstanceInfo") if not selectInst or not getInst or not setDiff then return end - -- Save current instance so we can restore it after - -- EJ_GetInstanceInfo returns: name, description, bgImage, buttonImage1, ..., mapID, journalInstanceID - -- journalInstanceID is at index 12 + -- journalInstanceID is the 12th return of EJ_GetInstanceInfo. local savedInstID = getInstInfo and select(12, getInstInfo()) local dungeonDiffID = self:GetEJDifficultyID("Dungeon") @@ -831,14 +753,11 @@ function Database:SyncEJDifficulty() end end - -- Restore previous instance selection if savedInstID and savedInstID > 0 then selectInst(savedInstID) 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() local setFilter = EJ("SetLootFilter") if not setFilter then return end @@ -859,10 +778,8 @@ function Database:SyncEJLootFilter() 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() - -- Remove old loot entries (filter in place to avoid O(n^2) tremove) + -- Filter in place to avoid O(n^2) tremove. local writeIdx = 0 for i = 1, #uiSearchData do if uiSearchData[i].category ~= "Loot" then @@ -875,7 +792,6 @@ local function RebuildLootSearchData() 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 @@ -899,10 +815,8 @@ local function RebuildLootSearchData() 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 @@ -923,15 +837,9 @@ local function RebuildLootSearchData() 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. --- Look up a transmog set's ID by exact name. Used to recover the setID for --- pinned appearance sets that were saved before transmogSetID was persisted. +-- Recovers setID for pinned sets saved before transmogSetID was persisted. function Database:GetTransmogSetIDByName(name) if not name or not C_TransmogSets or not C_TransmogSets.GetAllSets then return nil end local allSets = C_TransmogSets.GetAllSets() @@ -945,9 +853,7 @@ end function Database:GetLootItemLink(entry) local links = entry.lootItemLinks - -- Pinned/serialized entries lose their lootItemLinks table when written - -- to SavedVariables. Fall back to the live loot cache by itemID so - -- existing pins keep working without needing to be re-pinned. + -- Serialized pins lose lootItemLinks, so fall back to the live cache. if not links and entry.itemID then local live = lootItemCache[entry.itemID] if live then links = live.lootItemLinks end @@ -955,7 +861,6 @@ function Database:GetLootItemLink(entry) 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 @@ -984,11 +889,10 @@ function Database:EnrichLootStats(entry) 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. --- The slot is re-discovered each click to avoid overwriting user actions. -local outfitEntries = {} -- track injected entries for re-population +-- Outfit equip uses a temporary action bar slot rediscovered each click so we +-- don't overwrite user actions: PreClick places the outfit, secure handler +-- calls UseAction, PostClick clears the slot. +local outfitEntries = {} function Database:PopulateDynamicMounts() if not C_MountJournal or not C_MountJournal.GetMountIDs then return false end @@ -1014,8 +918,6 @@ function Database:PopulateDynamicMounts() return true end --- Called after PLAYER_LOGIN when C_ToyBox is available --- Scans the player's collected toys and injects them into the search database function Database:PopulateDynamicToys() if not C_ToyBox then return false end @@ -1026,7 +928,6 @@ function Database:PopulateDynamicToys() local GetToyFromIndex = C_ToyBox.GetToyFromIndex if not GetToyInfo or not GetNumFilteredToys or not GetToyFromIndex then return false end - -- Save current filter state, set to show all collected toys only local hasFilterAPI = C_ToyBox.GetCollectedShown and C_ToyBox.SetCollectedShown local savedCollected = hasFilterAPI and C_ToyBox.GetCollectedShown() local savedUncollected = C_ToyBox.GetUncollectedShown and C_ToyBox.GetUncollectedShown() @@ -1045,13 +946,10 @@ function Database:PopulateDynamicToys() if itemID and itemID > 0 then local _, toyName, toyIcon = GetToyInfo(itemID) if toyName and toyName ~= "" then - -- Tag faction/class-restricted toys so the click handler - -- routes to ToyBox highlight instead of attempting a - -- secure use that would silently no-op. C_ToyBox.IsToyUsable - -- mirrors what Blizzard's own ToyBox UI uses for the Use - -- button enable state, unlike the broader IsUsableItem - -- which can flunk usable toys when the item info hasn't - -- been cached yet at PLAYER_LOGIN time. + -- Tag faction/class-restricted toys so the click handler routes to + -- ToyBox highlight instead of a silently no-op secure use. + -- IsToyUsable matches the default UI's Use button state; the broader + -- IsUsableItem can fail when item info isn't cached at PLAYER_LOGIN. local isUsable = true if C_ToyBox and C_ToyBox.IsToyUsable then local ok, usable = pcall(C_ToyBox.IsToyUsable, itemID) @@ -1069,7 +967,6 @@ function Database:PopulateDynamicToys() end end - -- Restore filter state if hasFilterAPI then C_ToyBox.SetCollectedShown(savedCollected) end if C_ToyBox.SetUncollectedShown then C_ToyBox.SetUncollectedShown(savedUncollected) end if C_ToyBox.SetFilterString then C_ToyBox.SetFilterString(savedString) end @@ -1077,19 +974,15 @@ function Database:PopulateDynamicToys() return true end --- Called after PLAYER_LOGIN when C_PetJournal is available --- Scans the player's collected pets and injects them into the search database function Database:PopulateDynamicPets() if not C_PetJournal or not C_PetJournal.GetNumPets then return false end RemoveEntriesByCategory("Pet") - -- Save current filter state local savedCollected = C_PetJournal.IsFilterChecked and C_PetJournal.IsFilterChecked(LE_PET_JOURNAL_FILTER_COLLECTED) local savedNotCollected = C_PetJournal.IsFilterChecked and C_PetJournal.IsFilterChecked(LE_PET_JOURNAL_FILTER_NOT_COLLECTED) local savedString = C_PetJournal.GetSearchFilter and C_PetJournal.GetSearchFilter() or "" - -- Show all collected pets if C_PetJournal.SetFilterChecked then C_PetJournal.SetFilterChecked(LE_PET_JOURNAL_FILTER_COLLECTED, true) C_PetJournal.SetFilterChecked(LE_PET_JOURNAL_FILTER_NOT_COLLECTED, false) @@ -1124,7 +1017,6 @@ function Database:PopulateDynamicPets() end end - -- Restore filter state if C_PetJournal.SetFilterChecked then if savedCollected ~= nil then C_PetJournal.SetFilterChecked(LE_PET_JOURNAL_FILTER_COLLECTED, savedCollected) end if savedNotCollected ~= nil then C_PetJournal.SetFilterChecked(LE_PET_JOURNAL_FILTER_NOT_COLLECTED, savedNotCollected) end @@ -1133,10 +1025,6 @@ function Database:PopulateDynamicPets() return true end --- Called after PLAYER_LOGIN when C_Heirloom is available. --- Scans the heirloom catalog and injects any owned heirloom into the --- search database. Click handler creates the heirloom item in the --- player's bags via C_Heirloom.CreateHeirloom. function Database:PopulateDynamicHeirlooms() if not C_Heirloom or not C_Heirloom.GetHeirloomItemIDs then return false end @@ -1171,11 +1059,8 @@ function Database:PopulateDynamicHeirlooms() return true end --- Scan known character titles and inject as search entries. Click on a --- title row sets it as the current title via SetCurrentTitle. Titles --- come back from the API with a "%s" placeholder for the player's --- name; we strip it for display so the row reads "the Insane" rather --- than "%s the Insane". +-- Title names contain a "%s" placeholder for the player name; strip it so +-- "the Insane" displays instead of "%s the Insane". function Database:PopulateDynamicTitles() local getNum = GetNumTitles local getName = GetTitleName @@ -1206,10 +1091,6 @@ function Database:PopulateDynamicTitles() end --- Scan the player's saved Equipment Manager gear sets and inject as --- search entries. Click on a row equips the set via --- C_EquipmentSet.UseEquipmentSet (no protected-frame issues outside --- combat). Per-set icons come from the saved iconFileID. function Database:PopulateDynamicGearSets() if not C_EquipmentSet or not C_EquipmentSet.GetEquipmentSetIDs then return false end @@ -1236,12 +1117,9 @@ function Database:PopulateDynamicGearSets() return true 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 false end - -- Remove previous outfit entries (handles mid-session outfit changes) RemoveEntriesByCategory("Outfit") wipe(outfitEntries) @@ -1263,8 +1141,6 @@ function Database:PopulateDynamicOutfits() return true end --- Reads the default UI's transmog set filters and updates our saved settings. --- Called before populate and when the filter dropdown opens. function Database:SyncTransmogSetFiltersFromUI() local db = EasyFind and EasyFind.db if not db then return end @@ -1294,18 +1170,12 @@ function Database:SyncTransmogSetFiltersFromUI() end end --- Called after PLAYER_LOGIN when C_TransmogSets is available. --- Scans all transmog appearance sets and injects them into the search database. --- Respects class, collected, and PvE/PvP filter settings. function Database:PopulateDynamicTransmogSets() if not C_TransmogSets or not C_TransmogSets.GetAllSets then return false end - -- Remove previous entries (handles mid-session filter changes) RemoveEntriesWithField("transmogSetID") - -- Invalidate incremental search cache. Without this, a query that was - -- typed before the repopulate (e.g. "cauldron" searched with Druid sets - -- active) would still reuse prevCandidates on the next extension and - -- miss the newly injected entries. + -- Without this, a query typed before repopulate (e.g. "cauldron" while + -- Druid sets were active) reuses prevCandidates and misses the new entries. if self.ResetSearchCache then self:ResetSearchCache() end local allSets = C_TransmogSets.GetAllSets() @@ -1319,8 +1189,7 @@ function Database:PopulateDynamicTransmogSets() local showPvE = not db or db.appearanceSetPvE ~= false local showPvP = not db or db.appearanceSetPvP ~= false - -- Sync class filter to default UI. Guard against the hooksecurefunc in - -- Core.lua re-entering Populate from our own call here. + -- Guard against the Core.lua hooksecurefunc re-entering Populate. if C_TransmogSets.SetTransmogSetsClassFilter then EasyFind._tmogClassHookSuppress = true if not classFilter then @@ -1332,7 +1201,6 @@ function Database:PopulateDynamicTransmogSets() EasyFind._tmogClassHookSuppress = false end - -- Sync collected/PvE/PvP filters to default UI -- BaseSetsFilter enum: 1=Collected, 2=Not Collected, 3=PvE, 4=PvP local syncFilter = C_TransmogSets.SetBaseSetsFilter or C_TransmogSets.SetSetsFilter if syncFilter then @@ -1342,7 +1210,6 @@ function Database:PopulateDynamicTransmogSets() pcall(syncFilter, 4, showPvP) end - -- Refresh the default UI's sets list and class dropdown if loaded local wcf = _G["WardrobeCollectionFrame"] local scf = wcf and wcf.SetsCollectionFrame if scf and scf:IsShown() then @@ -1350,18 +1217,16 @@ function Database:PopulateDynamicTransmogSets() if scf.UpdateUI then pcall(scf.UpdateUI, scf) end if scf.Refresh then pcall(scf.Refresh, scf) end end - -- Refresh class dropdown text if wcf and wcf.ClassDropdown and wcf.ClassDropdown.Update then pcall(wcf.ClassDropdown.Update, wcf.ClassDropdown) end - -- Determine class mask for filtering local wantMask if not classFilter then local _, _, cid = UnitClass("player") wantMask = cid and lshift(1, cid - 1) or 0 elseif classFilter == "all" then - wantMask = nil -- accept all + wantMask = nil elseif type(classFilter) == "table" and classFilter.classID then wantMask = lshift(1, classFilter.classID - 1) end @@ -1371,19 +1236,16 @@ function Database:PopulateDynamicTransmogSets() for i = 1, #allSets do local setInfo = allSets[i] - -- Only include base sets; variants share the same visuals and - -- aren't directly navigable in the Sets tab left list + -- Variants share visuals with their base set and aren't navigable in the Sets list. local isBaseSet = true if C_TransmogSets.GetBaseSetID then local bid = C_TransmogSets.GetBaseSetID(setInfo.setID) isBaseSet = not bid or bid == setInfo.setID end if isBaseSet and setInfo.name and setInfo.name ~= "" and not setInfo.hiddenUntilCollected then - -- Class filter: use classMask for specific class, or accept all local cm = setInfo.classMask or 0 local classOk = not wantMask or cm == 0 or cm < 0 or band(cm, wantMask) ~= 0 - -- PvE/PvP filter (check for known PvP label patterns) local label = setInfo.label or "" local labelLower = slower(label) local isPvP = sfind(labelLower, "pvp") or sfind(labelLower, "season") @@ -1391,7 +1253,6 @@ function Database:PopulateDynamicTransmogSets() or sfind(labelLower, "combatant") local sourceOk = (isPvP and showPvP) or (not isPvP and showPvE) - -- Collected filter local collected = setInfo.collected local collectedOk = true if not (showCollected and showNotCollected) then @@ -1415,7 +1276,6 @@ function Database:PopulateDynamicTransmogSets() end end - -- Get icon from first appearance source local icon if GetSetPrimaryAppearances and GetSourceIcon then local appearances = GetSetPrimaryAppearances(setInfo.setID) @@ -1442,10 +1302,8 @@ function Database:PopulateDynamicTransmogSets() end 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). + -- Skip 121-168 (bonus/override/vehicle/stance bars) since they may reject + -- non-class-specific actions like totem or stance slots. for slot = 180, 169, -1 do if not HasAction(slot) then return slot end end @@ -1604,28 +1462,23 @@ function Database:CancelDynamicScans(includeBosses) end end --- Called after PLAYER_LOGIN. Scans the Encounter Journal for current-tier loot --- 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). +-- scanAllSpecs=true pre-caches every class/spec combo (loading screen path). function Database:PopulateDynamicLoot(scanAllSpecs) if InCombatLockdown() then return end local specPairs = BuildLootSpecPairs(scanAllSpecs) local needScan = GetLootSpecsToScan(specPairs) - -- 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 + -- EJ loot tables require the UI 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") @@ -1642,11 +1495,11 @@ function Database:PopulateDynamicLoot(scanAllSpecs) return end - -- Bump generation so any in-flight staggered scan aborts + -- Bump generation so any in-flight staggered scan aborts. lootScanGeneration = lootScanGeneration + 1 local myGen = lootScanGeneration - -- Suppress EJ UI events during scan + -- Suppress EJ UI events during scan. local ejFrame = _G["EncounterJournal"] local savedOnEvent if ejFrame then @@ -1656,7 +1509,6 @@ function Database:PopulateDynamicLoot(scanAllSpecs) local savedTier = EJ_GetCurrentTier and EJ_GetCurrentTier() - -- Collect all instances in the current tier local instances = {} local function collectInstances(isRaid) local idx = 1 @@ -1672,7 +1524,6 @@ function Database:PopulateDynamicLoot(scanAllSpecs) local GetItemInfoInstant = GetItemInfoInstant - -- Build list of {diffKey, diffID} pairs per source type for _, inst in ipairs(instances) do if myGen ~= lootScanGeneration then break end EJ_SelectInstance(inst.id) @@ -1711,7 +1562,6 @@ function Database:PopulateDynamicLoot(scanAllSpecs) end end - -- 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 @@ -1907,11 +1757,6 @@ function Database:PopulateDynamicLootAsync(done, scanAllSpecs) end) end --- Macro search: scans the player's account-wide and per-character macros --- and injects them as searchable entries. Direct click runs the macro; --- guide mode opens MacroFrame, switches to the matching tab, and selects --- the macro. Re-callable: clears prior Macro entries before re-injecting, --- so calls from UPDATE_MACROS reflect renames/edits/deletes. function Database:PopulateDynamicMacros() if not GetNumMacros or not GetMacroInfo then return false end @@ -1926,8 +1771,7 @@ function Database:PopulateDynamicMacros() if not name or name == "" then return end local nameLower = slower(name) local kw = { "macro", nameLower } - -- Index macro body words (slash command names, target names, etc.) - -- so /castsequence Hearthstone is reachable by typing "hearthstone". + -- Index macro body words so "/castsequence Hearthstone" is reachable by "hearthstone". if body and body ~= "" then local cleanBody = body:gsub("#show[^\n]*", ""):gsub("/", " ") for word in cleanBody:gmatch("[%w']+") do @@ -1967,14 +1811,7 @@ function Database:PopulateDynamicMacros() return true end --- Inject one entry per learned spell into uiSearchData. Retail Midnight --- replaced GetSpellBookItemInfo's (slot, slotType) string-typed args --- with (slotIndex, Enum.SpellBookSpellBank) and returns a single --- SpellBookItemInfo table containing name, subName, actionID, iconID. --- We use that path exclusively; pre-Midnight clients (Classic) skip --- registration since this addon targets Midnight 12.0+. function Database:PopulateDynamicAbilities() - -- Strip prior pass so /reload-equivalent rebuilds don't double up. RemoveEntriesByCategory("Ability") if self.ResetSearchCache then self:ResetSearchCache() end @@ -2048,11 +1885,8 @@ function Database:PopulateDynamicAbilities() end elseif FLYOUT_TYPE and itemInfo.itemType == FLYOUT_TYPE and GetFlyoutInfoFn and GetFlyoutSlotInfoFn then - -- Flyouts are containers (Switch Flight Style → Skyriding / - -- Steady Flight, etc.). The flyout itself isn't castable, - -- but its slot spells are. Scan and inject each, and use - -- the flyout's own name as a keyword so "switch flight" - -- finds the slot spells. + -- The flyout itself isn't castable; inject its slot spells + -- and keep the flyout name as a keyword so "switch flight" works. local flyoutID = itemInfo.actionID local flyoutName, _, numSlots, isKnown = GetFlyoutInfoFn(flyoutID) if isKnown and numSlots and numSlots > 0 then @@ -2084,14 +1918,9 @@ function Database:PopulateDynamicAbilities() return true end --- Common community abbreviations for dungeons/raids whose initials --- skip non-leading letters (e.g. "BWL" picks the W from blackWing) so --- the standard initials/prefix scoring can't reach them. Listed as --- per-instance keyword aliases: typing "bwl" gets the same 2-3 char --- exact-match boost (140) that "icc" already gets via the prefix path. --- Keys are lowercased instance names returned by EJ_GetInstanceByIndex. --- Exposed on ns so MapSearch can inject the same aliases onto its --- dungeon-entrance POIs and the two surfaces stay in sync. +-- Dungeon/raid initialisms that pick non-leading letters (e.g. "BWL" from +-- blackWing) which the standard prefix scoring can't reach. Exposed so +-- MapSearch can inject the same aliases on dungeon-entrance POIs. local INSTANCE_ABBRS = { ["blackwing lair"] = {"bwl"}, ["blackrock depths"] = {"brd"}, @@ -2152,12 +1981,7 @@ local function AddBossEntry(tier, isRaid, instID, instName, encName, encID, getC } end --- Inject one entry per dungeon/raid boss across every expansion tier. --- Click navigates the Encounter Journal to that boss. Icon is the --- boss's first creature portrait (EJ_GetCreatureInfo[5]) so results --- look like the EJ's own boss list. function Database:PopulateDynamicBosses() - -- Strip prior pass so re-runs don't double up. RemoveEntriesByCategory("Boss") if self.ResetSearchCache then self:ResetSearchCache() end @@ -2176,8 +2000,7 @@ function Database:PopulateDynamicBosses() return end - -- Suppress EJ UI events during the scan so opening the journal - -- mid-scan can't fight us. Restore at the end. + -- Suppress EJ UI events so opening the journal mid-scan can't fight us. local ejFrame = _G["EncounterJournal"] local savedOnEvent if ejFrame then @@ -2211,8 +2034,6 @@ function Database:PopulateDynamicBosses() end end - -- Restore prior tier and EJ event handler so the journal behaves - -- normally for the user's next interaction. if savedTier and selectTier then selectTier(savedTier) end if ejFrame and savedOnEvent then ejFrame:SetScript("OnEvent", savedOnEvent) end end @@ -2328,11 +2149,6 @@ function Database:PopulateDynamicBossesAsync(done) end) end --- Enumerate the player's class/spec talent tree(s) via C_Traits and --- emit one search entry per selectable talent. Each entry remembers the --- nodeID + entryID so the click handler can scroll the talents tree --- into view, find the matching node button under PlayerSpellsFrame. --- TalentsFrame.ButtonsParent, and highlight it. function Database:PopulateDynamicTalents() RemoveEntriesByCategory("Talent") if self.ResetSearchCache then self:ResetSearchCache() end @@ -2375,10 +2191,7 @@ function Database:PopulateDynamicTalents() if seen[name .. "|" .. (spellID or 0)] then return end seen[name .. "|" .. (spellID or 0)] = true - -- Allocation: choice nodes pick one of their entries via - -- activeEntry; regular nodes report activeRank > 0 when the - -- player has put points in. Used by the row renderer to - -- desaturate the per-talent icon for unspecced talents. + -- Row renderer desaturates the icon for unspecced talents. local isAllocated if isChoice then isAllocated = nodeInfo.activeEntry and nodeInfo.activeEntry.entryID == entryID @@ -2430,10 +2243,6 @@ function Database:PopulateDynamicTalents() return true end --- Inject one entry per unique item carried in the player's bags. The --- entry stores the first occupied location so guide mode can highlight --- the right slot; drag-to-pickup uses the item ID to put the item on --- the cursor (matching the in-game bag drag behavior). function Database:PopulateDynamicBags() local CONT = C_Container local getNumSlots = (CONT and CONT.GetContainerNumSlots) or GetContainerNumSlots @@ -2505,7 +2314,7 @@ function Database:PopulateDynamicBags() if type(raw) == "table" then itemID, texture, link, count = raw.itemID, raw.iconFileID, raw.hyperlink, raw.stackCount else - -- Legacy multi-return (Classic-era layout) + -- Classic-era multi-return layout. texture = raw local _ _, count, _, _, _, _, link, _, _, itemID = getItemInfo(bag, slot) @@ -2573,9 +2382,8 @@ function Database:_ResetHeavyProviderCaches() wipe(lootSpecsScanned) end --- Aliases the API category names don't carry. Keyed by lowercase name. --- Words from the name are tokenized automatically by the search scorer, --- so these only need acronyms and shortenings the user might type. +-- Aliases the API category names don't carry. Words from the name itself +-- are auto-tokenized; only put acronyms and shortenings here. local CATEGORY_KEYWORD_OVERLAY = { ["rated battlegrounds"] = {"rbg", "rated bg"}, ["alterac valley"] = {"av"}, @@ -2631,16 +2439,11 @@ local function buildCategoryEntry(opts) return entry end --- Walks GetCategoryList()/GetCategoryInfo() to build personal + --- guild achievement category entries directly from the live registry, --- so search results always match what the AchievementFrame sidebar --- actually contains for the current build. function Database:PopulateDynamicAchievements() if not GetCategoryList or not GetCategoryInfo then return false end - -- Midnight's GetCategoryList can include statistic tracker IDs. - -- Filter those from live category ancestry immediately; the async - -- Statistics pass later refreshes this provider with the full row-ID - -- set as a backup without blocking achievement results at login. + -- Midnight's GetCategoryList can include statistic tracker IDs. Filter + -- them out here; the async Statistics pass refreshes us later with the + -- full row-ID set as a backup. RemoveEntriesByCategory("Achievement Category") @@ -2725,15 +2528,10 @@ function Database:PopulateDynamicAchievements() return true end --- Walks GetStatisticsCategoryList()/GetCategoryInfo() to emit the --- Statistics tab's category tree. Same shape as achievements but --- targets the Statistics tab (tabIndex = 3). --- Set of achievement IDs that are statistic-tracker achievements. --- Populated by PopulateDynamicStatistics; consumed by callers that --- enumerate achievements via Blizzard's APIs (e.g. UI.lua's inline --- achievement search) so they can filter stat-trackers out -- those --- show up under their own dedicated Statistics filter, not as --- duplicate "Achievement" rows. +-- statisticIDs: achievement IDs that are statistic trackers. Populated by +-- PopulateDynamicStatistics so consumers that enumerate achievements via +-- Blizzard APIs can filter trackers out (they show under Statistics, not as +-- duplicate Achievement rows). Database.statisticIDs = Database.statisticIDs or {} Database.statisticsCategoryIDs = Database.statisticsCategoryIDs or {} Database.statisticsCategoryChildIDs = Database.statisticsCategoryChildIDs or {} @@ -2816,15 +2614,9 @@ function Database:IsStatisticAchievement(achievementID) return IsInStatisticsCategoryTree(achievementID, parentID) end --- Cancel token for async stat scans -- bumping it during a scan --- causes the in-flight time-sliced loop to bail without writing. +-- Bumping during a scan causes the in-flight time-sliced loop to bail. local statsScanGeneration = 0 --- Time-sliced version of PopulateDynamicStatistics. The per-row --- enumeration is ~400 API calls; running it sync at PLAYER_LOGIN --- doubles the load screen, and running it sync in one frame --- post-login causes a visible hitch. Spread the work across frames --- with a small per-tick budget instead. function Database:PopulateDynamicStatisticsAsync(done) if not GetStatisticsCategoryList or not GetCategoryInfo or not C_Timer or not C_Timer.After then @@ -2862,16 +2654,9 @@ function Database:PopulateDynamicStatisticsAsync(done) end end - -- Flat queue of per-row work items. The expensive per-category - -- setup (steps prefix, path, prototype with shared fields) is - -- built ONCE per category and stored on the queue item; per-row - -- work then only needs the API call + minimal entry construction. - -- - -- Each entry uses an __index prototype to share the constant - -- fields (category, buttonFrame, flashLabel, keywords, path, - -- stepsPrefix) so each row only stores its unique fields - -- (name, nameLower, statisticID, full steps array). - local STAT_KEYWORDS_EMPTY = {} -- shared empty + -- Per-category setup built once per category and shared via __index proto + -- so each row only stores its unique fields (name, statisticID, steps). + local STAT_KEYWORDS_EMPTY = {} local queue = {} local function enqueueCategory(cat, parentChain) @@ -2931,11 +2716,7 @@ function Database:PopulateDynamicStatisticsAsync(done) end for i = 1, #roots do enqueueCategory(roots[i], {}) end - -- Per-row work: one API call, one slower, one steps copy, one - -- table allocation with metatable. ~0.05-0.1ms each instead of - -- the 0.5ms buildCategoryEntry path. Keywords are skipped - -- entirely; matching falls back to the unique stat name which - -- is sufficient (search engine handles word-substring on names). + -- Keywords skipped; the unique stat name is enough for the scorer. local seenStatisticIDs = {} local function processRow(item) if not GetAchievementInfo then return end @@ -2964,9 +2745,6 @@ function Database:PopulateDynamicStatisticsAsync(done) uiSearchData[#uiSearchData + 1] = entry end - -- ~2ms per tick. With the prototype-based per-row work at - -- ~0.05-0.1ms each, ~25 rows fit per tick. ~2000 rows / 25 = - -- ~80 yields total. At 60fps that's ~1.3s, imperceptible per-tick. local BUDGET_MS = 2 local cursor = 1 local function step() @@ -2983,8 +2761,8 @@ function Database:PopulateDynamicStatisticsAsync(done) return end end - -- Stats done; refresh achievements so the full stat row-ID set - -- is available as a backup to the live category-tree filter. + -- Refresh achievements so the full stat row-ID set is available + -- as a backup to the live category-tree filter. Database.statisticsComplete = true Database.statisticsVersion = Database.statisticsVersion + 1 if Database.RefreshDynamicCategory then @@ -3027,12 +2805,7 @@ function Database:PopulateDynamicStatistics() end end - -- Emit one entry per individual stat row inside each category. - -- Each "row" in the Statistics tab is an achievement ID whose - -- value is fetched via GetStatistic(achievementID). Walking - -- GetCategoryNumAchievements + GetAchievementInfo gives us the - -- per-row IDs and titles for inline display. This synchronous path - -- remains as a fallback for environments without C_Timer. + -- Sync fallback path for environments without C_Timer. local seenStatisticIDs = {} local function emit(cat, parentChain) local baseSteps = { @@ -3072,11 +2845,10 @@ function Database:PopulateDynamicStatistics() MarkID(Database.statisticIDs, id) local steps = {} for s = 1, #baseSteps do steps[s] = baseSteps[s] end - -- Leaf step: ONLY statisticID + statisticName. - -- Including statisticsCategory here would make the - -- statisticsCategory handler match first (it runs - -- before the statisticID handler) and Cancel the - -- guide because the category is already selected. + -- Leaf step must be ONLY statisticID + statisticName. + -- Including statisticsCategory here lets that handler + -- match first (it runs before statisticID) and cancel + -- the guide since the category is already selected. steps[#steps + 1] = { waitForFrame = "AchievementFrame", statisticID = id, @@ -3113,10 +2885,7 @@ function Database:PopulateDynamicStatistics() return true 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. - +-- Children inherit buttonFrame/category and accumulate path + steps. function Database:FlattenTree(tree, parentPath, parentSteps, parentButtonFrame, parentCategory) parentPath = parentPath or {} parentSteps = parentSteps or {} @@ -3125,7 +2894,7 @@ function Database:FlattenTree(tree, parentPath, parentSteps, parentButtonFrame, local myButtonFrame = node.buttonFrame or parentButtonFrame local myCategory = node.category or parentCategory - -- Accumulate steps: reuse parent array when node adds nothing + -- Reuse parent array when node adds nothing. local mySteps if node.steps then mySteps = {} @@ -3135,7 +2904,7 @@ function Database:FlattenTree(tree, parentPath, parentSteps, parentButtonFrame, mySteps = parentSteps end - -- Build the flat entry (path = parent names leading here, NOT including self) + -- path = parent names leading here, NOT including self. local entry = { name = node.name, keywords = node.keywords or {}, @@ -3145,7 +2914,6 @@ function Database:FlattenTree(tree, parentPath, parentSteps, parentButtonFrame, steps = mySteps, } for i = 1, #parentPath do entry.path[i] = parentPath[i] end - -- Copy optional fields if node.flashLabel then entry.flashLabel = node.flashLabel end if node.icon then entry.icon = node.icon end if node.available then entry.available = node.available end @@ -3154,7 +2922,6 @@ function Database:FlattenTree(tree, parentPath, parentSteps, parentButtonFrame, uiSearchData[#uiSearchData + 1] = entry - -- Recurse into children with this node's name appended to the path if node.children then local childPath = {} for i = 1, #parentPath do childPath[i] = parentPath[i] end @@ -3165,15 +2932,11 @@ function Database:FlattenTree(tree, parentPath, parentSteps, parentButtonFrame, end function Database:BuildUIDatabase() - -- UI TREE -- Each node: { name, keywords, [category], [buttonFrame], [steps], [children] } - -- - category: inherited from parent if omitted - -- - buttonFrame: inherited from parent if omitted - -- - steps: only THIS node's new steps (flattener prepends parent steps) - -- - path: auto-built from ancestor names (never specified manually) + -- category and buttonFrame inherit from parent; steps prepends parent steps; + -- path is auto-built from ancestor names. local uiTree = { - -- CHARACTER INFO { name = "Character Info", keywords = {"character", "char", "attributes"}, @@ -3228,7 +2991,6 @@ function Database:BuildUIDatabase() }, }, - -- PROFESSIONS { name = "Professions", keywords = {"professions", "profession", "crafting", "trade skills", "skills"}, @@ -3237,7 +2999,6 @@ function Database:BuildUIDatabase() steps = {{ buttonFrame = "ProfessionMicroButton" }}, }, - -- TALENTS & SPELLBOOK { name = "Talents & Spellbook", keywords = {"talents and spellbook", "class abilities"}, @@ -3278,7 +3039,6 @@ function Database:BuildUIDatabase() }, }, - -- ACHIEVEMENTS { name = "Achievements", keywords = {"achievement", "achievements", "achieve", "points"}, @@ -3291,31 +3051,17 @@ function Database:BuildUIDatabase() keywords = {"achievements", "achievement tab", "personal achievements"}, category = "Achievements", steps = {{ waitForFrame = "AchievementFrame", tabIndex = 1 }}, - -- Category entries are emitted at runtime by - -- Database:PopulateDynamicAchievements() using - -- GetCategoryList() / GetCategoryInfo() so the search - -- index always matches the live AchievementFrame - -- sidebar for the current build. }, - -- STATISTICS (Tab 3) { name = "Statistics", keywords = {"statistics", "stats tab", "player statistics"}, category = "Achievements", steps = {{ waitForFrame = "AchievementFrame", tabIndex = 3 }}, - -- Statistics category entries are emitted at runtime by - -- Database:PopulateDynamicStatistics() using - -- GetStatisticsCategoryList() / GetCategoryInfo() so the - -- search index always matches the live AchievementFrame - -- Statistics sidebar for the current build. - -- Per-row stat entries (including duels) come from - -- PopulateDynamicStatistics; no manual entries needed. }, }, }, - -- QUEST LOG { name = "Quest Log", keywords = {"quest", "quests", "objectives", "log", "journal"}, @@ -3324,7 +3070,6 @@ function Database:BuildUIDatabase() steps = {{ buttonFrame = "QuestLogMicroButton" }}, }, - -- HOUSING { name = "Housing Dashboard", keywords = {"housing", "house", "home", "dashboard", "player housing"}, @@ -3333,7 +3078,6 @@ function Database:BuildUIDatabase() steps = {{ buttonFrame = "HousingMicroButton" }}, }, - -- GUILD & COMMUNITIES { name = "Guild & Communities", keywords = {"guild", "communities", "social", "clan"}, @@ -3342,7 +3086,6 @@ function Database:BuildUIDatabase() steps = {{ buttonFrame = "GuildMicroButton" }}, }, - -- GROUP FINDER { name = "Group Finder", keywords = {"lfg", "lfd", "lfr", "finder", "queue", "group finder"}, @@ -3350,7 +3093,6 @@ function Database:BuildUIDatabase() buttonFrame = "LFDMicroButton", steps = {{ buttonFrame = "LFDMicroButton" }}, children = { - -- PVE SECTION { name = "Dungeons & Raids", keywords = {"dungeons", "raids", "dungeons and raids"}, @@ -3375,7 +3117,6 @@ function Database:BuildUIDatabase() }, }, - -- PVP SECTION { name = "Player vs. Player", keywords = {"pvp", "player vs player", "battleground", "arena", "bg"}, @@ -3428,7 +3169,6 @@ function Database:BuildUIDatabase() }, }, - -- MYTHIC+ SECTION { name = "Mythic+ Dungeons", keywords = {"mythic", "mythic+", "m+", "keystone", "mythic plus", "keys"}, @@ -3438,7 +3178,6 @@ function Database:BuildUIDatabase() }, }, - -- WARBAND COLLECTIONS { name = "Warband Collections", keywords = {"collections", "warband"}, @@ -3455,7 +3194,6 @@ function Database:BuildUIDatabase() }, }, - -- TRANSMOGRIFICATION { name = "Transmogrification", keywords = {"transmogrification", "transmog", "tmog", "mog", "wardrobe", "outfit", "outfits", "appearance", "keymog"}, @@ -3464,7 +3202,6 @@ function Database:BuildUIDatabase() steps = {{ loadTransmog = true }}, }, - -- ADVENTURE GUIDE { name = "Adventure Guide", keywords = {"adventure", "guide", "dungeon journal", "encounters", "loot", "boss", "journal"}, @@ -3484,12 +3221,9 @@ function Database:BuildUIDatabase() }, }, - -- GAME MENU / HELP / SHOP - -- /click routes through the secure dispatch so the micro - -- button's OnClick fires in a hardware-event context; - -- ToggleGameMenu's internal ClearTarget then succeeds. A - -- /run ShowUIPanel(GameMenuFrame) here forbid-errors on - -- ClearTarget because /run is not a hardware event. + -- /click routes through secure dispatch so the micro button's OnClick + -- fires from a hardware event; ToggleGameMenu's ClearTarget needs that. + -- /run ShowUIPanel(GameMenuFrame) here forbid-errors on ClearTarget. { name = "Game Menu", keywords = {"menu", "settings", "options", "escape", "esc", "logout", "quit", "exit", "interface"}, @@ -3515,7 +3249,6 @@ function Database:BuildUIDatabase() }, }, - -- PORTRAIT MENU OPTIONS (Auto-generated by Harvester) { name = "Portrait Menu", keywords = {"portrait", "portrait menu", "right click portrait", "player frame menu"}, @@ -3546,7 +3279,6 @@ function Database:BuildUIDatabase() }, }, - -- OTHER UI ELEMENTS (no tree hierarchy, standalone) { name = "Bags / Inventory", keywords = {"bags", "bag", "inventory", "backpack", "items", "storage"}, @@ -3590,11 +3322,8 @@ function Database:BuildUIDatabase() slashCommand = "/click GameTimeFrame", }, - -- Quick action: dismiss any active companion. /dismisspet alone - -- only handles hunter/warlock pets, so the macrotext also toggles - -- the active battle-pet companion via SummonPetByGUID with the - -- currently-summoned GUID (which dismisses it). Two lines so one - -- click covers both kinds of "pet" the player might mean. + -- /dismisspet only handles hunter/warlock pets; the second line + -- toggles the active battle-pet companion (dismissing it). { name = "Dismiss Pet", keywords = {"dismiss", "dismiss pet", "pet", "companion", "summon", @@ -3605,7 +3334,6 @@ function Database:BuildUIDatabase() }, } - -- Flatten the tree into the flat uiSearchData array self:FlattenTree(uiTree) for _, item in ipairs(uiSearchData) do @@ -3630,12 +3358,10 @@ function Database:BuildUIDatabase() end end - -- Tag entries with isPvP / isPvE for filter support local PVP_NAMES = { ["PvP Talents"] = true, ["War Mode"] = true, ["PvP Flag"] = true, } for _, item in ipairs(uiSearchData) do - -- PvP: explicit category, known PvP entries, or under Player vs. Player tree if item.category == "PvP" or PVP_NAMES[item.name] or sfind(item.name, "Player vs. Player", 1, true) then item.isPvP = true @@ -3647,7 +3373,6 @@ function Database:BuildUIDatabase() end end end - -- PvE: Group Finder PvE subtree (Dungeons & Raids, Mythic+) if not item.isPvP and item.path then local underGroupFinder = false for _, p in ipairs(item.path) do @@ -3675,29 +3400,22 @@ function Database:BuildUIDatabase() end end --- SEARCH SCORING HELPERS --- Word-boundary matching, initials matching, and fuzzy/typo tolerance. - --- Pairs of words that are close in edit distance but semantically opposite. --- Matching between these pairs is suppressed across initials and fuzzy scoring. +-- Suppresses fuzzy/initials matches between word pairs that are close in +-- edit distance but semantically opposite. local FUZZY_BLOCKLIST = { ["pvp"] = { ["pve"] = true }, ["pve"] = { ["pvp"] = true }, - -- "ability" / "abilities" appear as keywords on every ability entry - -- so multi-word queries like "feral abilities" can scope to a spec. - -- Block fuzzy hits between them and the literal stat "agility" so - -- typing "agility" doesn't drag every ability into the results. + -- "ability"/"abilities" are keywords on every Ability entry; without + -- this, typing "agility" drags every ability into results. ["agility"] = { ["ability"] = true, ["abilities"] = true }, ["ability"] = { ["agility"] = true }, ["abilities"] = { ["agility"] = true }, } --- Words that real abbreviations skip without comment ("lfg" = "Looking For --- Group", "tot" = "Throne of Thunder"). Initials Strategy 2 may step over --- these for free; skipping any non-stopword (a content word like "atrocity" --- in "Shurrai, Atrocity of the Undersea") breaks the chain so unrelated --- queries don't get a misleading high score from accidental letter --- alignment. +-- Initials Strategy 2 may step over these for free ("lfg" = Looking For +-- Group, "tot" = Throne of Thunder). Hitting a non-stopword content word +-- without matching breaks the chain so accidental letter alignment doesn't +-- score (e.g. "sound" wrongly hitting "Shurrai, Atrocity of the Undersea"). local INITIALS_STOPWORDS = { ["of"] = true, ["the"] = true, ["a"] = true, ["an"] = true, ["and"] = true, ["or"] = true, ["in"] = true, ["on"] = true, @@ -3705,19 +3423,13 @@ local INITIALS_STOPWORDS = { ["with"] = true, ["from"] = true, } --- A word boundary is the start of the string or right after a space/punctuation. --- Returns true if found at a boundary, false if only found mid-word or not at all. function Database:FindAtWordBoundary(text, query) - -- Check at start of string. sfind avoids the per-call substring - -- allocation that ssub(text, 1, #query) would create. local found = sfind(text, query, 1, true) if not found then return false end if found == 1 then return true end - -- After word boundaries (space, dash, parenthesis, colon, slash, dot). - -- sbyte avoids creating a 1-char string per check. + -- 32=space 45=- 40=( 58=: 47=/ 46=. while found do local prev = sbyte(text, found - 1) - -- 32=space, 45=- 40=( 58=: 47=/ 46=. if prev == 32 or prev == 45 or prev == 40 or prev == 58 or prev == 47 or prev == 46 then return true @@ -3727,17 +3439,10 @@ function Database:FindAtWordBoundary(text, query) return false end --- Score how well `query` matches as initials/abbreviation of words in `text`. --- "rb" → "rated battlegrounds" = 130 (each char matches a word start) --- "raba" → "random battleground" = 125 (prefix of words) --- "ranb" → "random battleground" = 115 (longer prefix matching) --- Returns 0 if no reasonable initials match found. function Database:ScoreInitials(text, query) - -- Use cached word split (input is already lowercase) local words = GetWords(text) - if #words < 2 then return 0 end -- initials only make sense for multi-word + if #words < 2 then return 0 end - -- Blocklist: "pve" must never initials-match text containing word "pvp" (and vice versa) local blocked = FUZZY_BLOCKLIST[query] if blocked then for wi = 1, #words do @@ -3748,8 +3453,7 @@ function Database:ScoreInitials(text, query) local queryLen = #query local numWords = #words - -- Strategy 1: Pure initials - each query char matches the first letter of consecutive words - -- "rb" → R(ated) B(attlegrounds) + -- Strategy 1: pure initials. "rb" → R(ated) B(attlegrounds). if queryLen <= numWords then local allMatch = true for i = 1, queryLen do @@ -3759,21 +3463,13 @@ function Database:ScoreInitials(text, query) end end if allMatch then - -- Bonus for matching ALL words' initials (not partial) local bonus = (queryLen == numWords) and 135 or 130 return bonus end end - -- Strategy 2: Prefix-of-words - each query segment matches the start - -- of a word, walking left-to-right. "raba" → "ra(ndom) ba(ttleground)" - -- greedily consumes query chars across consecutive words. - -- Once the chain has started (wordsMatched > 0), encountering a - -- non-stopword that doesn't match breaks the chain. Without this, - -- queries like "sound" wrongly initial-match "Shurrai, Atrocity of - -- the Undersea" via S(hurrai) o(f) und(ersea), skipping the content - -- word "atrocity" silently. Stopwords (of/the/and/...) may still be - -- skipped: that's what lets "tot" → "Throne of Thunder" work. + -- Strategy 2: greedy word-prefix walk. "raba" → ra(ndom) ba(ttleground). + -- Stopwords may be skipped (so "tot" → Throne Of Thunder works). local qi = 1 local wordsMatched = 0 for wi = 1, numWords do @@ -3801,19 +3497,13 @@ function Database:ScoreInitials(text, query) return 0 end --- Score fuzzy/typo matching using Damerau-Levenshtein distance. --- Only applied to individual words in `text` that are similar in length to `query`. --- Returns a score > 0 if a close match is found, 0 otherwise. - function Database:ScoreFuzzy(text, query, queryLen) - -- Length-scaled typo tolerance (1 edit per ~4 chars), the same - -- shape Algolia / Elasticsearch AUTO mode use. Without this scale, - -- a 5-char query like "skull" would fuzzy-match "spell" (2 edits) - -- and similar 40%-different words, drowning real results. + -- Length-scaled typo budget (1 edit per ~4 chars). Without this, + -- "skull" fuzzy-matches "spell" and similar unrelated 40%-diff words. local maxEdits if queryLen >= 8 then maxEdits = 2 elseif queryLen >= 4 then maxEdits = 1 - else return 0 -- queries under 4 chars: no fuzzy, substring covers them + else return 0 end local queryFirst = sbyte(query, 1) @@ -3822,12 +3512,7 @@ function Database:ScoreFuzzy(text, query, queryLen) local textWords = GetWords(text) for wi = 1, #textWords do local word = textWords[wi] - -- First-letter constraint (Algolia default). "easter" vs - -- "master" is a 1-edit match technically, but they're - -- semantically unrelated: typos almost never change the - -- first character, and this rule kills the false-positive - -- flood without dropping real typos like "achievmnts" → - -- "achievements". + -- First-letter must match: typos rarely hit the first char. if sbyte(word, 1) == queryFirst and not (blocked and blocked[word]) then local wordLen = #word @@ -3846,10 +3531,7 @@ function Database:ScoreFuzzy(text, query, queryLen) return bestScore end --- Requires first character match to avoid spurious hits like "ahn'" in "magtheridon's". --- Uses sbyte() instead of ssub() inside the inner loop -- ssub creates a --- fresh 1-char string per call which adds up to thousands of short-lived --- strings per keystroke (3700 entries x queryLen iterations). +-- First-char constraint avoids spurious hits like "ahn'" in "magtheridon's". function Database:IsSubsequence(word, query, queryLen) if sbyte(word, 1) ~= sbyte(query, 1) then return false end local wi = 1 @@ -3869,16 +3551,14 @@ function Database:IsSubsequence(word, query, queryLen) end if not found then return false end end - -- Reject sparse matches: "inn" in "instance" matches i(1)n(2)n(6) but span 6 > 5 + -- Reject sparse matches: "inn" in "instance" hits i(1)n(2)n(6), span 6 > 5. return (wi - 1) - firstPos + 1 <= queryLen * 2 - 1 end --- Damerau-Levenshtein distance (supports transpositions). --- Capped: returns early if distance exceeds 2 (saves CPU). +-- Damerau-Levenshtein with transpositions, capped at 2. function Database:DamerauLevenshtein(s1, s2, len1, len2) - if mabs(len1 - len2) > 2 then return 3 end -- too different, skip + if mabs(len1 - len2) > 2 then return 3 end - -- Reuse module-level tables (rotated each row, no allocation) local prev2, prev, curr = dlPrev2, dlPrev, dlCurr for j = 0, len2 do prev[j] = j end @@ -3892,11 +3572,10 @@ function Database:DamerauLevenshtein(s1, s2, len1, len2) local c2 = sbyte(s2, j) local cost = (c1 == c2) and 0 or 1 curr[j] = mmin( - prev[j] + 1, -- deletion - curr[j - 1] + 1, -- insertion - prev[j - 1] + cost -- substitution + prev[j] + 1, + curr[j - 1] + 1, + prev[j - 1] + cost ) - -- Transposition if i > 1 and j > 1 and c1 == sbyte(s2, j - 1) and c1Prev == c2 then @@ -3904,22 +3583,13 @@ function Database:DamerauLevenshtein(s1, s2, len1, len2) end if curr[j] < minInRow then minInRow = curr[j] end end - -- Early exit if the best possible in this row already exceeds threshold if minInRow > 2 then return 3 end - prev2, prev, curr = prev, curr, prev2 -- rotate rows + prev2, prev, curr = prev, curr, prev2 end return prev[len2] end --- Fast pre-filter: every distinct character in the query must appear --- somewhere in `text`. Order and position don't matter so fuzzy --- matchers tolerant of transpositions (e.g., "acheivement" vs --- "achievement") still pass through to the real scorers. Missing a --- single query char is a definitive "cannot match" signal. --- --- Builds a small byte-set from the text per call. For stable text --- (POI names), the caller should cache result at a higher level; for --- one-off scoring this remains cheap: O(|text| + |query|). +-- Fast pre-filter: every distinct char in query must appear somewhere in text. local reuseCouldMatchSet = {} function Database:CouldMatch(text, query) local tlen, qlen = #text, #query @@ -3932,35 +3602,25 @@ function Database:CouldMatch(text, query) end for i = 1, qlen do local qb = query:byte(i) - if qb ~= 32 and not seen[qb] then -- skip spaces (multi-word queries) + if qb ~= 32 and not seen[qb] then return false end end return true end --- Unified name scoring: exact → starts-with → word-boundary → substring → initials → fuzzy. --- All search features (UI, map zone, map POI) use this single function. --- Returns a score ≥ 0. Caller decides the minimum threshold. +-- exact → starts-with → word-boundary → substring → initials → fuzzy. function Database:ScoreName(nameLower, query, queryLen, optQueryWords) - -- Trim trailing whitespace so "windrnr " behaves like "windrnr" if ssub(query, queryLen, queryLen) == " " then query = query:match("^(.-)%s+$") or query queryLen = #query if queryLen == 0 then return 0 end end - -- Cheap pre-filter: if the query's chars don't appear in order in - -- the name, no scoring path can match. Saves the subsequence + - -- fuzzy DP pass on the ~90%+ of entries that obviously don't match. if not Database:CouldMatch(nameLower, query) then return 0 end local score = 0 - -- Whole-string matching (works for single and multi-word queries) - -- sfind(plain) avoids the per-entry substring allocation that - -- ssub(haystack, 1, n) == needle would create. With ~3700 entries - -- scored per keystroke this is the dominant per-search alloc. if nameLower == query then score = 200 elseif sfind(nameLower, query, 1, true) == 1 then @@ -3968,24 +3628,20 @@ function Database:ScoreName(nameLower, query, queryLen, optQueryWords) elseif Database:FindAtWordBoundary(nameLower, query) then score = 120 elseif sfind(nameLower, query, 1, true) then - score = 30 -- mid-word substring + score = 30 end - -- Initials matching: "rb" → "Rated Battlegrounds" if score < 130 then local initScore = Database:ScoreInitials(nameLower, query) if initScore > score then score = initScore end end - -- Fuzzy/typo matching against whole query (queries >= 4 chars) if score < 100 and queryLen >= 4 then local fuzzyScore = Database:ScoreFuzzy(nameLower, query, queryLen) if fuzzyScore > score then score = fuzzyScore end end - -- Subsequence matching against name words for vowel-stripped abbreviations - -- Short queries (3-4): "qtr" → "quartermaster" (word must be 2x+ longer) - -- Longer queries (5-7): "windrnr" → "windrunner" (must cover 60%+ of word) + -- Vowel-stripped abbreviations: "qtr" → quartermaster, "windrnr" → windrunner. if score < 50 and queryLen >= 3 and not sfind(query, " ", 1, true) then local nameWords = GetWords(nameLower) for wi = 1, #nameWords do @@ -4005,12 +3661,8 @@ function Database:ScoreName(nameLower, query, queryLen, optQueryWords) end end - -- Per-word matching for multi-word queries: - -- Split query into words and score each against name words. - -- All query words must match for a score to be awarded. - -- Fires even when an inferior path already returned a low score - -- (e.g. mid-word substring 30) so a multi-word typo match like - -- "estern kingd" → "Eastern Kingdoms" can still surface. + -- Multi-word query: all words must match. Fires even after an inferior + -- low-score path so "estern kingd" → "Eastern Kingdoms" can still surface. if score < 100 and sfind(query, " ", 1, true) then local queryWords = optQueryWords if not queryWords then @@ -4051,7 +3703,6 @@ function Database:ScoreName(nameLower, query, queryLen, optQueryWords) ws = 40 end end - -- Subsequence for vowel-stripped words if ws == 0 and qwLen <= 8 and nwLen > qwLen and qwLen / nwLen >= 0.6 then if Database:IsSubsequence(nw, qw, qwLen) then ws = 45 @@ -4088,19 +3739,15 @@ function Database:ScoreName(nameLower, query, queryLen, optQueryWords) return score end --- Unified keyword scoring: additive score from matching against a list of keywords. --- Returns a total score to ADD to the name score. function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) if not keywordsLower then return 0 end - -- Trim trailing whitespace to match ScoreName behavior if ssub(query, queryLen, queryLen) == " " then query = query:match("^(.-)%s+$") or query queryLen = #query if queryLen == 0 then return 0 end end - -- Use pre-split query words if provided, otherwise split here local queryWords = optQueryWords if not queryWords then queryWords = {} @@ -4109,9 +3756,9 @@ function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) end end - -- Single-word query: take the BEST keyword match only (not sum). - -- Summing caused items with redundant keywords (e.g. "reputation" + - -- "reputation achievements") to outscore items with a better name match. + -- Single-word: take BEST kw match, not sum. Summing let items with + -- redundant keywords ("reputation" + "reputation achievements") beat + -- items with a better name match. local numKeywords = #keywordsLower if #queryWords == 1 then local best = 0 @@ -4119,14 +3766,13 @@ function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) local kw = keywordsLower[ki] local kwScore = 0 if kw == query then - -- Short abbreviations (2-3 chars) are intentional, boost above initials + -- Short abbreviations (2-3 chars) get boosted above initials. kwScore = queryLen <= 3 and 140 or 80 elseif sfind(kw, query, 1, true) == 1 then kwScore = 70 elseif Database:FindAtWordBoundary(kw, query) then kwScore = 55 end - -- Initials on keywords (skip for 1-2 char queries: penalty drops score below threshold) if kwScore < 60 and queryLen >= 3 then local initScore = Database:ScoreInitials(kw, query) if initScore > 0 then @@ -4134,12 +3780,10 @@ function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) kwScore = mmax(kwScore, initScore - penalty) end end - -- Fuzzy on keywords if kwScore < 40 and queryLen >= 4 then local kf = Database:ScoreFuzzy(kw, query, queryLen) if kf > 0 then kwScore = mmax(kwScore, kf) end end - -- Subsequence per-word in keywords if kwScore < 50 and queryLen >= 3 then local kwWords = GetWords(kw) for kwi = 1, #kwWords do @@ -4163,13 +3807,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. - -- All query words >= 2 chars must match at least one keyword; a single - -- unmatched word zeroes the total to prevent common words like "of" from - -- producing false positives. Single-character words are SKIPPED (not - -- failed) so a mid-type query like "feral a" still passes through entries - -- that match "feral", otherwise the prevCandidates incremental narrowing - -- chain inherits an empty set and "feral abil" silently returns nothing. + -- Multi-word: all words >= 2 chars must match. Single-char words are + -- SKIPPED (not failed) so mid-type queries like "feral a" don't empty + -- prevCandidates and silently kill the next "feral abil" extension. local total = 0 for qwi = 1, #queryWords do local queryWord = queryWords[qwi] @@ -4187,7 +3827,6 @@ function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) elseif Database:FindAtWordBoundary(kw, queryWord) then kwScore = 55 end - -- Initials on keywords (skip for 1-2 char queries: penalty drops score below threshold) if kwScore < 60 and queryWordLen >= 3 then local initScore = Database:ScoreInitials(kw, queryWord) if initScore > 0 then @@ -4195,7 +3834,6 @@ function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) kwScore = mmax(kwScore, initScore - penalty) end end - -- Fuzzy on keywords if kwScore < 40 and queryWordLen >= 4 then local kf = Database:ScoreFuzzy(kw, queryWord, queryWordLen) if kf > 0 then kwScore = mmax(kwScore, kf) end @@ -4210,14 +3848,13 @@ function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) end total = total + bestScore end - -- queryWordLen < 2: skipped (mid-type), don't fail entry end return total end --- Incremental search state: when the user extends the previous query (e.g. "mou" → "moun"), --- only re-score entries that matched before instead of the full dataset. +-- When the user extends the previous query (e.g. "mou" → "moun"), only +-- re-score entries that matched before instead of the full dataset. local function ScoreSingleFieldWord(fieldWord, queryWord, queryWordLen) if fieldWord == queryWord then return 100 end if sfind(fieldWord, queryWord, 1, true) == 1 then return 90 end @@ -4289,12 +3926,9 @@ function Database:ScoreEntryFields(data, queryWords) end if matched < 2 then return 0 end - -- Discount entries whose match came purely from embedded keyword - -- text (no query word actually appeared in the entry's own name). - -- Without this, an item whose keyword list happens to contain - -- "Eastern Kingdoms" outranks the literal Eastern Kingdoms zone - -- because keyword sums (90 per word) beat name-only avg-capped - -- scoring (110 max). + -- Discount pure-keyword matches: keyword sums (90/word) beat the + -- avg-capped name-only score (110 max), so an item whose keyword list + -- contains "Eastern Kingdoms" would outrank the actual zone. if nameMatches == 0 then total = math.floor(total * 0.45) end @@ -4435,11 +4069,8 @@ function Database:SearchUI(query, skipCategories) return resultsBuf end - -- First-call build: without a ready prefix index every search falls - -- through to a linear scan over uiSearchData. After dynamic providers - -- populate that's ~1500+ entries per keystroke. Build once on demand - -- so the FIRST search after reload doesn't pay the linear-scan cost - -- on every keystroke that follows. + -- Without a ready prefix index, every search linearly scans the full + -- 1500+ entry uiSearchData each keystroke. Build once on demand. if not prefixIndexReady then self:BuildSearchPrefixIndex() end @@ -4447,7 +4078,7 @@ function Database:SearchUI(query, skipCategories) query = slower(query) local queryLen = #query - -- Pre-trim trailing whitespace once (avoids per-entry match() in scoring) + -- Pre-trim trailing whitespace so scoring doesn't match() per entry. if ssub(query, queryLen, queryLen) == " " then query = query:match("^(.-)%s+$") or query queryLen = #query @@ -4460,16 +4091,10 @@ function Database:SearchUI(query, skipCategories) queryWords[#queryWords + 1] = w end - -- Detect " boss" style queries. When the user mentions - -- "boss" anywhere in the query, bosses are allowed to match by - -- their instance keyword (e.g. "icc boss" -> all ICC bosses). - -- Without "boss" present, bosses are restricted to name-only - -- matches so plain "raid"/"dungeon"/"icc" don't flood with them. + -- Gates: without "boss" in the query, bosses match name only ("icc" + -- alone shouldn't flood with bosses). Achievements (~175 entries with + -- broad keywords) are gated behind "ach"/"stat" or a strong name match. local bossQueryWord = false - -- Same gate for achievement entries: there are ~175 of them, and - -- nearly any common word (easter, mounts, kill, dungeon...) shows - -- up in some achievement keyword. Gate them behind the user - -- typing "ach"/"achievement"/etc. or a strong name match. local achQueryWord = false local lootStatQueryWord = false for qi = 1, #queryWords do @@ -4486,7 +4111,6 @@ function Database:SearchUI(query, skipCategories) end end - -- Determine candidate set: incremental (previous matches) or full database local skipKey = skipCategories and ( (skipCategories["Mount"] and "M" or "") .. (skipCategories["Toy"] and "T" or "") .. @@ -4496,26 +4120,13 @@ function Database:SearchUI(query, skipCategories) (skipCategories["Loot"] and "L" or "") ) or "" - -- prevCandidates is missing entries the current (more permissive) - -- scoring pass would now match. Two distinct cases trigger this: - -- - -- 1. A gating flag flipped false → true on this keystroke - -- ("icc bos" → "icc boss" turns bossQueryWord on; "characters" - -- → "characters ach..." turns achQueryWord on). Boss / - -- achievement entries that previously failed the stricter - -- gate were never scored, so prevCandidates contains zero of - -- them. - -- - -- 2. A new word was just appended to a stat-keyword query - -- ("haste" → "haste ring"). lootStatQueryWord stays true - -- throughout, but loot rings are missing from the "ha" - -- prefix bucket (their lootStatKw is populated lazily, after - -- the index was built), so they never made it into - -- prevCandidates. Adding "ring" pulls them in via the "ri" - -- bucket on the multi-token path. - -- - -- Either case bypasses extension for one keystroke and lets the - -- prefix lookup rebuild the candidate set. + -- prevCandidates can miss entries the now-more-permissive pass matches: + -- 1. A gate flipped on ("icc bos" → "icc boss"). Boss/ach entries + -- were never scored before the flip. + -- 2. A word was appended to a stat-keyword query ("haste" → + -- "haste ring"). Loot rings aren't in the "ha" prefix bucket + -- (lootStatKw is enriched lazily); the "ri" bucket pulls them in. + -- Either case bypasses extension and rebuilds via the prefix lookup. local prevLootStat, prevBossWord, prevAchWord = false, false, false local prevWordCount = 0 if prevQuery ~= "" then @@ -4543,8 +4154,6 @@ function Database:SearchUI(query, skipCategories) if prevLen > 0 and skipKey == prevSkipKey and queryLen > prevLen and ssub(query, 1, prevLen) == prevQuery and not gatingShifted then - -- Forward extension of the prior query: re-score the prior - -- candidates only. (Cheapest path.) searchSet = prevCandidates else if prefixIndexReady then @@ -4585,9 +4194,8 @@ function Database:SearchUI(query, skipCategories) if lootStatQueryWord and not data._statsEnriched then Database:EnrichLootStats(data) end - -- 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. + -- Each query word scores against name + slot + stats + source + -- kws; best match wins; an unmatched word eliminates the item. local totalScore = 0 local nameWords = GetWords(nameLower) @@ -4596,11 +4204,9 @@ function Database:SearchUI(query, skipCategories) local qwLen = #qw local bestWord = 0 - -- Score against item name words (prefix match per word). - -- Name match must outrank lootSourceKw (boss name) - -- match below; otherwise loot whose own name contains - -- the query gets buried under same-boss loot that - -- only matched via the encounter name. + -- Name match must outrank lootSourceKw (boss name) below, + -- else loot named for the query gets buried under same- + -- boss loot that only matched via the encounter name. for ni = 1, #nameWords do local nw = nameWords[ni] if nw == qw then @@ -4610,7 +4216,6 @@ function Database:SearchUI(query, skipCategories) end end - -- Score against slot keywords if data.lootSlotKw then for ki = 1, #data.lootSlotKw do local kw = data.lootSlotKw[ki] @@ -4622,7 +4227,6 @@ function Database:SearchUI(query, skipCategories) end end - -- Score against stat keywords if data.lootStatKw then for ki = 1, #data.lootStatKw do local kw = data.lootStatKw[ki] @@ -4634,12 +4238,9 @@ function Database:SearchUI(query, skipCategories) end end - -- Score against source keywords (boss/dungeon name). - -- Prefix match on a boss name is a strong real signal - -- ("nexu" -> "Nexus-Point Xenas"), so its score must - -- beat a 1-edit fuzzy hit (85) on an unrelated setting - -- name like "Next View" -- otherwise gear from the - -- searched encounter ranks below misspellings. + -- Prefix on boss/dungeon name must beat 1-edit fuzzy (85) + -- on unrelated names, so "nexu" -> Nexus-Point Xenas gear + -- doesn't rank below misspellings of other entries. if data.lootSourceKw then for ki = 1, #data.lootSourceKw do local kw = data.lootSourceKw[ki] @@ -4660,18 +4261,13 @@ function Database:SearchUI(query, skipCategories) score = totalScore elseif data.category == "Boss" and not bossQueryWord then - -- Bosses match by name only when the query doesn't - -- mention "boss". This prevents plain "raid"/"dungeon" - -- /"icc" from dragging every encounter into the list. score = Database:ScoreName(nameLower, query, queryLen, queryWords) else local cat = data.category local isAchEntry = cat == "Achievements" or cat == "Guild Achievements" or cat == "Statistics" if isAchEntry and not achQueryWord then - -- Restrict achievement entries to strong name matches - -- only (exact / prefix / starts-with-word). Skip the - -- keyword score entirely so short common keyword + -- Strong name matches only (skip kw score) so short kw -- aliases don't drag every achievement category in. if nameLower == query then score = 200 @@ -4683,12 +4279,8 @@ function Database:SearchUI(query, skipCategories) score = 0 end else - -- MAX, not SUM. Many entries put their lowercased name into - -- keywordsLower (talents, abilities, mounts, ...), so a sum - -- double-counts the same fuzzy hit -- "hearth" matching - -- "heart" once via the name and once via the keyword - -- silently doubles into 170, beating real 150 prefix - -- matches like "Hearthstone Board". + -- MAX, not SUM. Many entries duplicate their name into + -- keywordsLower; a sum double-counts the same fuzzy hit. score = mmax( Database:ScoreName(nameLower, query, queryLen, queryWords), Database:ScoreKeywords(data.keywordsLower, query, queryLen, queryWords) @@ -4715,7 +4307,6 @@ function Database:SearchUI(query, skipCategories) end end end - -- Trim stale entries from previous search for i = candidateIdx + 1, #prevCandidates do prevCandidates[i] = nil end @@ -4731,9 +4322,8 @@ function Database:SearchUI(query, skipCategories) end resultsN = SEARCH_RESULT_CAP end - -- Clear stale data refs on pool slots above the current result - -- count. Without this, a broad search followed by a narrow one - -- leaves the pool holding refs from the broad search forever. + -- Drop stale data refs so a broad-then-narrow search doesn't keep + -- the broad refs alive forever. for i = resultsN + 1, #resultEntryPool do local r = resultEntryPool[i] if r and r.data then @@ -4745,9 +4335,8 @@ function Database:SearchUI(query, skipCategories) return results end --- Static UI database must be built at load time (before ADDON_LOADED) so other --- modules can reference uiSearchData during their own initialization. --- Not routed through Core.lua SafeInit, so wrap here for error safety. +-- Build at load time (before ADDON_LOADED) so other modules can reference +-- uiSearchData during their own init. Not routed through SafeInit, so wrap. local initOk, initErr = pcall(Database.Initialize, Database) if not initOk then print("|cffff4444EasyFind Database failed to initialize: " .. tostring(initErr) .. "|r") diff --git a/DatabaseDynamic.lua b/DatabaseDynamic.lua index 71c913d..05788b0 100644 --- a/DatabaseDynamic.lua +++ b/DatabaseDynamic.lua @@ -157,24 +157,13 @@ function Database:RefreshDynamicCategory(key) return changed end --- LoadCoreDynamicSearchData is now a no-op stub: every provider runs on --- its own frame via LoadDeferredSyncProvidersStaggered after PLAYER_LOGIN. --- Spreading the cost across ~12 frames means no single frame's added --- work is visible as a stutter, matching the pre-refactor experience. function Database:LoadCoreDynamicSearchData() return false end --- Run every sync (non-async) provider one per frame. Each provider runs --- in its own frame, so no single frame stacks the cost of multiple --- collection / spell-book / EJ scans. ~12 frames (~200ms at 60fps) for --- all sync providers to populate after login. function Database:LoadDeferredSyncProvidersStaggered() - -- Batch flag suppresses per-provider ResetSearchCache (which rebuilds - -- the prefix index over the entire uiSearchData each time). Without - -- this each of the ~12 providers triggers a full rebuild. By the - -- end the prefix index has been rebuilt 12 times. We rebuild once - -- at the end of the chain instead. + -- Suppress per-provider ResetSearchCache; rebuild the prefix index + -- once at the end of the staggered chain. self._dynamicBatchLoading = true self._dynamicBatchChanged = false local index = 1 @@ -198,20 +187,14 @@ function Database:LoadDeferredSyncProvidersStaggered() self._dynamicBatchLoading = false self._dynamicBatchChanged = false if changed and self.ResetSearchCache then self:ResetSearchCache() end - -- Pre-warm the prefix index off the loaded dataset so the very - -- first user search doesn't pay the build cost on the keystroke. - -- Cheap if already ready; rebuilds via ResetSearchCache otherwise. if self.WarmSearchHotPath then self:WarmSearchHotPath() end end step() end --- Synchronous heavy-data load. Used at PLAYER_LOGIN so the load --- screen absorbs the cost. Only loot (current spec) is scanned here: --- it's small and matches a common search ("ring", "haste ring"). Boss --- scanning iterates ~1000+ encounters across every expansion tier, so --- it runs through its async provider after login or on boss-related --- searches instead of blocking this sync path. +-- Sync heavy load at PLAYER_LOGIN absorbs cost during the load screen. +-- Only loot (current spec) runs here; boss scanning iterates ~1000+ +-- encounters and runs via its async provider instead. local SYNC_HEAVY_KEYS = { loot = true } function Database:LoadHeavyDynamicSearchDataSync() for i = 1, #dynamicProviders do @@ -222,9 +205,8 @@ function Database:LoadHeavyDynamicSearchDataSync() if fn then local pre = provider.pre and self[provider.pre] if pre then pre(self) end - -- Loot scans ONLY the current spec at login (scanAllSpecs=false). - -- Spec toggles in the loot filter dropdown trigger a lazy scan - -- for that spec via the async path. + -- Loot scans only the current spec at login; spec + -- toggles trigger a lazy scan for that spec. local ok, err = xpcall(fn, Utils.ErrorHandler, self) if ok then provider.loaded = true diff --git a/Highlight.lua b/Highlight.lua index 890bbbc..dfbd2de 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -24,7 +24,7 @@ local C_Timer = C_Timer local GetTime = GetTime local UIParent = UIParent -local HOVER_MIN_DISPLAY = 0.3 -- seconds the highlight must be visible before hover clears it +local HOVER_MIN_DISPLAY = 0.3 local highlightFrame local indicatorFrame @@ -33,14 +33,12 @@ local contextTooltip local currentGuide local currentStepIndex local stepTicker -local highlightShownAt -- GetTime() when the current highlight was first shown +local highlightShownAt +-- Hover-to-dismiss fires only on the final step (or a single-highlight +-- with no guide). Intermediate steps must persist or hover would +-- short-circuit the breadcrumb. local function canHoverDismiss() - -- Hover-to-dismiss only fires on the LAST step of a guide (or when - -- there's no guide running -- a single-highlight, like clicking a - -- search result that didn't open Guide mode). Intermediate guide - -- steps must stick around long enough for the player to see them - -- light up; clearing on hover would short-circuit the breadcrumb. if currentGuide and currentGuide.steps then local total = #currentGuide.steps if currentStepIndex and currentStepIndex < total then return false end @@ -54,8 +52,8 @@ local function isTerminalHighlight() end local function clearTerminalHighlight() - -- Use Cancel, not HideHighlight, so a guide ticker cannot recreate - -- the final highlight after hover, scroll virtualization, or panel close. + -- Cancel (not HideHighlight) so a guide ticker can't recreate the + -- final highlight after hover / scroll / panel close. Highlight:Cancel() end @@ -132,7 +130,7 @@ function Highlight:FindContainerSlotButton(bag, slot) if button then return button end end - -- Classic fallback: old container buttons are indexed in reverse slot order. + -- Old container buttons are indexed in reverse slot order. local CONT = C_Container local getSlots = (CONT and CONT.GetContainerNumSlots) or GetContainerNumSlots for i = 1, 13 do @@ -211,7 +209,6 @@ function Highlight:CreateHighlightFrame() local borderSize = 4 - -- Highlight border is ALWAYS yellow local top = highlightFrame:CreateTexture(nil, "OVERLAY") top:SetColorTexture(YELLOW_HIGHLIGHT[1], YELLOW_HIGHLIGHT[2], YELLOW_HIGHLIGHT[3], 1) highlightFrame.top = top @@ -238,21 +235,12 @@ function Highlight:CreateHighlightFrame() alpha:SetDuration(0.5) highlightFrame.animGroup = animGroup - -- Visibility + identity watcher. Throttled to 0.1s so it's not a - -- per-frame cost. - -- IsVisible() (unlike IsShown) reflects parent-chain visibility, so - -- cascade-hides (panel close, tab switch) trigger a clear even - -- though OnHide doesn't fire on each child. - -- _targetValidator catches the case where the target frame is still - -- visible but no longer represents the original target -- e.g. a - -- ScrollBox button repurposed for a different spell after the user - -- pages the spellbook. Caller passes a closure that re-checks - -- identity (SpellFrameMatchesSelf etc.). - -- Visibility clears only run for terminal/no-guide highlights. The - -- per-step UpdateGuide loop still owns intermediate breadcrumb - -- behavior, avoiding false clears while ScrollBox categories rebuild. - -- Custom glow/spyglass targets opt into hover-dismiss through - -- _hoverDismissFrame because they bypass the regular border path. + -- Throttled visibility + identity watcher. IsVisible (unlike IsShown) + -- reflects parent-chain visibility, so cascade-hides trigger a clear. + -- _targetValidator catches when the target is still visible but no + -- longer represents the original (ScrollBox button repurposed for a + -- different spell, etc.). Clears only on terminal highlights so the + -- per-step UpdateGuide owns intermediate breadcrumb behavior. local watchAccum = 0 highlightFrame:HookScript("OnUpdate", function(self, elapsed) watchAccum = watchAccum + elapsed @@ -289,14 +277,12 @@ function Highlight:CreateIndicatorFrame() indicatorFrame:SetSize(aSize, aSize) indicatorFrame:SetFrameStrata("TOOLTIP") indicatorFrame:SetFrameLevel(501) - indicatorFrame.isUIIndicator = true -- flag so UpdateIndicator applies iconScale + indicatorFrame.isUIIndicator = true indicatorFrame:Hide() - -- Use shared icon creation with UI-specific sizes (no canvas conversion needed) if ns.CreateIndicatorTextures then ns.CreateIndicatorTextures(indicatorFrame, ns.ICON_SIZE, ns.ICON_GLOW_SIZE) else - -- Fallback if MapSearch hasn't loaded yet (shouldn't happen per .toc order) local ind = indicatorFrame:CreateTexture(nil, "ARTWORK") ind:SetSize(80, 80) ind:SetPoint("CENTER") @@ -337,7 +323,6 @@ function Highlight:CreateInstructionFrame() text:SetNonSpaceWrap(true) instructionFrame.text = text - -- Dismiss button local dismissBtn = CreateFrame("Button", nil, instructionFrame, "UIPanelButtonTemplate") dismissBtn:SetSize(80, 22) dismissBtn:SetPoint("BOTTOM", 0, 8) @@ -428,7 +413,6 @@ function Highlight:StartGuide(guideData) self:NotifyClearButton() end --- Start the guide at a specific step (used by DirectOpen to skip to final highlight) function Highlight:StartGuideAtStep(guideData, stepIndex) self:Cancel() @@ -457,8 +441,6 @@ function Highlight:StartGuideAtStep(guideData, stepIndex) self:NotifyClearButton() 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 @@ -484,7 +466,6 @@ function Highlight:UpdateGuide() return end - -- Custom text only if step.customText then self:ShowInstruction(step.customText) C_Timer.After(5, function() self:Cancel() end) @@ -493,44 +474,35 @@ function Highlight:UpdateGuide() local isLastStep = (currentStepIndex == #currentGuide.steps) - -- Step 1 type: Highlight a button directly (like micro menu buttons) if step.buttonFrame then local targetFrame = Utils.GetFrameByPath(step.buttonFrame) or _G[step.buttonFrame] if targetFrame and targetFrame:IsShown() then - -- Check if user clicked it (frame that button opens is now visible) local nextStep = currentGuide.steps[currentStepIndex + 1] if nextStep and nextStep.waitForFrame then local waitFrame = self:GetFrameByPath(nextStep.waitForFrame) if waitFrame and waitFrame:IsShown() then - -- User clicked button, frame is open, advance self:AdvanceStep() return end elseif not nextStep then - -- Single step guide - dismiss on hover if canHoverDismiss() and targetFrame:IsMouseOver() then self:Cancel() return end end - -- Still need to click button - highlight only, no text self:HighlightFrame(targetFrame) end return end - -- Portrait menu step 1: highlight player portrait and tell user to right-click if step.portraitMenu then - -- Check if the portrait menu is already open if self:IsPortraitMenuOpen() then - -- Menu is open, advance to next step self:HideContextTooltip() self:AdvanceStep() return end - -- Highlight the player frame portrait and show contextual tooltip local portrait = PlayerFrame if portrait and portrait:IsShown() then self:HighlightFrame(portrait) @@ -539,14 +511,12 @@ function Highlight:UpdateGuide() return end - -- Game Menu button by text (e.g. "Macros", "Options"). The - -- buttons in GameMenuFrame are unnamed (dynamic hex IDs) but - -- carry their label text, so look up by GetText() on each child. + -- GameMenuFrame buttons are unnamed (dynamic hex IDs) but carry their + -- label text, so look up by GetText() on each child. if step.gameMenuText then if not GameMenuFrame or not GameMenuFrame:IsShown() then - -- Menu closed: in guide mode rewind to reopen, in DirectOpen - -- mode (left-click "open + show me") just cancel since the - -- user has dismissed the destination. + -- In guide mode, rewind to reopen; in DirectOpen mode, cancel + -- since the user dismissed the destination. if currentGuide and currentGuide.noCourseCorrect then self:Cancel() else @@ -570,9 +540,7 @@ function Highlight:UpdateGuide() return end - -- Portrait menu step 2: find and highlight the correct menu option if step.portraitMenuOption then - -- Check if portrait menu is still open if not self:IsPortraitMenuOpen() then self:HideContextTooltip() if currentGuide and currentGuide.noCourseCorrect then @@ -584,17 +552,14 @@ function Highlight:UpdateGuide() return end - -- Find the menu option button and highlight it local optionBtn = self:FindPortraitMenuOption(step.portraitMenuOption) if optionBtn then self:HighlightFrame(optionBtn) - -- Check for hover/click to dismiss if canHoverDismiss() and optionBtn:IsMouseOver() then self:Cancel() return end else - -- Option not found in the open menu - it's not available in this context self:HideHighlight() self:ShowInstruction("'" .. step.portraitMenuOption .. "' is not available here") C_Timer.After(2.5, function() self:Cancel() end) @@ -603,16 +568,14 @@ function Highlight:UpdateGuide() return end - -- Container item highlight (standalone step, no waitForFrame): - -- highlights the first visible slot and glows duplicates through - -- the bag search overlay. Opening bags is one-shot in SelectResult; - -- this ticker must not reopen bags after the user closes them. + -- Standalone container item highlight: highlights the first visible + -- slot and glows duplicates via the bag search overlay. Opening bags + -- is one-shot in SelectResult; don't reopen them here. if step.containerBag ~= nil and step.containerSlot then self:HighlightContainerSlotStep(step) return end - -- Step 2+ type: Wait for frame, then highlight tab or region if step.waitForFrame then local frame = self:GetFrameByPath(step.waitForFrame) @@ -626,38 +589,28 @@ function Highlight:UpdateGuide() return end - -- Frame is open if step.tabIndex then - -- Check if already on correct tab if self:IsTabSelected(step.waitForFrame, step.tabIndex) then - -- Correct tab, advance to next step self:AdvanceStep() return end - -- Need to click tab local tabBtn = self:GetTabButton(step.waitForFrame, step.tabIndex) if tabBtn then - -- Highlight tab (even if disabled - show user where it is) self:HighlightFrame(tabBtn) - - -- If button is disabled and user hovers over it, clear the highlight local isEnabled = not tabBtn.IsEnabled or tabBtn:IsEnabled() if canHoverDismiss() and not isEnabled and tabBtn:IsMouseOver() then self:Cancel() return end elseif isLastStep then - -- Can't find tab button on last step, show instruction self:ShowInstruction(step.text or "Click the correct tab") end return end - -- Battle pet terminal step. The UI module owns the Pet Journal - -- reveal logic because it already handles pet IDs/species IDs and - -- direct-open behavior; Guide only needs to highlight the row it - -- returns. + -- UI module owns Pet Journal reveal logic (pet IDs / species IDs + -- and direct-open); Guide only highlights the row it returns. if step.petID or step.speciesID then local row = ns.UI and ns.UI.RevealPetInJournal and ns.UI:RevealPetInJournal(step) @@ -673,9 +626,6 @@ function Highlight:UpdateGuide() return end - -- EJ tier + dungeon/raid tab (boss navigation). Sets the tier on - -- the EJ then highlights the correct tab; advances when both - -- the tab is active and the InstanceSelect ScrollBox is showing. if step.waitForFrame == "EncounterJournal" and step.ejTier then local tabIdx = step.ejTabIsRaid and 5 or 4 if not step._tierApplied then @@ -694,10 +644,7 @@ function Highlight:UpdateGuide() return end - -- Side tab (like Dungeon Finder / Raid Finder / Premade Groups in Group Finder) if step.sideTabIndex then - -- First check: are we still on the correct main tab? - -- Look back through previous steps to find the required tabIndex local requiredTabIndex = nil for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] @@ -710,7 +657,6 @@ function Highlight:UpdateGuide() if requiredTabIndex then local currentTab = self:GetCurrentTabIndex(step.waitForFrame) if currentTab and currentTab ~= requiredTabIndex then - -- User navigated to wrong tab - go back to the tab selection step for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] if prevStep and prevStep.tabIndex == requiredTabIndex then @@ -722,30 +668,22 @@ function Highlight:UpdateGuide() end end - -- Check if already on correct side tab if self:IsSideTabSelected(step.waitForFrame, step.sideTabIndex) then - -- Correct tab, advance to next step self:AdvanceStep() return end - -- Need to click side tab local sideBtn = self:GetSideTabButton(step.waitForFrame, step.sideTabIndex) if sideBtn and sideBtn:IsShown() then - -- Highlight side tab (even if disabled - show user where it is) self:HighlightFrame(sideBtn) - - -- If button is disabled and user hovers over it, clear the highlight local isEnabled = not sideBtn.IsEnabled or sideBtn:IsEnabled() if canHoverDismiss() and not isEnabled and sideBtn:IsMouseOver() then self:Cancel() return end elseif isLastStep then - -- Can't find side button on last step, show instruction self:ShowInstruction(step.text or "Click the correct option on the left") else - -- Button not found/visible - might be on wrong tab, go back if currentStepIndex > 1 then currentStepIndex = currentStepIndex - 1 self:HideHighlight() @@ -754,10 +692,7 @@ function Highlight:UpdateGuide() return end - -- PVP Side tab (Quick Match / Rated / Premade Groups / Training Grounds in PvP tab) if step.pvpSideTabIndex then - -- First check: are we still on the correct main tab (PvP tab = tab 2)? - -- Look back through previous steps to find the required tabIndex local requiredTabIndex = nil for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] @@ -770,8 +705,6 @@ function Highlight:UpdateGuide() if requiredTabIndex then local currentTab = self:GetCurrentTabIndex(step.waitForFrame) if currentTab and currentTab ~= requiredTabIndex then - -- User navigated to wrong tab (e.g., clicked Dungeons & Raids instead of staying on PvP) - -- Go back to the tab selection step for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] if prevStep and prevStep.tabIndex == requiredTabIndex then @@ -783,30 +716,22 @@ function Highlight:UpdateGuide() end end - -- Check if already on correct PvP side tab if self:IsPvPSideTabSelected(step.waitForFrame, step.pvpSideTabIndex) then - -- Correct tab, advance to next step self:AdvanceStep() return end - -- Need to click PvP side tab local pvpBtn = self:GetPvPSideTabButton(step.waitForFrame, step.pvpSideTabIndex) if pvpBtn and pvpBtn:IsShown() then - -- Highlight PvP side tab (even if disabled - show user where it is) self:HighlightFrame(pvpBtn) - - -- If button is disabled and user hovers over it, clear the highlight local isEnabled = not pvpBtn.IsEnabled or pvpBtn:IsEnabled() if canHoverDismiss() and not isEnabled and pvpBtn:IsMouseOver() then self:Cancel() return end elseif isLastStep then - -- Can't find side button on last step, show instruction self:ShowInstruction(step.text or "Click the correct option on the left") else - -- Button not found/visible - might be on wrong tab, go back if currentStepIndex > 1 then currentStepIndex = currentStepIndex - 1 self:HideHighlight() @@ -815,9 +740,7 @@ function Highlight:UpdateGuide() return end - -- Statistics category navigation (tree-based category selection) if step.statisticsCategory then - -- First check: are we still on the Statistics tab? local currentTab = PanelTemplates_GetSelectedTab and PanelTemplates_GetSelectedTab(AchievementFrame) if currentTab ~= 3 then for i, s in ipairs(currentGuide.steps) do @@ -830,9 +753,9 @@ function Highlight:UpdateGuide() return end - -- Prerequisite check: verify ALL earlier statisticsCategory steps are still - -- expanded (children visible). A parent doesn't need to stay "selected" once - -- we've drilled into a child - it just needs to still be expanded. + -- Verify earlier statisticsCategory steps are still expanded. + -- A parent doesn't need to stay selected after drilling in, + -- just expanded. for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] if prevStep and prevStep.statisticsCategory then @@ -844,24 +767,22 @@ function Highlight:UpdateGuide() end end - -- Check if already on correct statistics category if self:IsCategorySelectedByData(step.statisticsCategory, step.statisticsCategoryID) then if isLastStep then self:Cancel() return end - -- Non-final: only advance if children are actually visible (parent expanded), - -- not just selected - clicking a collapsed parent selects it without showing children + -- Clicking a collapsed parent selects without showing + -- children, so only advance if expanded. local elementData = self:FindCategoryElementData(step.statisticsCategory, step.statisticsCategoryID) if not elementData or not elementData.parent or not elementData.collapsed then self:AdvanceStep() return end - -- Selected but collapsed - fall through to highlight so user expands it end - -- For non-final steps: if the category is a parent that's expanded (children visible), - -- skip ahead - don't force the user to re-select a parent they've already drilled into + -- If the category is an expanded parent, skip ahead instead + -- of forcing the user to re-select it. if not isLastStep then local elementData = self:FindCategoryElementData(step.statisticsCategory, step.statisticsCategoryID) if elementData and elementData.parent and not elementData.collapsed then @@ -870,7 +791,6 @@ function Highlight:UpdateGuide() end end - -- Not selected - find the button (scrolls into view automatically) local categoryBtn = self:GetStatisticsCategoryButton(step.statisticsCategory, step.statisticsCategoryID) if categoryBtn then self:HighlightFrame(categoryBtn) @@ -880,10 +800,6 @@ function Highlight:UpdateGuide() return end - -- Statistic terminal step. AchievementFrameStats.ScrollBox's - -- data provider stores entries shaped { id, colorIndex, header } - -- where `id` is the statistic ID. Match on that, scroll the - -- row into view, then highlight the rendered button. if step.statisticID then if not (AchievementFrame and AchievementFrame:IsShown()) then self:Cancel() @@ -915,11 +831,6 @@ function Highlight:UpdateGuide() return end - -- Achievement-by-ID terminal step. Drive Blizzard's own - -- OpenAchievementFrameToAchievement to scroll the - -- AchievementFrameAchievements.ScrollBox to the row, then - -- look up the visible button via the data provider's selected - -- element and layer our yellow border on it. if step.achievementID then if not (AchievementFrame and AchievementFrame:IsShown()) then self:Cancel() @@ -947,16 +858,11 @@ function Highlight:UpdateGuide() return end - -- Achievement category navigation (tree-based category selection in Achievements/Guild tabs) if step.achievementCategory then - -- If the player closed the achievement panel, don't try to - -- reopen / rewind. We deprecated that behavior; the guide - -- just cancels. if not (AchievementFrame and AchievementFrame:IsShown()) then self:Cancel() return end - -- First check: are we still on the correct tab? local requiredTabIndex = nil for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] @@ -969,7 +875,6 @@ function Highlight:UpdateGuide() if requiredTabIndex then local currentTab = PanelTemplates_GetSelectedTab and PanelTemplates_GetSelectedTab(AchievementFrame) if currentTab and currentTab ~= requiredTabIndex then - -- User clicked away to wrong tab - go back to the tab selection step for i, s in ipairs(currentGuide.steps) do if s.tabIndex == requiredTabIndex then currentStepIndex = i @@ -981,9 +886,6 @@ function Highlight:UpdateGuide() end end - -- Prerequisite check: verify ALL earlier achievementCategory steps are still - -- expanded (children visible). A parent doesn't need to stay "selected" once - -- we've drilled into a child - it just needs to still be expanded. for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] if prevStep and prevStep.achievementCategory then @@ -995,23 +897,18 @@ function Highlight:UpdateGuide() end end - -- Check if already on correct category if self:IsCategorySelectedByData(step.achievementCategory, step.achievementCategoryID) then if isLastStep then self:Cancel() return end - -- Non-final: only advance if children are actually visible (parent expanded), - -- not just selected - clicking a collapsed parent selects it without showing children local elementData = self:FindCategoryElementData(step.achievementCategory, step.achievementCategoryID) if not elementData or not elementData.parent or not elementData.collapsed then self:AdvanceStep() return end - -- Selected but collapsed - fall through to highlight so user expands it end - -- Not selected - find the button (scrolls into view automatically) local categoryBtn = self:GetAchievementCategoryButton(step.achievementCategory, step.achievementCategoryID) if categoryBtn then self:HighlightFrame(categoryBtn) @@ -1021,9 +918,7 @@ function Highlight:UpdateGuide() return end - -- Character Frame sidebar buttons (Character Stats, Titles, Equipment Manager) if step.sidebarButtonFrame or step.sidebarIndex then - -- Check we're on the correct CharacterFrame tab first local requiredTabIndex = nil for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] @@ -1047,7 +942,6 @@ function Highlight:UpdateGuide() end end - -- Check if the sidebar tab is already selected if self:IsSidebarTabSelected(step.sidebarIndex) then if isLastStep then self:Cancel() @@ -1057,7 +951,6 @@ function Highlight:UpdateGuide() return end - -- Highlight the sidebar tab button local sidebarBtn = self:GetSidebarTabButton(step.sidebarIndex) if sidebarBtn then self:HighlightFrame(sidebarBtn) @@ -1072,9 +965,7 @@ function Highlight:UpdateGuide() return end - -- Currency header expansion (expand a header section in the Currency tab) if step.currencyHeader then - -- Check we're on the correct CharacterFrame tab (Currency = tab 3) local requiredTabIndex = nil for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] @@ -1098,11 +989,9 @@ function Highlight:UpdateGuide() end end - -- Check if header is already expanded, collapsed, or not in list (parent collapsed) local headerState = self:IsCurrencyHeaderExpanded(step.currencyHeader) if headerState == true then - -- Header is expanded - advance to next step if isLastStep then self:Cancel() else @@ -1112,8 +1001,6 @@ function Highlight:UpdateGuide() end if headerState == nil then - -- Header not found - parent must be collapsed. - -- First, try to go back to previous currencyHeader step for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] if prevStep and prevStep.currencyHeader then @@ -1123,46 +1010,37 @@ function Highlight:UpdateGuide() end end - -- No previous step found - find and highlight first collapsed header from top if C_CurrencyInfo and C_CurrencyInfo.GetCurrencyListSize then local size = C_CurrencyInfo.GetCurrencyListSize() for i = 1, size do local info = C_CurrencyInfo.GetCurrencyListInfo(i) if info and info.isHeader and not info.isHeaderExpanded then - -- Try to find and highlight the button local headerBtn = self:GetCurrencyHeaderButton(info.name) if headerBtn then self:HighlightFrame(headerBtn) return else - -- Button not found - hide and retry self:HideHighlight() return end end end end - -- All headers expanded but target still not found self:HideHighlight() return end - -- headerState == false: header is visible but collapsed - find and highlight the button local headerBtn = self:GetCurrencyHeaderButton(step.currencyHeader) if headerBtn then - -- Found the button - highlight it for user to click self:HighlightFrame(headerBtn) return else - -- Button not found - hide highlight and retry on next tick self:HideHighlight() return end end - -- Currency row highlight (scroll to and highlight a specific currency by ID) if step.currencyID then - -- Check we're on the correct CharacterFrame tab (Currency = tab 3) local requiredTabIndex = nil for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] @@ -1186,22 +1064,18 @@ function Highlight:UpdateGuide() end end - -- Check if ALL parent headers are still expanded; if any collapsed or missing, go back for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] if prevStep and prevStep.currencyHeader then local state = self:IsCurrencyHeaderExpanded(prevStep.currencyHeader) if state ~= true then - -- Either collapsed (false) or parent not visible (nil) - go back currentStepIndex = i self:HideHighlight() return end - -- Don't break - check ALL parent headers in the chain end end - -- Scroll to the currency and highlight its row self:ScrollToCurrencyRow(step.currencyID) local currencyBtn = self:GetCurrencyRowButton(step.currencyID) if currencyBtn then @@ -1218,9 +1092,7 @@ function Highlight:UpdateGuide() return end - -- Faction header expansion (expand a header section in the Reputation tab) if step.factionHeader then - -- Check we're on the correct CharacterFrame tab (Reputation = tab 2) local requiredTabIndex = nil for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] @@ -1244,11 +1116,9 @@ function Highlight:UpdateGuide() end end - -- Check if header is already expanded, collapsed, or not in list (parent collapsed) local headerState = self:IsFactionHeaderExpanded(step.factionHeader) if headerState == true then - -- Header is expanded - advance to next step if isLastStep then self:Cancel() else @@ -1258,8 +1128,6 @@ function Highlight:UpdateGuide() end if headerState == nil then - -- Header not found - parent must be collapsed. - -- First, try to go back to previous factionHeader step for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] if prevStep and prevStep.factionHeader then @@ -1269,19 +1137,16 @@ function Highlight:UpdateGuide() end end - -- No previous step found - find and highlight first collapsed header from top if C_Reputation and C_Reputation.GetNumFactions then local numFactions = C_Reputation.GetNumFactions() for i = 1, numFactions do local factionData = C_Reputation.GetFactionDataByIndex(i) if factionData and factionData.isHeader and not factionData.isHeaderExpanded then - -- Try to find and highlight the button local headerBtn = self:GetFactionHeaderButton(factionData.name) if headerBtn then self:HighlightFrame(headerBtn) return else - -- Button not found - hide and retry self:HideHighlight() return end @@ -1289,27 +1154,21 @@ function Highlight:UpdateGuide() end end - -- No collapsed headers found - wait self:HideHighlight() return end - -- headerState == false: header is visible but collapsed - find and highlight the button local headerBtn = self:GetFactionHeaderButton(step.factionHeader) if headerBtn then - -- Found the button - highlight it for user to click self:HighlightFrame(headerBtn) return else - -- Button not found - hide highlight and retry on next tick self:HideHighlight() return end end - -- Faction row highlight (scroll to and highlight a specific faction by ID) if step.factionID then - -- Check we're on the correct CharacterFrame tab (Reputation = tab 2) local requiredTabIndex = nil for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] @@ -1333,25 +1192,21 @@ function Highlight:UpdateGuide() end end - -- Check if ALL parent headers are still expanded; if any collapsed or missing, go back for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] if prevStep and prevStep.factionHeader then local state = self:IsFactionHeaderExpanded(prevStep.factionHeader) if state ~= true then - -- Either collapsed (false) or parent not visible (nil) - go back currentStepIndex = i self:HideHighlight() return end - -- Don't break - check ALL parent headers in the chain end end - -- Scroll to the faction and highlight its row. - -- ScrollToFactionRow issues the scroll command; the ScrollBox may need one - -- render frame to update, so GetFactionRowButton can return nil on the first - -- tick. Returning without showing any instruction lets the 0.1s ticker retry. + -- GetFactionRowButton can return nil on the first tick after + -- ScrollToFactionRow: the ScrollBox needs a render frame to + -- update. The 0.1s ticker retries. self:ScrollToFactionRow(step.factionID) local factionBtn = self:GetFactionRowButton(step.factionID) if factionBtn then @@ -1364,19 +1219,15 @@ function Highlight:UpdateGuide() return end - -- PREREQUISITE VALIDATION for final-destination steps (regionFrames, - -- searchButtonText, text-only). Walk backwards through the step list - -- and make sure every earlier tab / side-tab prerequisite is still - -- satisfied. If the user switched away (e.g. clicked Training Grounds - -- while we expect Premade Groups) we rewind to that step so the guide - -- re-highlights the correct side-tab button instead of pointing at - -- empty space. + -- Prerequisite validation for final-destination steps. Rewind to + -- any earlier step whose tab / side-tab is no longer satisfied so + -- we re-highlight the correct button instead of pointing at empty + -- space. do for i = currentStepIndex - 1, 1, -1 do local prev = currentGuide.steps[i] if not prev then break end - -- Validate main tab (tabIndex) if prev.tabIndex and prev.waitForFrame then local currentTab = self:GetCurrentTabIndex(prev.waitForFrame) if currentTab and currentTab ~= prev.tabIndex then @@ -1386,7 +1237,6 @@ function Highlight:UpdateGuide() end end - -- Validate PvP side tab (pvpSideTabIndex) if prev.pvpSideTabIndex and prev.waitForFrame then if not self:IsPvPSideTabSelected(prev.waitForFrame, prev.pvpSideTabIndex) then currentStepIndex = i @@ -1395,7 +1245,6 @@ function Highlight:UpdateGuide() end end - -- Validate PvE side tab (sideTabIndex) if prev.sideTabIndex and prev.waitForFrame then if not self:IsSideTabSelected(prev.waitForFrame, prev.sideTabIndex) then currentStepIndex = i @@ -1404,7 +1253,6 @@ function Highlight:UpdateGuide() end end - -- Validate Character Frame sidebar tab (sidebarIndex) if prev.sidebarIndex then if not self:IsSidebarTabSelected(prev.sidebarIndex) then currentStepIndex = i @@ -1413,8 +1261,6 @@ function Highlight:UpdateGuide() end end - -- Validate EJ tier (user picked a different tier dropdown). - -- If the live tier no longer matches, rewind so we re-set it. if prev.ejTier then local liveTier = EJ_GetCurrentTier and EJ_GetCurrentTier() if liveTier and liveTier ~= prev.ejTier then @@ -1425,7 +1271,6 @@ function Highlight:UpdateGuide() end end - -- Validate EJ boss (encounterID changed = user clicked different boss) if prev.ejEncounterID then local infoFrame = _G["EncounterJournalEncounterFrameInfo"] local ej = _G["EncounterJournal"] @@ -1443,7 +1288,6 @@ function Highlight:UpdateGuide() 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 @@ -1456,10 +1300,7 @@ function Highlight:UpdateGuide() 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() @@ -1479,11 +1320,8 @@ function Highlight:UpdateGuide() 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) @@ -1493,7 +1331,6 @@ function Highlight:UpdateGuide() 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 @@ -1527,7 +1364,6 @@ function Highlight:UpdateGuide() return end - -- EJ loot tab: highlight if not selected, advance when loot content visible if step.ejLootTab then if step.ejDifficultyID and not step._diffSet then step._diffSet = true @@ -1535,13 +1371,11 @@ function Highlight:UpdateGuide() end 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 @@ -1556,7 +1390,6 @@ function Highlight:UpdateGuide() 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 @@ -1579,7 +1412,6 @@ function Highlight:UpdateGuide() 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 @@ -1595,7 +1427,6 @@ function Highlight:UpdateGuide() return end - -- Wardrobe Sets tab: switch to the Sets sub-tab within WardrobeCollectionFrame if step.wardrobeSetsTab then local wcf = _G["WardrobeCollectionFrame"] if wcf and wcf.SetsCollectionFrame and wcf.SetsCollectionFrame:IsShown() then @@ -1609,8 +1440,6 @@ function Highlight:UpdateGuide() return end - -- Transmog set: scroll to and highlight in the Sets ScrollBox - -- Transmog set: scroll to it in the left list, highlight the row. if step.transmogSetID then local wcf = _G["WardrobeCollectionFrame"] local scf = wcf and wcf.SetsCollectionFrame @@ -1620,8 +1449,7 @@ function Highlight:UpdateGuide() local scrollBox = lc and lc.ScrollBox -- ScrollToElementData silently fails for this ScrollBox. - -- Find the element's index in the data provider and use - -- SetScrollPercentage to scroll to the calculated position. + -- Use SetScrollPercentage against the element's index instead. if scrollBox and scrollBox.SetScrollPercentage then local dp = scrollBox.GetDataProvider and scrollBox:GetDataProvider() if dp then @@ -1640,7 +1468,6 @@ function Highlight:UpdateGuide() end end - -- Highlight the row if scrollBox then local setBtn = ScrollBoxFindButton(scrollBox, function(btn) local edata = btn.GetElementData and btn:GetElementData() @@ -1656,7 +1483,6 @@ function Highlight:UpdateGuide() return end - -- Transmog variant dropdown: highlight it for the user to click if step.transmogVariantDropdown then local wcf = _G["WardrobeCollectionFrame"] local scf = wcf and wcf.SetsCollectionFrame @@ -1668,7 +1494,6 @@ function Highlight:UpdateGuide() return end - -- Transmog variant: use SelectSet to switch to the variant if step.transmogVariantSetID then local wcf = _G["WardrobeCollectionFrame"] local scf = wcf and wcf.SetsCollectionFrame @@ -1679,19 +1504,10 @@ function Highlight:UpdateGuide() return end - -- Talent node: prefer TalentsFrame's own button-by-nodeID lookup - -- when available (Midnight's TalentFrameBaseMixin exposes it), and - -- fall back to enumerating known button containers. ButtonsParent - -- holds class talents; hero/sub-tree talents live under their own - -- containers, so we walk each candidate parent. + -- Match by talent NAME against the talent button frame names. + -- nodeID-based lookup is unreliable across talent revisions; + -- name matching works for class and hero talents alike. if step.talentNodeID then - -- Match by talent NAME against the talent button frames, - -- which are frame-named after their talent (per inspect: - -- "PlayerSpellsFrame.TalentsFrame.ButtonsParent." with - -- the parent path showing "Forestwalk" / "Thick Hide" / - -- etc.). nodeID-based lookup has been unreliable across - -- talent revisions; matching by GetName() works for class - -- talents AND hero talents. local talentsFrame = PlayerSpellsFrame and PlayerSpellsFrame.TalentsFrame if not talentsFrame then return end local targetLower = (currentGuide.name or ""):lower() @@ -1702,9 +1518,8 @@ function Highlight:UpdateGuide() return n and n:lower() or nil end - -- Recursive search: choice-node options nest one extra level - -- below ButtonsParent's direct children, so a fixed 2-level - -- walk misses them. Cap depth to avoid parent loops. + -- Choice-node options nest one level below ButtonsParent's + -- direct children, so a fixed 2-level walk misses them. local function searchTree(frame, depth) if not frame or depth > 5 then return nil end if frame.SearchIcon and nameOf(frame) == targetLower then @@ -1746,18 +1561,15 @@ function Highlight:UpdateGuide() return end - -- Text-only final step (when we've navigated but can't highlight specific element) if step.text and not step.regionFrames and not step.regionFrame and not step.searchButtonText then self:ShowInstruction(step.text) return end - -- Highlight a region (like PvP talents area) - these are final destinations if step.regionFrames or step.regionFrame then local framePaths = step.regionFrames or { step.regionFrame } local region = nil - -- Try each possible frame path for _, path in ipairs(framePaths) do local testFrame = self:GetFrameByPath(path) if testFrame and testFrame:IsShown() then @@ -1766,27 +1578,22 @@ function Highlight:UpdateGuide() end end - -- If path-based lookup failed, try text-based search as fallback if not region and step.searchButtonText then region = self:FindRatedPvPButton(step.searchButtonText) end if region then - -- Found the region, highlight it (no text needed) self:HighlightFrame(region) - -- Check for hover to dismiss if canHoverDismiss() and region:IsMouseOver() then self:Cancel() return end else - -- Can't find region frame, show instruction text since this is final step self:ShowInstruction(step.text or "Look for this area in the current window") end return end - -- Search for a button by text (for PvP rated queue buttons like Solo Shuffle, 2v2, 3v3) if step.searchButtonText then local pvpBtn = self:FindRatedPvPButton(step.searchButtonText) if pvpBtn then @@ -1815,7 +1622,6 @@ end function Highlight:GetFrameByPath(path) if not path then return nil end - -- Special dynamic lookup for unnamed frames if path == "FIND_PVP_TALENTS" then return self:FindPvPTalentsTray() end @@ -1841,17 +1647,14 @@ function Highlight:FindPvPTalentsTray() end function Highlight:IsTabSelected(frameName, tabIndex) - -- PlayerSpellsFrame (Talents & Spellbook) if frameName == "PlayerSpellsFrame" then local frame = PlayerSpellsFrame if frame then - -- Try multiple methods to detect current tab if frame.GetTab then local currentTab = frame:GetTab() return currentTab == tabIndex end - -- Check which tab content is visible - -- Tab 1 = Specialization, Tab 2 = Talents, Tab 3 = Spellbook + -- Tab 1=Spec, 2=Talents, 3=Spellbook if tabIndex == 1 and frame.SpecFrame and frame.SpecFrame:IsShown() then return true elseif tabIndex == 2 and frame.TalentsFrame and frame.TalentsFrame:IsShown() then @@ -1865,7 +1668,6 @@ function Highlight:IsTabSelected(frameName, tabIndex) return false end - -- CollectionsJournal if frameName == "CollectionsJournal" then local frame = CollectionsJournal if frame and PanelTemplates_GetSelectedTab then @@ -1874,29 +1676,23 @@ function Highlight:IsTabSelected(frameName, tabIndex) return false end - -- CharacterFrame (Character Info tabs: Character, Reputation, Currency) if frameName == "CharacterFrame" then local frame = CharacterFrame if frame and PanelTemplates_GetSelectedTab then return PanelTemplates_GetSelectedTab(frame) == tabIndex end - -- Fallback: check by content visibility if tabIndex == 1 then - -- Character tab (paperdoll) if PaperDollFrame and PaperDollFrame:IsShown() then return true end if CharacterStatsPane and CharacterStatsPane:IsShown() then return true end elseif tabIndex == 2 then - -- Reputation tab if ReputationFrame and ReputationFrame:IsShown() then return true end elseif tabIndex == 3 then - -- Currency tab if TokenFrame and TokenFrame:IsShown() then return true end if CurrencyFrame and CurrencyFrame:IsShown() then return true end end return false end - -- PVEFrame (Group Finder tabs at bottom) if frameName == "PVEFrame" then local frame = PVEFrame if frame and PanelTemplates_GetSelectedTab then @@ -1905,7 +1701,6 @@ function Highlight:IsTabSelected(frameName, tabIndex) return false end - -- AchievementFrame if frameName == "AchievementFrame" then local frame = AchievementFrame if frame and PanelTemplates_GetSelectedTab then @@ -1914,59 +1709,46 @@ function Highlight:IsTabSelected(frameName, tabIndex) return false end - -- EncounterJournal (Adventure Guide) if frameName == "EncounterJournal" then local frame = EncounterJournal if frame then - -- Modern Adventure Guide tabs (7 total in modern WoW): - -- Tab 1 = Journeys, Tab 2 = Traveler's Log, Tab 3 = Suggested Content - -- Tab 4 = Dungeons, Tab 5 = Raids, Tab 6 = Item Sets (Loot), Tab 7 = Tutorials - - -- Check by specific frame visibility + -- 1=Journeys 2=TravelersLog 3=Suggested 4=Dungeons 5=Raids + -- 6=ItemSets/Loot 7=Tutorials local tabContentChecks = { - -- Tab 1: Journeys function() if frame.JourneysFrame and frame.JourneysFrame:IsShown() then return true end return false end, - -- Tab 2: Traveler's Log function() if frame.TravelersLogFrame and frame.TravelersLogFrame:IsShown() then return true end return false end, - -- Tab 3: Suggested Content function() if frame.suggestFrame and frame.suggestFrame:IsShown() then return true end if frame.SuggestFrame and frame.SuggestFrame:IsShown() then return true end return false end, - -- Tab 4: Dungeons function() if frame.instanceSelect and frame.instanceSelect:IsShown() then - -- Check if we're filtering to dungeons if frame.instanceSelect.tabsEnabled then return frame.instanceSelect.tabsEnabled[1] == true end end return false end, - -- Tab 5: Raids function() if frame.instanceSelect and frame.instanceSelect:IsShown() then - -- Check if we're filtering to raids if frame.instanceSelect.tabsEnabled then return frame.instanceSelect.tabsEnabled[2] == true end end return false end, - -- Tab 6: Item Sets / Loot function() if frame.LootJournal and frame.LootJournal:IsShown() then return true end if frame.LootJournalFrame and frame.LootJournalFrame:IsShown() then return true end return false end, - -- Tab 7: Tutorials function() if frame.TutorialFrame and frame.TutorialFrame:IsShown() then return true end return false @@ -1978,7 +1760,6 @@ function Highlight:IsTabSelected(frameName, tabIndex) return true end - -- Try PanelTemplates as fallback if PanelTemplates_GetSelectedTab then local selectedTab = PanelTemplates_GetSelectedTab(frame) if selectedTab == tabIndex then return true end @@ -1990,7 +1771,6 @@ function Highlight:IsTabSelected(frameName, tabIndex) return false end --- Get the currently selected tab index for a frame function Highlight:GetCurrentTabIndex(frameName) local frame = _G[frameName] if frame and PanelTemplates_GetSelectedTab then @@ -2000,16 +1780,15 @@ function Highlight:GetCurrentTabIndex(frameName) end function Highlight:IsSideTabSelected(frameName, sideTabIndex) - -- PVEFrame side buttons (Dungeon Finder, Raid Finder, Premade Groups) if frameName == "PVEFrame" then if sideTabIndex == 1 then return LFDParentFrame and LFDParentFrame:IsShown() elseif sideTabIndex == 2 then return RaidFinderFrame and RaidFinderFrame:IsShown() elseif sideTabIndex == 3 then - -- Premade Groups: check LFGListPVEStub first, then LFGListFrame (modern WoW) - -- BUT only if Dungeon Finder and Raid Finder are NOT active (LFGListFrame is - -- a shared frame that may report IsShown even when another panel is on top) + -- LFGListFrame is shared across sub-panels and may report + -- IsShown even when another panel is on top, so explicitly + -- rule them out first. if LFDParentFrame and LFDParentFrame:IsShown() then return false end @@ -2019,7 +1798,6 @@ function Highlight:IsSideTabSelected(frameName, sideTabIndex) if LFGListPVEStub and LFGListPVEStub:IsShown() then return true end - -- Check if LFGListFrame.CategorySelection is visible (the actual premade category list) if LFGListFrame and LFGListFrame.CategorySelection and LFGListFrame.CategorySelection:IsShown() then return true end @@ -2032,14 +1810,12 @@ function Highlight:IsSideTabSelected(frameName, sideTabIndex) end function Highlight:IsPvPSideTabSelected(frameName, sideTabIndex) - -- PVEFrame PvP side buttons (Quick Match, Rated, Premade Groups, Training Grounds) if frameName == "PVEFrame" then - -- First check the known panel frames for each tab local tab1Active = HonorFrame and HonorFrame:IsShown() local tab2Active = ConquestFrame and ConquestFrame:IsShown() local tab4Active = TrainingGroundsFrame and TrainingGroundsFrame:IsShown() - -- Tab 3 (Premade Groups): LFGListFrame is shared, so we must explicitly - -- rule out every other sub-panel before trusting it. + -- LFGListFrame is shared, so rule out every other sub-panel + -- before trusting it. local tab3Active = false if not tab1Active and not tab2Active and not tab4Active then if LFGListPVPStub and LFGListPVPStub:IsShown() then @@ -2057,11 +1833,9 @@ function Highlight:IsPvPSideTabSelected(frameName, sideTabIndex) elseif sideTabIndex == 3 then return tab3Active elseif sideTabIndex == 4 then - -- Training Grounds: check TrainingGroundsFrame (confirmed via DevFrame) if TrainingGroundsFrame and TrainingGroundsFrame:IsShown() then return true end - -- Fallback: we're on it if we're on PvP tab but none of the other 3 panels are active local onPvPTab = PanelTemplates_GetSelectedTab and PanelTemplates_GetSelectedTab(PVEFrame) == 2 if onPvPTab and not tab1Active and not tab2Active and not tab3Active then return true @@ -2069,7 +1843,6 @@ function Highlight:IsPvPSideTabSelected(frameName, sideTabIndex) return false end - -- Fallback: check button selected state for any tab local pvpButtons = self:GetPvPSideTabButtons() if pvpButtons and pvpButtons[sideTabIndex] then local sideTab = pvpButtons[sideTabIndex] @@ -2088,13 +1861,11 @@ function Highlight:IsPvPSideTabSelected(frameName, sideTabIndex) end function Highlight:GetPvPSideTabButtons() - -- Find the PvP side tab buttons local buttons = {} if not PVPQueueFrame then return buttons end - -- Primary: CategoryButton1-4 (confirmed via DevFrame) - -- CategoryButton1 = Quick Match, 2 = Rated, 3 = Premade Groups, 4 = Training Grounds + -- CategoryButton1=Quick Match, 2=Rated, 3=Premade, 4=Training Grounds for i = 1, 4 do local btn = PVPQueueFrame["CategoryButton" .. i] if btn then @@ -2102,7 +1873,6 @@ function Highlight:GetPvPSideTabButtons() end end - -- Fallback: try legacy named buttons if CategoryButtons not found if not buttons[1] then local legacyNames = {"HonorButton", "ConquestButton", "LFGListButton", "TrainingGroundsButton"} for i, name in ipairs(legacyNames) do @@ -2123,7 +1893,6 @@ function Highlight:GetPvPSideTabButton(frameName, sideTabIndex) return btn end - -- Fallback: Try to find generic category buttons local fallbackNames = { "PVPQueueFrameCategoryButton1", "PVPQueueFrameCategoryButton2", @@ -2136,11 +1905,10 @@ function Highlight:GetPvPSideTabButton(frameName, sideTabIndex) return nil end --- ACHIEVEMENT/STATISTICS CATEGORY NAVIGATION HELPERS --- All three tabs (Achievements, Guild, Statistics) share AchievementFrameCategories.ScrollBox. --- Element data: { id = , selected = true/false, parent = ..., isChild = ..., ... } --- Category names are resolved via GetCategoryInfo(elementData.id). - +-- All three tabs (Achievements, Guild, Statistics) share +-- AchievementFrameCategories.ScrollBox. Element data shape: +-- { id, selected, parent, isChild, ... }. Names resolve via +-- GetCategoryInfo(elementData.id). local function CategoryDataMatches(data, categoryName, categoryID) if not data then return false end local catID = tonumber(data.id) @@ -2156,8 +1924,6 @@ local function CategoryDataMatches(data, categoryName, categoryID) return false end --- Shared helper: find element data in the ScrollBox data provider by category name/id. --- Returns (elementData, scrollBox) or (nil, nil). function Highlight:FindCategoryElementData(categoryName, categoryID) local categoriesFrame = _G["AchievementFrameCategories"] if not categoriesFrame or not categoriesFrame.ScrollBox then return nil, nil end @@ -2176,17 +1942,14 @@ function Highlight:FindCategoryElementData(categoryName, categoryID) return elementData, scrollBox end --- Shared helper: find a visible category button by name in the ScrollBox. --- Uses GetElementData().id → GetCategoryInfo() for reliable matching --- (AchievementCategoryTemplate stores text on btn.Button, not btn itself). --- Returns the button frame or nil. +-- AchievementCategoryTemplate stores text on btn.Button, so use +-- GetElementData().id → GetCategoryInfo() instead of GetText() on btn. function Highlight:FindVisibleCategoryButton(categoryName, categoryID) local categoriesFrame = _G["AchievementFrameCategories"] if not categoriesFrame or not categoriesFrame.ScrollBox then return nil end local scrollBox = categoriesFrame.ScrollBox - -- Primary: FindFrameByPredicate (cleanest ScrollBox API) if scrollBox.FindFrameByPredicate then local frame = scrollBox:FindFrameByPredicate(function(frame, elementData) local data = elementData @@ -2197,7 +1960,6 @@ function Highlight:FindVisibleCategoryButton(categoryName, categoryID) if frame then return frame end end - -- Fallback: EnumerateFrames with GetElementData if scrollBox.EnumerateFrames then for _, btn in scrollBox:EnumerateFrames() do if btn and btn:IsShown() and btn.GetElementData then @@ -2212,16 +1974,12 @@ function Highlight:FindVisibleCategoryButton(categoryName, categoryID) return nil end --- Shared helper: check if a category is currently selected. --- Uses elementData.selected (set by Blizzard's selection system). function Highlight:IsCategorySelectedByData(categoryName, categoryID) - -- Primary: check the data provider for elementData.selected local elementData = self:FindCategoryElementData(categoryName, categoryID) if elementData and elementData.selected then return true end - -- Fallback: find visible button and check its elementData directly local btn = self:FindVisibleCategoryButton(categoryName, categoryID) if btn and btn.GetElementData then local btnData = btn:GetElementData() @@ -2231,19 +1989,13 @@ function Highlight:IsCategorySelectedByData(categoryName, categoryID) return false end --- Shared helper: check if a category is expanded (its children visible) OR selected. --- Used for prerequisite validation - a parent category doesn't need to be "selected" --- once its child is selected; it only needs to still be expanded. +-- A parent doesn't need to stay selected once a child is - just expanded. function Highlight:IsCategoryExpandedOrSelected(categoryName, categoryID) local elementData = self:FindCategoryElementData(categoryName, categoryID) if not elementData then return false end - -- If it's directly selected, obviously satisfied if elementData.selected then return true end - -- If it's a parent (has children) and is expanded (not collapsed), it's satisfied - -- This covers the case where a child category is selected - the parent is no longer - -- "selected" but it IS expanded, meaning the prerequisite is still met. if elementData.parent == true and not elementData.collapsed then return true end @@ -2251,18 +2003,14 @@ function Highlight:IsCategoryExpandedOrSelected(categoryName, categoryID) return false end --- Shared helper: scroll to a category and return the button frame. --- Expands parent categories if needed via AchievementFrameCategories_ExpandToCategory. function Highlight:ScrollToCategoryButton(categoryName, categoryID) local elementData, scrollBox = self:FindCategoryElementData(categoryName, categoryID) if not elementData or not scrollBox then return nil end - -- If the category is hidden (parent collapsed), try to expand to it if elementData.hidden then local catID = tonumber(elementData.id) if catID and AchievementFrameCategories_ExpandToCategory then AchievementFrameCategories_ExpandToCategory(catID) - -- Data provider may have changed, re-find if AchievementFrameCategories_UpdateDataProvider then AchievementFrameCategories_UpdateDataProvider() end @@ -2271,18 +2019,14 @@ function Highlight:ScrollToCategoryButton(categoryName, categoryID) end end - -- Scroll to center the category in view local alignCenter = ScrollBoxConstants and ScrollBoxConstants.AlignCenter if scrollBox.ScrollToElementData then pcall(scrollBox.ScrollToElementData, scrollBox, elementData, alignCenter) end - -- Now find the visible button after scrolling return self:FindVisibleCategoryButton(categoryName, categoryID) end --- STATISTICS CATEGORY - function Highlight:IsStatisticsCategorySelected(categoryName, categoryID) if not AchievementFrame or not AchievementFrame:IsShown() then return false @@ -2291,12 +2035,10 @@ function Highlight:IsStatisticsCategorySelected(categoryName, categoryID) return false end - -- Primary: check elementData.selected via data provider if self:IsCategorySelectedByData(categoryName, categoryID) then return true end - -- For parent categories, check if child categories are now visible (parent expanded) if currentGuide and currentStepIndex then local nextStep = currentGuide.steps[currentStepIndex + 1] if nextStep and nextStep.statisticsCategory then @@ -2318,27 +2060,21 @@ function Highlight:GetStatisticsCategoryButton(categoryName, categoryID) return nil end - -- First: check currently visible buttons (fast, no scrolling) local visibleBtn = self:FindVisibleCategoryButton(categoryName, categoryID) if visibleBtn then return visibleBtn end - -- Not visible: scroll to it via data provider return self:ScrollToCategoryButton(categoryName, categoryID) end --- ACHIEVEMENT/GUILD CATEGORY - function Highlight:IsAchievementCategorySelected(categoryName, categoryID) if not AchievementFrame or not AchievementFrame:IsShown() then return false end - -- Primary: check elementData.selected via data provider if self:IsCategorySelectedByData(categoryName, categoryID) then return true end - -- For parent categories, check if child categories are now visible (parent expanded) if currentGuide and currentStepIndex then local nextStep = currentGuide.steps[currentStepIndex + 1] if nextStep and nextStep.achievementCategory then @@ -2357,40 +2093,30 @@ function Highlight:GetAchievementCategoryButton(categoryName, categoryID, noScro return nil end - -- First: check currently visible buttons (fast, no scrolling) local visibleBtn = self:FindVisibleCategoryButton(categoryName, categoryID) if visibleBtn then return visibleBtn end - -- Not visible: scroll to it (unless noScroll requested by selection checks) if noScroll then return nil end return self:ScrollToCategoryButton(categoryName, categoryID) end --- Character Frame sidebar tab helpers (Character Stats, Titles, Equipment Manager) function Highlight:IsSidebarTabSelected(sidebarIndex) if not CharacterFrame or not CharacterFrame:IsShown() then return false end - -- Check if the correct sidebar pane is shown if sidebarIndex == 1 then - -- Character Stats - check if CharacterStatsPane or the default paperdoll stats view is shown if CharacterStatsPane and CharacterStatsPane:IsShown() then return true end - -- Default view when Character tab is open and no other sidebar is active elseif sidebarIndex == 2 then - -- Titles pane if PaperDollTitlesPane and PaperDollTitlesPane:IsShown() then return true end elseif sidebarIndex == 3 then - -- Equipment Manager pane if PaperDollEquipmentManagerPane and PaperDollEquipmentManagerPane:IsShown() then return true end end - -- Also check the sidebar tab's visual selected state local sidebarTab = _G["PaperDollSidebarTab" .. sidebarIndex] if sidebarTab then if sidebarTab.isSelected then return true end if sidebarTab.selected then return true end - -- Check for checked/pressed state on buttons if sidebarTab.GetChecked and sidebarTab:GetChecked() then return true end if sidebarTab.IsChecked and sidebarTab:IsChecked() then return true end end @@ -2403,13 +2129,11 @@ function Highlight:GetSidebarTabButton(sidebarIndex) return nil end - -- Try PaperDollSidebarTab buttons directly (confirmed via Frame Inspector) local sidebarTab = _G["PaperDollSidebarTab" .. sidebarIndex] if sidebarTab and sidebarTab:IsShown() then return sidebarTab end - -- Try PaperDollSidebarTabs container children local sidebarTabs = _G["PaperDollSidebarTabs"] if not sidebarTabs and PaperDollFrame then sidebarTabs = PaperDollFrame.SidebarTabs @@ -2425,20 +2149,17 @@ function Highlight:GetSidebarTabButton(sidebarIndex) end function Highlight:FindRatedPvPButton(buttonText) - -- Search the ACTIVE sub-panel for a button with matching text (Solo Shuffle, 2v2, Arenas, etc.) if not PVEFrame or not PVEFrame:IsShown() then return nil end local searchText = slower(buttonText) - -- Determine the active sub-panel and search only within it. - -- This prevents finding buttons on inactive sub-tabs (e.g. finding - -- "Random Battlegrounds" on Quick Match when looking for "Arena Skirmishes" - -- on Premade Groups). + -- Search only the active sub-panel: otherwise we'd find "Random + -- Battlegrounds" on Quick Match when looking for an Arena Skirmishes + -- button on Premade Groups. local searchRoots = {} - -- PvP sub-panels if HonorFrame and HonorFrame:IsShown() then searchRoots[#searchRoots + 1] = HonorFrame end @@ -2452,7 +2173,6 @@ function Highlight:FindRatedPvPButton(buttonText) searchRoots[#searchRoots + 1] = TrainingGroundsFrame end - -- PvE sub-panels if LFDParentFrame and LFDParentFrame:IsShown() then searchRoots[#searchRoots + 1] = LFDParentFrame end @@ -2463,35 +2183,29 @@ function Highlight:FindRatedPvPButton(buttonText) searchRoots[#searchRoots + 1] = LFGListPVEStub end - -- LFGListFrame is shared between PvE and PvP premade groups + -- LFGListFrame is shared between PvE and PvP premade groups. if LFGListFrame and LFGListFrame:IsShown() then searchRoots[#searchRoots + 1] = LFGListFrame end - -- Search each active sub-panel for _, root in ipairs(searchRoots) do local result = SearchFrameTreeFuzzy(root, searchText) if result then return result end end - -- Fallback: search entire PVEFrame (for edge cases like Mythic+ or unknown layouts) return SearchFrameTreeFuzzy(PVEFrame, searchText) end function Highlight:GetTabButton(frameName, tabIndex) - -- PlayerSpellsFrame tabs if frameName == "PlayerSpellsFrame" then local frame = PlayerSpellsFrame if frame and frame.TabSystem and frame.TabSystem.tabs then return frame.TabSystem.tabs[tabIndex] end - -- Fallback to global tab buttons local tabBtn = _G["PlayerSpellsFrameTab" .. tabIndex] if tabBtn then return tabBtn end - -- Try the tab system directly if frame then - -- Modern tab system might use TabSystem if frame.TabSystem then local count = 0 for i = 1, select("#", frame.TabSystem:GetChildren()) do @@ -2505,27 +2219,22 @@ function Highlight:GetTabButton(frameName, tabIndex) end end - -- CollectionsJournal tabs if frameName == "CollectionsJournal" then return _G["CollectionsJournalTab" .. tabIndex] end - -- CharacterFrame tabs (Character, Reputation, Currency) if frameName == "CharacterFrame" then return _G["CharacterFrameTab" .. tabIndex] end - -- PVEFrame tabs (at bottom: Dungeons & Raids, Player vs. Player, Mythic+) if frameName == "PVEFrame" then return _G["PVEFrameTab" .. tabIndex] end - -- AchievementFrame tabs (Achievements, Guild, Statistics) if frameName == "AchievementFrame" then return _G["AchievementFrameTab" .. tabIndex] end - -- WardrobeCollectionFrame tabs (Items / Sets) if frameName == "WardrobeCollectionFrame" then local tabBtn = _G["WardrobeCollectionFrameTab" .. tabIndex] if tabBtn then return tabBtn end @@ -2538,8 +2247,8 @@ function Highlight:GetTabButton(frameName, tabIndex) end if tabIndex == 2 and frame.SetsTab then return frame.SetsTab end if tabIndex == 1 and frame.ItemsTab then return frame.ItemsTab end - -- Scan button-type children only; content frames like - -- SetsCollectionFrame also match "Sets" but aren't tabs + -- Only button-type children: SetsCollectionFrame also matches + -- "Sets" but it's a content frame, not a tab. local tabKeywords = { {"Items", "Item", "Tab1"}, {"Sets", "Set", "Tab2"} } local keywords = tabKeywords[tabIndex] if keywords then @@ -2558,26 +2267,21 @@ function Highlight:GetTabButton(frameName, tabIndex) end end - -- EncounterJournal tabs (Adventure Guide) if frameName == "EncounterJournal" then local frame = EncounterJournal if frame then - -- Modern Adventure Guide tabs (7 total in modern WoW): - -- Tab 1 = Journeys, Tab 2 = Traveler's Log, Tab 3 = Suggested Content - -- Tab 4 = Dungeons, Tab 5 = Raids, Tab 6 = Item Sets (Loot), Tab 7 = Tutorials - - -- Tab keywords to search for in button names + -- 1=Journeys 2=TravelersLog 3=Suggested 4=Dungeons 5=Raids + -- 6=ItemSets/Loot 7=Tutorials local tabKeywords = { - {"Journey", "Journeys"}, -- Tab 1 - {"Traveler", "Travel", "Log"}, -- Tab 2 - {"Suggest"}, -- Tab 3 - {"Dungeon"}, -- Tab 4 - {"Raid"}, -- Tab 5 - {"Loot", "ItemSet", "Set"}, -- Tab 6 - {"Tutorial", "HelpFrame"}, -- Tab 7 + {"Journey", "Journeys"}, + {"Traveler", "Travel", "Log"}, + {"Suggest"}, + {"Dungeon"}, + {"Raid"}, + {"Loot", "ItemSet", "Set"}, + {"Tutorial", "HelpFrame"}, } - -- Helper function to search a container for tab buttons local function findTabInContainer(container, keywords) if not container then return nil end for i = 1, select("#", container:GetChildren()) do @@ -2588,7 +2292,6 @@ function Highlight:GetTabButton(frameName, tabIndex) return child end end - -- Also check child text if it's a button if child.GetText then local text = child:GetText() or "" for _, keyword in ipairs(keywords) do @@ -2603,7 +2306,6 @@ function Highlight:GetTabButton(frameName, tabIndex) local keywords = tabKeywords[tabIndex] if keywords then - -- Try direct frame properties first local directNames = { {"journeysTab", "JourneysTab"}, {"travelersLogTab", "TravelersLogTab"}, @@ -2618,36 +2320,29 @@ function Highlight:GetTabButton(frameName, tabIndex) if btn then return btn end end - -- Search in TopNavBar (where modern tabs often live) local btn = findTabInContainer(frame.TopNavBar, keywords) if btn then return btn end - -- Search in TabBar btn = findTabInContainer(frame.TabBar, keywords) if btn then return btn end - -- Search in TabSystem btn = findTabInContainer(frame.TabSystem, keywords) if btn then return btn end - -- Search frame's direct children btn = findTabInContainer(frame, keywords) if btn then return btn end end - -- Try TabSystem.tabs array by index if frame.TabSystem and frame.TabSystem.tabs then return frame.TabSystem.tabs[tabIndex] end - -- Try iterating children of various containers by index local containers = {frame.TopNavBar, frame.TabBar, frame.TabSystem, frame} for _, container in ipairs(containers) do if container then local count = 0 for i = 1, select("#", container:GetChildren()) do local child = select(i, container:GetChildren()) - -- Only count button-like children if child.GetText or child.Click then count = count + 1 if count == tabIndex then @@ -2658,7 +2353,6 @@ function Highlight:GetTabButton(frameName, tabIndex) end end - -- Last resort: try global names local tab = _G["EncounterJournalTab" .. tabIndex] if tab then return tab end @@ -2671,19 +2365,17 @@ function Highlight:GetTabButton(frameName, tabIndex) end function Highlight:GetSideTabButton(frameName, sideTabIndex) - -- PVEFrame side buttons (Dungeon Finder, Raid Finder, Premade Groups) if frameName == "PVEFrame" then local sideButtons = { - GroupFinderFrame and GroupFinderFrame.DungeonFinderButton, -- 1 - GroupFinderFrame and GroupFinderFrame.RaidFinderButton, -- 2 - GroupFinderFrame and GroupFinderFrame.LFGListButton, -- 3 + GroupFinderFrame and GroupFinderFrame.DungeonFinderButton, + GroupFinderFrame and GroupFinderFrame.RaidFinderButton, + GroupFinderFrame and GroupFinderFrame.LFGListButton, } local btn = sideButtons[sideTabIndex] if btn and btn:IsShown() then return btn end - -- Fallback: look for buttons by name local buttonNames = { "GroupFinderFrameGroupButton1", "GroupFinderFrameGroupButton2", @@ -2701,11 +2393,6 @@ function Highlight:HighlightFrame(frame, instructionText, validator) return end - -- Track this target so the visibility watcher can hide the highlight - -- when the user closes the panel / switches tabs / changes pages and - -- the target frame stops being visible. The OnUpdate poller in - -- CreateHighlightFrame handles cascade-hides AND identity changes - -- (a ScrollBox button repurposed for a different spell after paging). highlightFrame._targetFrame = frame highlightFrame._targetValidator = validator highlightFrame._hoverDismissFrame = frame @@ -2717,7 +2404,6 @@ function Highlight:HighlightFrame(frame, instructionText, validator) highlightFrame:ClearAllPoints() highlightFrame:SetAllPoints(frame) - -- Top and bottom own the corners (full width including padding) highlightFrame.top:ClearAllPoints() highlightFrame.top:SetPoint("BOTTOMLEFT", frame, "TOPLEFT", -pad, 0) highlightFrame.top:SetPoint("BOTTOMRIGHT", frame, "TOPRIGHT", pad, 0) @@ -2728,7 +2414,6 @@ function Highlight:HighlightFrame(frame, instructionText, validator) highlightFrame.bottom:SetPoint("TOPRIGHT", frame, "BOTTOMRIGHT", pad, 0) highlightFrame.bottom:SetHeight(bs) - -- Left and right fit between top and bottom (no corner overlap) highlightFrame.left:ClearAllPoints() highlightFrame.left:SetPoint("TOPLEFT", highlightFrame.top, "BOTTOMLEFT", 0, 0) highlightFrame.left:SetPoint("BOTTOMLEFT", highlightFrame.bottom, "TOPLEFT", 0, 0) @@ -2764,13 +2449,11 @@ end function Highlight:ShowInstruction(text) instructionFrame.text:SetText(text) - -- Calculate proper width and height with word wrap local maxWidth = 450 instructionFrame:SetWidth(maxWidth) - -- Get actual text height after word wrap local textHeight = instructionFrame.text:GetStringHeight() - local frameHeight = mmax(90, textHeight + 60) -- 60px for padding and button + local frameHeight = mmax(90, textHeight + 60) instructionFrame:SetHeight(frameHeight) instructionFrame:ClearAllPoints() @@ -2778,21 +2461,12 @@ function Highlight:ShowInstruction(text) instructionFrame:Show() end --- SearchIcon registration: caller has already shown button.SearchIcon --- (Blizzard's native spyglass on talent / spellbook nodes). This hooks --- the button into highlightFrame's visibility watcher so the spyglass --- clears on cascade-hide / repurpose / hover-dismiss without us drawing --- our own yellow border on top. --- --- button - the talent / spell button whose SearchIcon was shown. --- validator - (frame) -> bool. Returns true while the frame still --- represents the original target; false on identity --- change (e.g. a ScrollBox row repurposed for a --- different spell). Optional: pass nil for "always --- valid until the button hides". +-- Hooks Blizzard's native SearchIcon spyglass into the visibility +-- watcher so it clears on cascade-hide / repurpose / hover-dismiss +-- without drawing our own yellow border on top. validator may be nil +-- for "always valid until the button hides". function Highlight:RegisterSearchIconWatch(button, validator) if not button then return end - -- Clear any previous registration so only one spyglass is live. if highlightFrame._talentSearchBtn and highlightFrame._talentSearchBtn ~= button and highlightFrame._talentSearchBtn.SearchIcon then @@ -2803,9 +2477,8 @@ function Highlight:RegisterSearchIconWatch(button, validator) highlightFrame._targetValidator = validator highlightFrame._hoverDismissFrame = button highlightFrame._clearWhenTargetHidden = true - -- Hide the yellow border textures so the watcher's OnUpdate ticks - -- without the standard highlight visuals showing on top of the - -- native spyglass. + -- Hide our yellow border so the watcher ticks without drawing on + -- top of the native spyglass. if highlightFrame.top then highlightFrame.top:Hide() end if highlightFrame.bottom then highlightFrame.bottom:Hide() end if highlightFrame.left then highlightFrame.left:Hide() end @@ -2817,19 +2490,14 @@ function Highlight:RegisterSearchIconWatch(button, validator) if highlightFrame.animGroup then highlightFrame.animGroup:Stop() end end --- Backwards-compatible alias used by the talent-name search path. function Highlight:RegisterTalentSearchIcon(button, targetNameLower, nameOf) self:RegisterSearchIconWatch(button, function(f) return nameOf and nameOf(f) == targetNameLower end) end --- Spellbook glow: matches the in-game spellbook-item-unassigned-glow --- visual without hijacking the real glow texture (which signals --- "spell not on an action bar"). We attach our own texture child to --- the button using the same atlas and run a pulse animation, then --- register with the watcher so it clears on cascade-hide / repurpose --- / hover-dismiss like the talent spyglass. +-- Matches the spellbook-item-unassigned-glow visual via our own texture +-- (not the real glow, which signals "spell not on an action bar"). function Highlight:HighlightSpellbookSpell(row, validator) if not row or not row:IsShown() then self:HideHighlight() @@ -2837,9 +2505,8 @@ function Highlight:HighlightSpellbookSpell(row, validator) end self:HideHighlight() - -- The visible spellbook entry has an inner .Button child holding just - -- the spell icon (~40x39); the parent row container also includes - -- the spell name label and other artwork. Glow only the icon. + -- Glow only the icon: row.Button (~40x39) holds the icon; the row + -- itself also contains label and artwork we don't want lit up. local iconBtn = (row.Button and row.Button.IsShown and row.Button:IsShown() and row.Button) or row local glow = iconBtn._efSearchGlow @@ -2904,7 +2571,6 @@ function Highlight:HideHighlight() highlightFrame._clearWhenTargetHidden = nil highlightFrame:Hide() if highlightFrame.animGroup then highlightFrame.animGroup:Stop() end - -- Restore yellow border textures hidden by HighlightTalentSearch. if highlightFrame.top then highlightFrame.top:Show() end if highlightFrame.bottom then highlightFrame.bottom:Show() end if highlightFrame.left then highlightFrame.left:Show() end @@ -2940,9 +2606,7 @@ function Highlight:ClearAll() self:NotifyClearButton() end --- Portrait menu helpers function Highlight:IsPortraitMenuOpen() - -- Method 1: Check old-style DropDownList frames for i = 1, 5 do local dropdown = _G["DropDownList" .. i] if dropdown and dropdown:IsShown() then @@ -2950,8 +2614,6 @@ function Highlight:IsPortraitMenuOpen() end end - -- Method 2: Check modern Menu system (11.0+) - -- The Menu API creates frames managed by MenuManager if Menu and Menu.GetManager then local ok, manager = pcall(Menu.GetManager) if ok and manager then @@ -2962,12 +2624,10 @@ function Highlight:IsPortraitMenuOpen() end end - -- Method 3: Check if UIDROPDOWNMENU is open if UIDROPDOWNMENU_OPEN_MENU and UIDROPDOWNMENU_OPEN_MENU ~= "" then return true end - -- Method 4: Check for any visible context menu frame by common naming patterns local menuNames = {"PlayerFrameDropDown", "DropDownList1", "UnitPopupWindow"} for _, name in ipairs(menuNames) do local frame = _G[name] @@ -2982,9 +2642,7 @@ function Highlight:IsPortraitMenuOpen() return false end --- Find the visible portrait/context menu frame function Highlight:GetPortraitMenuFrame() - -- Check old-style DropDownList frames for i = 1, 5 do local dropdown = _G["DropDownList" .. i] if dropdown and dropdown:IsShown() then @@ -2992,7 +2650,6 @@ function Highlight:GetPortraitMenuFrame() end end - -- Check modern Menu system if Menu and Menu.GetManager then local ok, manager = pcall(Menu.GetManager) if ok and manager then @@ -3003,7 +2660,6 @@ function Highlight:GetPortraitMenuFrame() end end - -- Fallback: check common frame names local menuNames = {"UnitPopupWindow"} for _, name in ipairs(menuNames) do local frame = _G[name] @@ -3018,10 +2674,8 @@ function Highlight:GetPortraitMenuFrame() return nil end --- Find a button in GameMenuFrame by its label text. The buttons in --- modern GameMenuFrame are unnamed (dynamic hex IDs change each --- session) so name-based lookup doesn't work; iterate visible --- children and match GetText(). +-- Modern GameMenuFrame buttons have dynamic hex names that change each +-- session; iterate visible children and match GetText(). function Highlight:FindGameMenuButton(label) if not GameMenuFrame or not label then return nil end local target = slower(label) @@ -3075,7 +2729,6 @@ function Highlight:FindPortraitMenuOption(optionName) if shownOk and shown then local text = getFrameText(child) if text and sfind(slower(text), optionNameLower, 1, true) then - -- Found matching option - return it if clickable return child end @@ -3086,7 +2739,6 @@ function Highlight:FindPortraitMenuOption(optionName) return nil end - -- Search DropDownList frames (legacy) for i = 1, 5 do local dropdown = _G["DropDownList" .. i] if dropdown and dropdown:IsShown() then @@ -3095,7 +2747,6 @@ function Highlight:FindPortraitMenuOption(optionName) end end - -- Search modern Menu system (11.0+) if Menu and Menu.GetManager then local ok, manager = pcall(Menu.GetManager) if ok and manager then @@ -3107,7 +2758,6 @@ function Highlight:FindPortraitMenuOption(optionName) end end - -- Fallback: search common menu frame names local menuNames = {"UnitPopupWindow"} for _, name in ipairs(menuNames) do local frame = _G[name] @@ -3123,9 +2773,7 @@ function Highlight:FindPortraitMenuOption(optionName) return nil end --- Currency navigation helpers - --- Returns: true (expanded), false (collapsed), nil (not in list - parent collapsed) +-- Returns true (expanded), false (collapsed), nil (parent collapsed). function Highlight:IsCurrencyHeaderExpanded(headerName) if not C_CurrencyInfo or not C_CurrencyInfo.GetCurrencyListSize then return nil end @@ -3138,10 +2786,9 @@ function Highlight:IsCurrencyHeaderExpanded(headerName) return info.isHeaderExpanded end end - return nil -- header not visible, parent must be collapsed + return nil end --- Find the visible UI button for a currency header in the TokenFrame ScrollBox function Highlight:GetCurrencyHeaderButton(headerName) if not TokenFrame or not TokenFrame:IsShown() then return nil end @@ -3175,7 +2822,6 @@ function Highlight:GetCurrencyHeaderButton(headerName) return nil end --- Scroll the TokenFrame ScrollBox to show a specific currency by ID function Highlight:ScrollToCurrencyRow(currencyID) if not TokenFrame or not TokenFrame:IsShown() then return end if not TokenFrame.ScrollBox then return end @@ -3201,7 +2847,6 @@ function Highlight:ScrollToCurrencyRow(currencyID) end, fraction) end --- Find the visible UI button/row for a specific currency by ID in the TokenFrame ScrollBox function Highlight:GetCurrencyRowButton(currencyID) if not TokenFrame or not TokenFrame:IsShown() then return nil end if not TokenFrame.ScrollBox then return nil end @@ -3224,9 +2869,7 @@ function Highlight:GetCurrencyRowButton(currencyID) end) end --- REPUTATION HELPERS - --- Returns: true (expanded), false (collapsed), nil (not found/parent collapsed) +-- Returns true (expanded), false (collapsed), nil (parent collapsed). function Highlight:IsFactionHeaderExpanded(headerName) if not C_Reputation or not C_Reputation.GetNumFactions then return nil end @@ -3236,20 +2879,17 @@ function Highlight:IsFactionHeaderExpanded(headerName) for i = 1, numFactions do local factionData = C_Reputation.GetFactionDataByIndex(i) if factionData and factionData.isHeader and factionData.name and slower(factionData.name) == headerNameLower then - -- Check both new and old property names for compatibility if factionData.isHeaderExpanded ~= nil then return factionData.isHeaderExpanded elseif factionData.isCollapsed ~= nil then - return not factionData.isCollapsed -- isCollapsed is inverse of isHeaderExpanded + return not factionData.isCollapsed end - -- Fallback: assume expanded if we can see it return true end end - return nil -- header not visible, parent must be collapsed + return nil end --- Find the visible UI button for a faction header in the ReputationFrame ScrollBox function Highlight:GetFactionHeaderButton(headerName) if not ReputationFrame or not ReputationFrame:IsShown() then return nil end @@ -3283,7 +2923,6 @@ function Highlight:GetFactionHeaderButton(headerName) return nil end --- Scroll the ReputationFrame ScrollBox to show a specific faction by ID function Highlight:ScrollToFactionRow(factionID) if not ReputationFrame or not ReputationFrame:IsShown() then return end if not ReputationFrame.ScrollBox then return end @@ -3309,7 +2948,6 @@ function Highlight:ScrollToFactionRow(factionID) end, fraction) end --- Find the visible UI button/row for a specific faction by ID in the ReputationFrame ScrollBox function Highlight:GetFactionRowButton(factionID) if not ReputationFrame or not ReputationFrame:IsShown() then return nil end if not ReputationFrame.ScrollBox then return nil end diff --git a/MapSearch.lua b/MapSearch.lua index 3a949a4..dd0097e 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -28,7 +28,6 @@ local IsMouseButtonDown = IsMouseButtonDown local hooksecurefunc = hooksecurefunc local wipe = wipe --- Cache C_* API functions at file scope to get unhooked copies local GetMapInfo = C_Map.GetMapInfo local GetMapChildrenInfo = C_Map.GetMapChildrenInfo local GetBestMapForUnit = C_Map.GetBestMapForUnit @@ -50,8 +49,7 @@ local GetPlayerMapPosition = C_Map.GetPlayerMapPosition local sgsub = string.gsub --- Normalize a name for instance matching: lowercase, hyphens to spaces, collapse whitespace. --- Handles cases like "Nexus-Point Xenas" vs "Nexus Point Xenas". +-- Handles "Nexus-Point Xenas" vs "Nexus Point Xenas" for instance matching. local function normalizeName(name) local n = slower(name) n = sgsub(n, "%-", " ") @@ -114,8 +112,6 @@ local function EnrichZoneWithEntrance(poi, entrance) poi.icon = entrance.icon end --- Build a name → entrance lookup from an array of POI entries. --- Reuses reuseEntranceLookup to avoid allocation. local reuseEntranceLookup = {} local function BuildEntranceLookup(entries) wipe(reuseEntranceLookup) @@ -129,17 +125,16 @@ end local STAR_GLOW_TEXTURE = "Interface\\Cooldown\\star4" --- INDICATOR THEME DEFINITIONS local INDICATOR_STYLES = { ["Classic Quest Arrow"] = { texture = "Interface\\MINIMAP\\MiniMap-QuestArrow", texCoord = nil, - preRotated = false, -- Needs mpi rotation to point down + preRotated = false, }, ["EasyFind Arrow"] = { texture = "Interface\\AddOns\\EasyFind\\Images\\arrow-hq", texCoord = nil, - preRotated = true, -- Already points down, no rotation needed + preRotated = true, }, ["Minimap Player Arrow"] = { texture = "Interface\\Minimap\\MinimapArrow", @@ -151,11 +146,11 @@ local INDICATOR_STYLES = { texCoord = nil, preRotated = true, rotation = 2.356, - offsetX = 0, -- Shift right to center fingertip - offsetY = 0, -- Shift down to center fingertip + offsetX = 0, + offsetY = 0, }, ["HD Gauntlet"] = { - texture = 6116532, -- interface/cursor/crosshair/uicastcrosshair2x.blp + texture = 6116532, texCoord = {0.000, 0.240, 0.000, 0.420}, preRotated = true, rotation = 2.356, @@ -163,7 +158,6 @@ local INDICATOR_STYLES = { offsetY = 0, }, } --- Indicator color presets local INDICATOR_COLORS = { ["Yellow"] = {1.0, 1.0, 0.0}, ["Gold"] = {1.0, 0.82, 0.0}, @@ -180,7 +174,6 @@ local function GetIndicatorColor() return INDICATOR_COLORS[colorName] or INDICATOR_COLORS["Yellow"] end --- Store in namespace so all modules can access it ns.GetIndicatorTexture = function() local style = EasyFind.db.indicatorStyle or "EasyFind Arrow" return INDICATOR_STYLES[style] or INDICATOR_STYLES["EasyFind Arrow"] @@ -191,35 +184,24 @@ ns.INDICATOR_COLORS = INDICATOR_COLORS local GetIndicatorTexture = ns.GetIndicatorTexture --- UNIFIED SIZING - all values are in UI coordinate units (same as UIParent). --- Map code converts to canvas units via ns.UIToCanvas() so visual size matches. --- Changing a value here changes BOTH map and UI icons uniformly. - --- Single-pin group (the indicator icon + pin + highlight are always sized together) -ns.ICON_SIZE = 48 -- Indicator icon (arrow/pointer/cursor) -ns.ICON_GLOW_SIZE = 68 -- Glow behind indicator icon -ns.PIN_SIZE = 28 -- Map pin icon (category icon, e.g. auction house) -ns.PIN_GLOW_SIZE = 40 -- Map pin glow -ns.HIGHLIGHT_SIZE = 30 -- Yellow highlight border box +-- All sizes in UI coordinate units; UIToCanvas converts so visual size matches. +ns.ICON_SIZE = 48 +ns.ICON_GLOW_SIZE = 68 +ns.PIN_SIZE = 28 +ns.PIN_GLOW_SIZE = 40 +ns.HIGHLIGHT_SIZE = 30 --- Multi-pin (slightly smaller so clusters don't overlap) ns.MULTI_SCALE = 1.0 --- Zone indicator (continent maps) ns.ZONE_ICON_SIZE = 48 ns.ZONE_ICON_GLOW_SIZE = 68 --- Breadcrumb indicator ns.BREADCRUMB_SIZE = 48 local ANIM_DURATION = 0.5 --- Convert a size in UI units to canvas units so it appears the same visual --- size as a same-valued element on UIParent. --- WoW's map zooms by making the canvas LARGER, not by changing scale. --- So the conversion is: canvasWidth / viewportWidth (canvas units per screen unit). --- @param uiSize number size in UI coordinate units --- @return number equivalent canvas coordinate units +-- WoW's map zooms by enlarging the canvas, not by changing scale, so the +-- conversion is canvasWidth / viewportWidth. function ns.UIToCanvas(uiSize) local sc = WorldMapFrame and WorldMapFrame.ScrollContainer if not sc or not sc.Child then return uiSize end @@ -231,15 +213,8 @@ function ns.UIToCanvas(uiSize) return uiSize * (canvasW / viewportW) end --- SHARED ICON CREATION / UPDATE --- Every indicator icon in the addon (map search, zone search, UI search, breadcrumb) --- MUST use these two functions so they all look identical. - --- Create icon + glow textures on a parent frame. --- Returns nothing; sets parentFrame.indicator and parentFrame.glow. --- @param parentFrame Frame - the frame the icon sits in --- @param iconSize number|nil - override size (defaults to ns.ICON_SIZE) --- @param glowSize number|nil - override glow (defaults to ns.ICON_GLOW_SIZE; 0 = no glow) +-- Every indicator icon (map search, zone search, UI search, breadcrumb) must +-- use these two functions so they all look identical. function ns.CreateIndicatorTextures(parentFrame, iconSize, glowSize) iconSize = iconSize or ns.ICON_SIZE glowSize = glowSize or ns.ICON_GLOW_SIZE @@ -247,7 +222,6 @@ function ns.CreateIndicatorTextures(parentFrame, iconSize, glowSize) local color = GetIndicatorColor() local ox, oy = style.offsetX or 0, style.offsetY or 0 - -- Icon texture local ind = parentFrame:CreateTexture(nil, "ARTWORK") ind:SetSize(iconSize, iconSize) ind:SetPoint("CENTER", parentFrame, "CENTER", ox, oy) @@ -265,7 +239,6 @@ function ns.CreateIndicatorTextures(parentFrame, iconSize, glowSize) ind:SetRotation(indicatorRotation) parentFrame.indicator = ind - -- Glow texture (optional) if glowSize and glowSize > 0 then local glow = parentFrame:CreateTexture(nil, "BACKGROUND") glow:SetSize(glowSize, glowSize) @@ -276,15 +249,11 @@ function ns.CreateIndicatorTextures(parentFrame, iconSize, glowSize) parentFrame.glow = glow end - -- Auto-update on every Show so indicators are ALWAYS in sync with settings. parentFrame:HookScript("OnShow", function(self) ns.UpdateIndicator(self) end) end --- Update an existing indicator (and optional glow) to match current settings. --- Works on any frame that was set up with ns.CreateIndicatorTextures. --- @param parentFrame Frame function ns.UpdateIndicator(parentFrame) if not parentFrame or not parentFrame.indicator then return end local style = GetIndicatorTexture() @@ -298,7 +267,6 @@ function ns.UpdateIndicator(parentFrame) else tex:SetTexCoord(0, 1, 0, 1) end - -- Use directional override if set, otherwise use style default local indicatorRotation if parentFrame.indicatorDirection then indicatorRotation = ns.GetDirectionalRotation(parentFrame.indicatorDirection) @@ -314,8 +282,7 @@ function ns.UpdateIndicator(parentFrame) tex:ClearAllPoints() tex:SetPoint("CENTER", parentFrame, "CENTER", ox, oy) - -- Sync texture size to frame size (frame gets resized at show time; - -- the texture must match or it stays at its creation-time size). + -- Frames get resized at show time; the texture must match. local fw, fh = parentFrame:GetSize() if fw and fw > 0 then tex:SetSize(fw, fh) @@ -325,30 +292,24 @@ function ns.UpdateIndicator(parentFrame) parentFrame.glow:SetVertexColor(color[1], color[2], color[3], 0.35) end - -- Apply user icon scale to UI indicators (map indicators handle scale in their own sizing code) + -- Map indicators handle scale in their own sizing code. if parentFrame.isUIIndicator then parentFrame:SetScale(EasyFind.db.iconScale or 0.8) end end --- Compute the rotation for an indicator pointing in a given direction. --- Takes the style's own rotation into account so every style works correctly. --- @param direction string "down"|"up"|"left"|"right" --- @return number rotation in radians function ns.GetDirectionalRotation(direction) local style = GetIndicatorTexture() - -- Base rotation is whatever points the indicator downward: - -- preRotated indicators already point down at rotation=0 - -- non-preRotated indicators point down at rotation=mpi + -- baseDown is the rotation that points the indicator downward. local baseDown = style.rotation or (style.preRotated and 0 or mpi) if direction == "down" then return baseDown elseif direction == "up" then - return baseDown + mpi -- flip 180° + return baseDown + mpi elseif direction == "right" then - return baseDown - mpi / 2 -- 90° CW + return baseDown - mpi / 2 elseif direction == "left" then - return baseDown + mpi / 2 -- 90° CCW + return baseDown + mpi / 2 end return baseDown end @@ -356,26 +317,23 @@ end local highlightFrame local indicatorFrame local waypointPin -local zoneHighlightFrame -- For highlighting zones on continent maps +local zoneHighlightFrame local isGlobalSearch = false --- Set by SelectResult when invoked in Guide mode (directOverride=false) --- so the final waypoint placed at the end of the breadcrumb teaches --- the user how to dismiss it (hover = clear), same as a global-style --- result. Reset on each SelectResult call. +-- Set in Guide mode so the breadcrumb's final waypoint dismisses on hover. local pinHoverClearsOverride = nil -local activePinState = nil -- {mapID, x, y, icon, category} - survives map close/reopen -local mapIsMaximized = false -- Tracks WorldMapFrame maximize state for search bar repositioning -local cachedWorldZones -- Built once per session by GetAllWorldZones +local activePinState = nil +local mapIsMaximized = false +local cachedWorldZones local worldZonePrefixIndex = {} local worldZonePrefixSeen = {} local worldZonePrefixReady = false local emptyWorldZones = {} -local rareTrackCache = {} -- [vignetteGUID] = rare entry, persists across scans for auto-track -local rareTrackMapID = nil -- mapID the cache is valid for -local rareDeadGUIDs = {} -- GUIDs confirmed dead/despawned, blocked from re-entering cache -MapSearch._rareTrackCache = rareTrackCache -- exposed for DevMem diagnostics -MapSearch._rareDeadGUIDs = rareDeadGUIDs -- exposed for DevMem diagnostics -local efTrackedVignetteGUID = nil -- GUID we explicitly set via SetSuperTrackedVignette (rares only) +local rareTrackCache = {} +local rareTrackMapID = nil +local rareDeadGUIDs = {} +MapSearch._rareTrackCache = rareTrackCache +MapSearch._rareDeadGUIDs = rareDeadGUIDs +local efTrackedVignetteGUID = nil local reuseAllPOIs = {} local reuseZoneNames = {} @@ -453,8 +411,6 @@ loadingScreenFrame:SetScript("OnEvent", function(_, event, isInitialLogin, isRel end) end) --- PIN HELPERS - local function GetMapPinKey(data) if data.isZone and data.zoneMapID then return "zone:" .. data.zoneMapID @@ -470,7 +426,6 @@ local function CleanForStorage(data) clean[k] = v end end - -- score and pin (frame ref) intentionally excluded return clean end @@ -486,9 +441,8 @@ local function PinMapItem(data) if IsMapItemPinned(data) then return end local clean = CleanForStorage(data) clean.isPinned = true - -- Default a freshly-pinned parent to collapsed. The toggle callback - -- writes back to this same SavedVariables entry, so any user-driven - -- expand/collapse state survives map close, /reload, and logout. + -- Default freshly-pinned parent to collapsed; user toggles write back + -- to this same entry so state survives map close, /reload, and logout. if data.isZone and data.zoneMapID and clean.collapsed == nil then clean.collapsed = true end @@ -506,25 +460,19 @@ local function UnpinMapItem(data) end end --- Expose pin helpers on the module so external renderers (MapTab) can --- toggle pins without duplicating the canonicalization logic. MapSearch.GetMapPinKey = function(_, data) return GetMapPinKey(data) end MapSearch.IsMapItemPinned = function(_, data) return IsMapItemPinned(data) end MapSearch.PinMapItem = function(_, data) return PinMapItem(data) end MapSearch.UnpinMapItem = function(_, data) return UnpinMapItem(data) end --- Category icons mapping local CATEGORY_ICONS = { flightmaster = "atlas:TaxiNode_Neutral", zeppelin = 342918, boat = 1126431, portal = "Interface\\Icons\\Spell_Arcane_PortalDalaran", tram = "Interface\\Icons\\INV_Misc_Gear_01", - -- Use the clean map-pin atlases instead of the journal sprite sheet. - -- The 1121272 sheet's icons are wrapped in a wide soft glow that - -- gets clipped to a square inside the row's icon slot; the pin - -- atlases are tight, no-bleed shapes that match WoW's own dungeon / - -- raid / delve world-map pins. + -- The 1121272 sheet's icons are wrapped in a wide soft glow that gets + -- clipped inside the row's icon slot; these atlases are tight, no-bleed. dungeon = "atlas:Dungeon", raid = "atlas:Raid", delve = { file = 1121272, coords = { 0.0000, 0.0620, 0.3903, 0.4509 } }, @@ -601,12 +549,7 @@ end ns.MapSearch = ns.MapSearch or MapSearch ns.MapSearch.GetCategoryIcon = GetCategoryIcon --- GetFilterBucket lives below the CATEGORIES table so it can reference --- it as an upvalue (Lua resolves at definition time, so declaring this --- function above CATEGORIES would silently treat the name as a global --- and every parent-based lookup would return nil). - --- Category definitions with hierarchy +-- GetFilterBucket must stay below CATEGORIES so its upvalue resolves. local CATEGORIES = { travel = { keywords = {"travel", "transport", "transportation", "getting around"} }, instance = { keywords = {"instance", "instances", "group content"} }, @@ -651,9 +594,7 @@ local CATEGORIES = { local function GetFilterBucket(data) if not data then return "other" end - -- Category takes priority over isZone so dungeons/raids/delves that - -- are map-type Dungeon (and therefore carry isZone=true alongside - -- their category) still bucket into "instances" instead of "zones". + -- Dungeons/raids/delves carry isZone=true; check category first. local cat = data.category if cat == "dungeon" or cat == "raid" or cat == "delve" then return "instances" end if data.isZone then return "zones" end @@ -664,9 +605,7 @@ local function GetFilterBucket(data) if parent == "instance" then return "instances" end if parent == "travel" then return "travel" end if parent == "service" or cat == "service" then return "services" end - -- StaticLocations carries narrower categories (classtrainer_*, prof_*, - -- guildbank, etc.) that aren't in the CATEGORIES table. Treat them as - -- services so the filter works on them too. + -- StaticLocations subcategories (classtrainer_*, prof_*, etc.) bucket here. if sfind(cat, "^classtrainer_") or sfind(cat, "^prof_") then return "services" end if cat == "guildbank" or cat == "guildservices" or cat == "trainingdummy" then return "services" end return "other" @@ -678,16 +617,12 @@ local function MapTabFlightPathsEnabled() return filters and filters.flightpath ~= false end --- Categories allowed in global (cross-zone) search results. --- Everything else (services, travel, etc.) is excluded to keep global results clean. local GLOBAL_SEARCH_CATEGORIES = { dungeon = true, raid = true, delve = true, } --- Static locations are loaded from StaticLocations.lua (generated by tools/ImportPOIs.ps1) --- To add POIs: record in-game with /devpoi, then run ImportPOIs.ps1 local STATIC_LOCATIONS = ns.STATIC_LOCATIONS or {} function MapSearch:Initialize() @@ -719,7 +654,6 @@ function MapSearch:CreateFilterDropdown(globalName, options, dbKey, toggleBtn, a insets = { left = 4, right = 4, top = 4, bottom = 4 }, }) - -- "Show:" header (gold text, matching WoW tracking menu) local header = dropdown:CreateFontString(nil, "ARTWORK", "GameFontHighlight") header:SetPoint("TOPLEFT", 12, -PADDING_TOP) header:SetText("Show:") @@ -736,7 +670,6 @@ function MapSearch:CreateFilterDropdown(globalName, options, dbKey, toggleBtn, a row:SetHitRectInsets(0, 0, 0, 0) row.optKey = opt.key - -- Rounded square checkbox (standard WoW style) row:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") row:GetNormalTexture():SetSize(CHECK_SIZE, CHECK_SIZE) row:GetNormalTexture():ClearAllPoints() @@ -747,24 +680,20 @@ function MapSearch:CreateFilterDropdown(globalName, options, dbKey, toggleBtn, a row:GetCheckedTexture():ClearAllPoints() row:GetCheckedTexture():SetPoint("LEFT", 4, 0) - -- Label local label = row:CreateFontString(nil, "ARTWORK", "GameFontHighlight") label:SetPoint("LEFT", row:GetNormalTexture(), "RIGHT", 4, 0) label:SetText(opt.label) - -- Highlight on hover local highlight = row:CreateTexture(nil, "HIGHLIGHT") highlight:SetAllPoints() highlight:SetColorTexture(1, 1, 1, 0.1) - -- Keyboard focus highlight (separate from mouse hover) local kbHighlight = row:CreateTexture(nil, "BACKGROUND") kbHighlight:SetAllPoints() kbHighlight:SetColorTexture(1, 1, 1, 0.1) kbHighlight:Hide() row.kbHighlight = kbHighlight - -- Start checked row:SetChecked(true) row:SetScript("OnClick", function(self) @@ -797,7 +726,6 @@ function MapSearch:CreateFilterDropdown(globalName, options, dbKey, toggleBtn, a local totalHeight = PADDING_TOP + HEADER_HEIGHT + #options * ROW_HEIGHT + PADDING_BOTTOM dropdown:SetSize(DROPDOWN_WIDTH, totalHeight) - -- Sync checkmarks to saved state on show dropdown:SetScript("OnShow", function(self) local filters = EasyFind.db[dbKey] for key, row in pairs(checkRows) do @@ -815,7 +743,6 @@ function MapSearch:CreateFilterDropdown(globalName, options, dbKey, toggleBtn, a end end) - -- Close when clicking outside dropdown:SetScript("OnUpdate", function(self) if self:IsShown() and IsMouseButtonDown("LeftButton") then if not self:IsMouseOver() and not toggleBtn:IsMouseOver() then @@ -840,14 +767,12 @@ function MapSearch:CreateFilterDropdown(globalName, options, dbKey, toggleBtn, a return dropdown end --- Resize highlight border textures in canvas units so they match the UI search --- highlight thickness regardless of map zoom. Uses the same 4px / 4px pad as --- Highlight.lua but converted through UIToCanvas. +-- Canvas-unit borders so thickness matches UI search regardless of zoom. local function ResizeHighlightBorders(frame) local bs = ns.UIToCanvas(4) local pad = ns.UIToCanvas(4) - -- Top and bottom own the corners (full width including padding) + -- Top/bottom own the corners (full width including padding). frame.top:ClearAllPoints() frame.top:SetHeight(bs) frame.top:SetPoint("BOTTOMLEFT", frame, "TOPLEFT", -pad, 0) @@ -858,7 +783,6 @@ local function ResizeHighlightBorders(frame) frame.bottom:SetPoint("TOPLEFT", frame, "BOTTOMLEFT", -pad, 0) frame.bottom:SetPoint("TOPRIGHT", frame, "BOTTOMRIGHT", pad, 0) - -- Left and right fit between top and bottom (no corner overlap) frame.left:ClearAllPoints() frame.left:SetWidth(bs) frame.left:SetPoint("TOPLEFT", frame.top, "BOTTOMLEFT", 0, 0) @@ -882,8 +806,6 @@ function MapSearch:CreateHighlightFrame() highlightFrame:SetSize(64, 64) highlightFrame:SetFrameStrata("TOOLTIP") highlightFrame:SetFrameLevel(2000) - -- Decoration only, never absorb clicks even if it ends up over a - -- clickable region in some map mode (maximized canvas, etc.). highlightFrame:EnableMouse(false) highlightFrame:Hide() @@ -903,11 +825,7 @@ function MapSearch:CreateHighlightFrame() right:SetColorTexture(YELLOW_HIGHLIGHT[1], YELLOW_HIGHLIGHT[2], YELLOW_HIGHLIGHT[3], 1) highlightFrame.right = right - -- Indicator pointing down to the location. Parented to the canvas - -- (sibling of waypointPin / highlightFrame) and anchored explicitly - -- on every show, never reparented. Reparenting between hovers had - -- inconsistent timing on some result rows where the indicator would - -- inherit a stale Hidden state and never repaint. + -- Anchored explicitly each show; reparenting caused stale Hidden state. indicatorFrame = CreateFrame("Frame", "EasyFindMapIndicator", WorldMapFrame.ScrollContainer.Child) indicatorFrame:SetFrameStrata("TOOLTIP") indicatorFrame:SetFrameLevel(2000) @@ -923,7 +841,6 @@ function MapSearch:CreateHighlightFrame() alpha:SetDuration(ANIM_DURATION) highlightFrame.animGroup = animGroup - -- Indicator bob + pulse animation (independent of parent highlight alpha) local indAnimGroup = indicatorFrame:CreateAnimationGroup() indAnimGroup:SetLooping("BOUNCE") local indMove = indAnimGroup:CreateAnimation("Translation") @@ -935,15 +852,12 @@ function MapSearch:CreateHighlightFrame() indAlpha:SetDuration(ANIM_DURATION) indicatorFrame.animGroup = indAnimGroup - -- Create static location pin - shows the icon for locations from database waypointPin = CreateFrame("Frame", "EasyFindLocationPin", WorldMapFrame.ScrollContainer.Child) - waypointPin:SetSize(64, 64) -- Large icon for visibility + waypointPin:SetSize(64, 64) waypointPin:SetFrameStrata("HIGH") waypointPin:SetFrameLevel(2000) waypointPin:Hide() - -- Enable mouse for hover tooltip + click-to-navigate (local search) - -- or hover-to-dismiss (global search) waypointPin:EnableMouse(true) waypointPin:SetScript("OnEnter", function(self) if self.isLocalSearch then @@ -967,7 +881,6 @@ function MapSearch:CreateHighlightFrame() waypointPin:SetScript("OnMouseUp", function(self, button) if button == "LeftButton" and self.isLocalSearch and self.waypointX and self.waypointY then local x, y = self.waypointX, self.waypointY - -- Collapse multi-pin to this one if in multi-pin state if activePinState and activePinState.instances then MapSearch:ShowWaypointAt(x, y, nil, self.waypointCategory) end @@ -989,7 +902,6 @@ function MapSearch:CreateHighlightFrame() wpIcon:SetAllPoints() waypointPin.icon = wpIcon - -- Add a pulsing glow effect around the icon (ALWAYS YELLOW - this is a pin, not an arrow) local glow = waypointPin:CreateTexture(nil, "BACKGROUND") glow:SetSize(100, 100) glow:SetPoint("CENTER") @@ -998,7 +910,6 @@ function MapSearch:CreateHighlightFrame() glow:SetBlendMode("ADD") waypointPin.glow = glow - -- Animation for the location pin glow local pinAnimGroup = waypointPin:CreateAnimationGroup() pinAnimGroup:SetLooping("BOUNCE") local pinPulse = pinAnimGroup:CreateAnimation("Alpha") @@ -1010,31 +921,25 @@ function MapSearch:CreateHighlightFrame() end function MapSearch:CreateZoneHighlightFrame() - -- Frame to overlay and highlight zones on continent maps zoneHighlightFrame = CreateFrame("Frame", "EasyFindZoneHighlight", WorldMapFrame.ScrollContainer.Child) - zoneHighlightFrame:SetFrameStrata("TOOLTIP") -- High strata to be visible + zoneHighlightFrame:SetFrameStrata("TOOLTIP") zoneHighlightFrame:SetFrameLevel(400) zoneHighlightFrame:SetAllPoints(WorldMapFrame.ScrollContainer.Child) - -- Decorative overlay only, must never absorb clicks. At TOOLTIP - -- strata it's the topmost frame in WorldMapFrame; if the canvas - -- extents reach under the MapTab side panel (maximized map), a - -- mouse-enabled overlay there would eat row clicks. + -- Must not absorb clicks: at TOOLTIP strata, if the canvas extends + -- under the MapTab side panel (maximized map), it would eat row clicks. zoneHighlightFrame:EnableMouse(false) zoneHighlightFrame:Hide() - -- Store references to zone highlight textures zoneHighlightFrame.highlights = {} - -- Create a pool of highlight textures we can reuse (ALWAYS YELLOW) for i = 1, 10 do local highlight = zoneHighlightFrame:CreateTexture("EasyFindZoneHighlight"..i, "OVERLAY") highlight:SetColorTexture(YELLOW_HIGHLIGHT[1], YELLOW_HIGHLIGHT[2], YELLOW_HIGHLIGHT[3], 0.5) - highlight:SetDrawLayer("OVERLAY", 7) -- Highest sublayer + highlight:SetDrawLayer("OVERLAY", 7) highlight:Hide() zoneHighlightFrame.highlights[i] = highlight end - -- Animation for pulsing effect local animGroup = zoneHighlightFrame:CreateAnimationGroup() animGroup:SetLooping("BOUNCE") local alpha = animGroup:CreateAnimation("Alpha") @@ -1043,7 +948,6 @@ function MapSearch:CreateZoneHighlightFrame() alpha:SetDuration(ANIM_DURATION) zoneHighlightFrame.animGroup = animGroup - -- Create indicator for zone highlighting local zoneInd = CreateFrame("Frame", "EasyFindZoneIndicator", WorldMapFrame.ScrollContainer.Child) zoneInd:SetSize(ns.ICON_SIZE, ns.ICON_SIZE) zoneInd:SetFrameStrata("TOOLTIP") @@ -1067,7 +971,6 @@ function MapSearch:CreateZoneHighlightFrame() zoneHighlightFrame.indicator = zoneInd end --- Get direct child zones only (1 level deep) for local search function MapSearch:GetDirectChildZones(mapID) mapID = mapID or (WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID()) if not mapID then return {} end @@ -1075,12 +978,10 @@ function MapSearch:GetDirectChildZones(mapID) local zones = {} local seen = {} - -- Get all direct children (not recursive) - local children = GetMapChildrenInfo(mapID, nil, false) -- false = not recursive + local children = GetMapChildrenInfo(mapID, nil, false) if children then for _, child in ipairs(children) do if child.name and not seen[child.mapID] then - -- Skip dungeon, micro, and orphan maps - only include navigable zones local mt = child.mapType if mt ~= Enum.UIMapType.Dungeon and mt ~= Enum.UIMapType.Micro and mt ~= Enum.UIMapType.Orphan then seen[child.mapID] = true @@ -1098,11 +999,10 @@ function MapSearch:GetDirectChildZones(mapID) return zones end --- Get the map hierarchy path for a zone (e.g., "Azeroth > Kalimdor > Durotar") function MapSearch:GetMapHierarchy(mapID) local hierarchy = {} local currentID = mapID - local maxDepth = 10 -- Safety limit + local maxDepth = 10 while currentID and maxDepth > 0 do local mapInfo = GetMapInfo(currentID) @@ -1122,7 +1022,6 @@ function MapSearch:GetMapHierarchy(mapID) return hierarchy end --- Recursively get ALL zones in the world for global search function MapSearch:GetAllWorldZones(startMapID, depth, parentPath) depth = depth or 0 parentPath = parentPath or {} @@ -1191,11 +1090,8 @@ local ZONE_PARENT_OVERRIDES = MapUtils.PARENT_OVERRIDES or { [2346] = 2274, -- Undermine → Khaz Algar (API incorrectly says The Ringing Deeps 2214) } --- Per-query cache for SearchZones, keyed on mode (local vs global). --- Stores recently-run queries so backspace hits cache (the previous --- query's result set is still in memory) and typing extensions still --- narrow from the most recent entry. LRU-evicts when capacity exceeds --- SEARCH_CACHE_MAX. Invalidated alongside cachedWorldZones. +-- LRU per-mode (local/global) so backspace hits cache and typing +-- extensions narrow from the most recent entry. local SEARCH_CACHE_MAX = 32 local searchZonesCache = { local_ = { entries = {}, order = {}, lastQuery = "" }, @@ -1306,14 +1202,10 @@ function MapSearch:SearchZones(query) or emptyWorldZones else - -- Local: only direct children of current map zones = self:GetDirectChildZones() candidates = zones end - -- Query cache: exact-hit returns cached results (covers backspace - -- and re-typing). Extension of the last query narrows from its - -- match set. Anything else falls through to a full scan. local cacheKey = isGlobalSearch and "global_" or "local_" local cache = searchZonesCache[cacheKey] local cachedHit = cache.entries[query] @@ -1332,9 +1224,8 @@ function MapSearch:SearchZones(query) local abbrevTarget = ZONE_ABBREVIATIONS[query] -- check once outside loop for _, zone in ipairs(candidates) do - -- Cache the lowercased name on the zone itself. cachedWorldZones - -- persists across searches, so this pays the slower() cost once - -- per zone instead of every keystroke (global mode = 1500 zones). + -- nameLower is cached on the zone so slower() is paid once per zone, + -- not per keystroke (global mode = 1500 zones). local nameLower = zone.nameLower if not nameLower then nameLower = slower(zone.name) @@ -1342,18 +1233,12 @@ function MapSearch:SearchZones(query) end local score = ns.Database:ScoreName(nameLower, query, #query) - -- Check abbreviation match (e.g. "sw" → "stormwind city") if abbrevTarget and nameLower == abbrevTarget then - score = mmax(score, 200) -- Treat as exact match + score = mmax(score, 200) end - -- Ancestor matching is intentionally NOT done here. If the - -- parent zone matches the query, the renderer expands ALL of - -- its children via GetWorldChildren, no need to inject - -- partial children into the results, which produced the - -- inconsistent "only some children show" behavior where - -- whether a child surfaced depended on whether its name - -- happened to share characters with the query. + -- Don't inject ancestor matches: the renderer expands the matched + -- parent via GetWorldChildren so all children show consistently. if score >= 50 then zone.score = score @@ -1361,7 +1246,6 @@ function MapSearch:SearchZones(query) end end - -- Sort by score, then by name tsort(matches, function(a, b) if a.score ~= b.score then return a.score > b.score @@ -1378,8 +1262,7 @@ function MapSearch:SearchZones(query) return matches end --- Group zone matches by their FULL parent path for clean display --- ONLY groups zones when multiple search results share the EXACT SAME parent path +-- Groups only when multiple results share the EXACT SAME parent path. local zoneGroupPool = {} local zoneGroupPoolN = 0 local zoneGroupByKey = {} @@ -1458,7 +1341,6 @@ function MapSearch:GroupZonesByParent(zones) return zoneGroupResults end --- Walk up the parent chain to find the continent a map belongs to local function GetContinentForMap(mapID) local id = mapID for i = 1, 10 do @@ -1470,9 +1352,8 @@ local function GetContinentForMap(mapID) end end --- Project mapID's rect onto viewMapID's coordinate space via their shared --- continent. Handles zones not in a direct parent-child relationship --- (e.g. Stormwind projected onto Elwynn Forest). +-- Handles zones not in a direct parent-child relationship (e.g. Stormwind +-- projected onto Elwynn Forest) via their shared continent. local function GetMapRectViaContinent(mapID, viewMapID) local c1 = GetContinentForMap(mapID) local c2 = GetContinentForMap(viewMapID) @@ -1488,10 +1369,8 @@ local function GetMapRectViaContinent(mapID, viewMapID) return (tL - vL) / vW, (tR - vL) / vW, (tT - vT) / vH, (tB - vT) / vH end --- Scan GetMapInfoAtPosition across a grid on viewMapID to find the actual --- boundary where the game considers targetMapID to exist. Returns a tight --- bounding rect, or nil if the target isn't found. Uses the continent- --- projected rect as the search region (with padding) to limit API calls. +-- Scan GetMapInfoAtPosition on viewMapID for targetMapID's tight bounds. +-- Uses the continent-projected rect (with padding) to limit API calls. local function ScanZoneBoundsOnMap(targetMapID, viewMapID, projL, projR, projT, projB) local pad = 0.05 local minX = mmax(0, (projL or 0) - pad) @@ -1522,32 +1401,27 @@ local function ScanZoneBoundsOnMap(targetMapID, viewMapID, projL, projR, projT, end if not foundL then return nil end - -- Shrink by half a step on each side for a tighter fit local inset = step * 0.5 return foundL + inset, foundR - inset, foundT + inset, foundB - inset end --- Check if a zone has no physical presence on any ancestor map. --- Orphan zones (e.g. Vision of Stormwind, Vision of Orgrimmar) return all --- zeros from GetMapRectOnMap and have no continent projection. Bugged zones --- (Uldum, Vale) return valid rects, so this won't match them. +-- Orphan zones (Vision of Stormwind, etc.) return all zeros from +-- GetMapRectOnMap and have no continent projection. Bugged zones (Uldum, +-- Vale) return valid rects, so this won't match them. local function IsOrphanZone(mapID) local info = GetMapInfo(mapID) if not info or not info.parentMapID then return false end local ok, left, right, top, bottom = pcall(GetMapRectOnMap, mapID, info.parentMapID) if not ok or not left then return true end if left ~= 0 or right ~= 0 or top ~= 0 or bottom ~= 0 then return false end - -- Continent projection also returns all zeros for truly orphaned zones local pL, pR, pT, pB = GetMapRectViaContinent(mapID, info.parentMapID) if not pL then return true end return pL == 0 and pR == 0 and pT == 0 and pB == 0 end --- Resolve a mapID to the best match for a given view map. When a zone exists --- under multiple mapIDs with the same name (e.g. TBC Isle of Quel'Danas 122 --- vs Midnight versions 2432/2424/2565), the original mapID may have no --- position on the view map. This finds a same-named child of viewMapID that --- does have a valid rect, or returns the original mapID unchanged. +-- A zone may exist under multiple mapIDs (TBC IQD 122 vs Midnight versions). +-- Find a same-named child of viewMapID that has a valid rect, else return +-- the original mapID unchanged. local function ResolveZoneForMap(mapID, viewMapID) local info = GetMapInfo(mapID) if not info or not info.name then return mapID end @@ -1572,11 +1446,9 @@ local function ResolveZoneForMap(mapID, viewMapID) return mapID end --- Sample points outside a zone's rect to find surrounding zones. --- minCount=2 → only return a zone if it appears on 2+ sides (for pre-texture --- check: catches cities like Ironforge where Dun Morogh surrounds 3/4 sides) --- minCount=1 → return the first valid zone found (for post-texture fallback: --- catches cities like Stormwind where only 1-2 probes hit a named zone) +-- minCount=2 finds a zone on 2+ sides (catches cities like Ironforge where +-- Dun Morogh surrounds 3/4 sides); minCount=1 finds the first hit (catches +-- Stormwind where only 1-2 probes hit a named zone). local function FindSurroundingZone(parentMapID, mapID, left, right, top, bottom, minCount) local centerX = (left + right) / 2 local centerY = (top + bottom) / 2 @@ -1607,15 +1479,9 @@ local function FindSurroundingZone(parentMapID, mapID, left, right, top, bottom, if bestID then return zones[bestID] end end --- Hover-safe variant of HighlightZone: draws a translucent yellow rect --- where the zone sits on the currently-viewed map, but only if the zone --- is actually visible there. Strict no-side-effects contract: --- * never calls SetMapID (would trigger OnMapChanged → MapTab refresh) --- * never touches self.pendingZoneHighlight (no nav state) --- * bails silently when the zone isn't on the current map, when we're --- already viewing it, or when its rect is degenerate --- Used by RunHoverPreview so hovering a result row never moves the map --- and never re-renders the results window. +-- Hover-only HighlightZone variant. Never calls SetMapID, never touches +-- pendingZoneHighlight; bails when the zone isn't on the current map or +-- when we're already viewing it. function MapSearch:PreviewZoneHighlight(mapID) if not zoneHighlightFrame then return end if not WorldMapFrame or not WorldMapFrame.ScrollContainer then return end @@ -1628,10 +1494,7 @@ function MapSearch:PreviewZoneHighlight(mapID) local resolved = ResolveZoneForMap(mapID, parentMapID) if resolved ~= mapID then mapID = resolved end - -- Strict direct-child gate: only preview zones whose parentMapID is - -- the currently-viewed map. Anything else (ancestors, siblings, - -- distant descendants) produces glitchy or misleading rects and is - -- skipped entirely. + -- Direct-child only: ancestors/siblings/descendants produce glitchy rects. local zoneInfo = GetMapInfo(mapID) if not zoneInfo or zoneInfo.parentMapID ~= parentMapID then return end @@ -1644,10 +1507,7 @@ function MapSearch:PreviewZoneHighlight(mapID) left, right, top, bottom = pL, pR, pT, pB end - -- Degenerate or implausibly-large rect: ancestor escapees that the - -- chain check above missed (different continent root, projection - -- math overflow) tend to produce widths >> 1. Tight gates here keep - -- the preview "this zone fits sensibly inside the visible canvas". + -- Reject degenerate or implausibly-large rects (ancestor escapees). if (right - left) < 0.01 or (bottom - top) < 0.01 then return end if (right - left) > 1.05 or (bottom - top) > 1.05 then return end if right < 0.02 or left > 0.98 or bottom < 0.02 or top > 0.98 then return end @@ -1657,8 +1517,7 @@ function MapSearch:PreviewZoneHighlight(mapID) local clampedT = mmax(0, top) local clampedB = mmin(1, bottom) if (clampedR - clampedL) < 0.01 or (clampedB - clampedT) < 0.01 then return end - -- Clamped rect covers essentially the whole canvas: it's a - -- "this zone is the entire view" case. No useful preview. + -- Whole-canvas rect = "this zone is the entire view": no useful preview. if (clampedR - clampedL) >= 0.95 and (clampedB - clampedT) >= 0.95 then return end local canvasWidth, canvasHeight = canvas:GetSize() @@ -1667,8 +1526,7 @@ function MapSearch:PreviewZoneHighlight(mapID) local zoneCenterPxX = centerX * canvasWidth local zoneCenterPxY = centerY * canvasHeight - -- Hide all pooled textures first so previous-hover residue is gone - -- before we paint the new preview. + -- Hide pooled textures so prior-hover residue doesn't bleed through. for i = 1, #zoneHighlightFrame.highlights do local hl = zoneHighlightFrame.highlights[i] hl:Hide() @@ -1677,11 +1535,8 @@ function MapSearch:PreviewZoneHighlight(mapID) hl:SetTexCoord(0, 1, 0, 1) end - -- Try the API's actual zone-shape texture (matches Blizzard's hover - -- behavior on the world map). Falls back to a translucent rect if - -- the API returns nothing useful or the texture belongs to a - -- different zone (cities pick up their containing zone's outline at - -- the center sample). + -- Falls back to a translucent rect when the texture belongs to a + -- different zone (cities pick up the containing zone's outline). local fileDataID, atlasID, texPercentX, texPercentY, texWidth, texHeight, posX, posY local highlightSuccess = pcall(function() fileDataID, atlasID, texPercentX, texPercentY, texWidth, texHeight, posX, posY = @@ -1726,9 +1581,7 @@ function MapSearch:PreviewZoneHighlight(mapID) end end else - -- No zone-shape texture available: fall back to a translucent - -- rectangle covering the zone's bounding rect. Same rect path - -- HighlightZone uses for "bugged" zones. + -- Fall back to a translucent rect (same path bugged zones use). local x = clampedL * canvasWidth local y = clampedT * canvasHeight local w = (clampedR - clampedL) * canvasWidth @@ -1750,7 +1603,6 @@ function MapSearch:PreviewZoneHighlight(mapID) zoneHighlightFrame:Show() end --- Highlight a zone on the continent map using the actual zone shape texture function MapSearch:HighlightZone(mapID) DebugPrint("[EasyFind] HighlightZone called for mapID:", mapID) @@ -1759,14 +1611,12 @@ function MapSearch:HighlightZone(mapID) return end - -- Save pending zone navigation before clearing (we might be highlighting an intermediate zone) + -- Save pending so we can still navigate after highlighting an intermediate. local savedPending = self.pendingZoneHighlight DebugPrint("[EasyFind] HighlightZone: saved pending:", savedPending) - -- Hide previous highlights self:ClearZoneHighlight() - -- Restore pending zone navigation self.pendingZoneHighlight = savedPending DebugPrint("[EasyFind] HighlightZone: restored pending:", self.pendingZoneHighlight) @@ -1786,7 +1636,6 @@ function MapSearch:HighlightZone(mapID) local parentMapID = WorldMapFrame:GetMapID() if not parentMapID then return end - -- Resolve to a same-named child if this mapID has no position here local resolved = ResolveZoneForMap(mapID, parentMapID) if resolved ~= mapID then mapID = resolved @@ -1802,17 +1651,14 @@ function MapSearch:HighlightZone(mapID) if not success or not left then return end - -- GetMapRectOnMap returned zeros - either an instanced zone with no physical - -- position, or a zone not in a direct parent-child relationship (e.g. - -- Stormwind on Elwynn). Try continent projection before giving up. + -- Zeros = instanced or non-direct-parent zone; try continent projection. if left == 0 and right == 0 and top == 0 and bottom == 0 then local pL, pR, pT, pB = GetMapRectViaContinent(mapID, parentMapID) if pL then left, right, top, bottom = pL, pR, pT, pB DebugPrint("[EasyFind] HighlightZone: used continent projection for coords") else - -- Blind scan: find where the target map exists on the parent map - -- (handles continents on the World map like Draenor, Outland, etc.) + -- Blind scan handles continents on World (Draenor, Outland, etc.). local minX, maxX, minY, maxY = 2, -1, 2, -1 for sx = 0.025, 0.975, 0.05 do for sy = 0.025, 0.975, 0.05 do @@ -1847,7 +1693,6 @@ function MapSearch:HighlightZone(mapID) local zoneLeftPx = left * canvasWidth local zoneRightPx = right * canvasWidth - -- B1: Try the zone's highlight texture from the game API local fileDataID, atlasID, texPercentX, texPercentY, texWidth, texHeight, posX, posY local highlightSuccess = pcall(function() fileDataID, atlasID, texPercentX, texPercentY, texWidth, texHeight, posX, posY = @@ -1861,8 +1706,7 @@ function MapSearch:HighlightZone(mapID) local hasTexture = highlightSuccess and posX and posY and texPercentX and texPercentY and ((fileDataID and fileDataID > 0) or (atlasID and atlasID ~= "")) - -- Validate the texture actually belongs to this zone. Cities on continent - -- maps pick up their containing zone's texture at the center point. + -- Cities on continent maps pick up the containing zone's texture. if hasTexture and isZone then local resolvedInfo = GetMapInfoAtPosition(parentMapID, centerX, centerY) if resolvedInfo and resolvedInfo.mapID ~= mapID then @@ -1870,11 +1714,9 @@ function MapSearch:HighlightZone(mapID) end end - -- Cities on continent maps have no highlight texture and sit inside - -- another zone (e.g. Ironforge inside Dun Morogh, Orgrimmar inside - -- Durotar). Sample interior points to find the containing zone. - -- Bugged zones (Uldum, Vale) are detected separately: their center - -- returns the continent itself rather than any zone. + -- Cities have no highlight texture and sit inside another zone (IF/Dun + -- Morogh, Org/Durotar); find the containing zone by sampling. Bugged + -- zones (Uldum, Vale) are detected by their center returning a continent. if not hasTexture and isZone then local parentMapInfo = GetMapInfo(parentMapID) if parentMapInfo and parentMapInfo.mapType == Enum.UIMapType.Continent then @@ -1884,7 +1726,6 @@ function MapSearch:HighlightZone(mapID) local isBuggedZone = not centerInfo or centerInfo.mapType ~= Enum.UIMapType.Zone if not isBuggedZone then - -- Normal city detection: sample interior points local counts = {} for sx = 0.2, 0.8, 0.3 do for sy = 0.2, 0.8, 0.3 do @@ -1949,9 +1790,8 @@ function MapSearch:HighlightZone(mapID) if isZone then local parentMapInfo = GetMapInfo(parentMapID) - -- On a zone-level map: scan for actual bounds, keeping the - -- continent projection as fallback if the scan returns a tiny - -- sliver (some cities barely register via GetMapInfoAtPosition) + -- Some cities barely register via GetMapInfoAtPosition, so + -- keep the projection as fallback when the scan is too small. if parentMapInfo and parentMapInfo.mapType == Enum.UIMapType.Zone then local sL, sR, sT, sB = ScanZoneBoundsOnMap(mapID, parentMapID, left, right, top, bottom) local projW, projH = right - left, bottom - top @@ -1974,14 +1814,10 @@ function MapSearch:HighlightZone(mapID) end end - -- Skip the border box when this is the final navigation target - -- (cities, Dalaran, etc.) - arrow-only is cleaner + -- Skip the border box on final targets (cities, etc.): arrow-only. local isFinalTarget = self.pendingZoneHighlight == mapID - -- Always place a click overlay for the final navigation target. - -- For working zones SetMapID is equivalent to a normal click. - -- For zones broken by the WoW bug (Uldum, Vale of Eternal Blossoms) - -- this is the only way to navigate in. + -- Click overlay is the only way into WoW-bugged zones (Uldum, Vale). if isFinalTarget then DebugPrint("[EasyFind] Final target, adding click overlay for:", mapID) if not zoneHighlightFrame.clickOverlay then @@ -2011,7 +1847,6 @@ function MapSearch:HighlightZone(mapID) overlay:Show() end - -- Pulsing radial glow centered on bugged zones with no highlight texture if isFinalTarget and not hasTexture then if not zoneHighlightFrame.centerGlow then local glow = canvas:CreateTexture(nil, "ARTWORK") @@ -2075,10 +1910,8 @@ function MapSearch:HighlightZone(mapID) zoneHighlightFrame.animGroup:Play() DebugPrint("[EasyFind] HighlightZone: highlight and frame shown") - -- Position indicator with smart bounds checking if zoneHighlightFrame.indicator then local zoneInd = zoneHighlightFrame.indicator - -- Convert UI-unit sizes to canvas units so it's visible on continent maps local userScale = EasyFind.db.iconScale or 0.8 local indicatorSize = ns.UIToCanvas(ns.ZONE_ICON_SIZE) * userScale local indicatorGlowSize = ns.UIToCanvas(ns.ZONE_ICON_GLOW_SIZE) * userScale @@ -2088,14 +1921,13 @@ function MapSearch:HighlightZone(mapID) if zoneInd.glow then zoneInd.glow:SetSize(indicatorGlowSize, indicatorGlowSize) end - -- DO NOT override color/texture here - OnShow hook handles it via ns.UpdateIndicator + -- Don't override color/texture: ns.UpdateIndicator handles it via OnShow. local margin = 50 zoneInd:ClearAllPoints() DebugPrint("[EasyFind] HighlightZone: indicator positioning - zoneTopPx:", zoneTopPx, "margin+indicatorSize:", margin + indicatorSize) - -- Set direction on the frame - ns.UpdateIndicator (via OnShow hook) reads this local gap = 25 if zoneTopPx > margin + indicatorSize then zoneInd.indicatorDirection = "down" @@ -2115,7 +1947,6 @@ function MapSearch:HighlightZone(mapID) DebugPrint("[EasyFind] Indicator placed RIGHT of zone") end - -- Update bob direction to match indicator pointing direction if zoneInd.translateAnim then if zoneInd.indicatorDirection == "down" then zoneInd.translateAnim:SetOffset(0, -10) @@ -2142,10 +1973,8 @@ function MapSearch:HighlightZone(mapID) return true end --- preserveBreadcrumb: when true, leaves the click-driven breadcrumb --- highlight (and its pendingZoneHighlight) untouched. Used by hover- --- preview cleanup so moving the cursor off a row doesn't wipe the --- gold breadcrumb the user is being guided to. +-- preserveBreadcrumb=true keeps the click-driven breadcrumb so a hover +-- exit doesn't wipe the gold guide the user is following. function MapSearch:ClearZoneHighlight(preserveBreadcrumb) if not zoneHighlightFrame then return end @@ -2187,7 +2016,6 @@ function MapSearch:ClearZoneHighlight(preserveBreadcrumb) if preserveBreadcrumb then return end - -- Also clear breadcrumb highlight if self.breadcrumbHighlight then if self.breadcrumbHighlight.indicatorFrame then self.breadcrumbHighlight.indicatorFrame:Hide() @@ -2198,15 +2026,13 @@ function MapSearch:ClearZoneHighlight(preserveBreadcrumb) if self.breadcrumbHighlight.glowAnim then self.breadcrumbHighlight.glowAnim:Stop() end - self.breadcrumbHighlight:Hide() -- OnHide unlocks the button highlight automatically + self.breadcrumbHighlight:Hide() end - -- Clear pending zone navigation (but NOT pendingWaypoint - that's the final - -- destination waypoint and must survive through the zone navigation chain) + -- pendingWaypoint must survive: it's the final destination through the chain. self.pendingZoneHighlight = nil end --- Highlight a zone with step-by-step navigation guidance (teaching mode) function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) DebugPrint("[EasyFind] HighlightZoneOnMap called for targetMapID:", targetMapID) @@ -2243,9 +2069,7 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) return end - -- If the target is an ancestor of the current map, it's in the breadcrumb. - -- Highlight that breadcrumb button directly instead of going through DCA - -- logic which would overshoot to the target's parent. + -- Ancestor target: highlight breadcrumb directly, else DCA overshoots. local currentParentChain = self:GetMapPath(currentMapID) for i = 1, #currentParentChain - 1 do if currentParentChain[i].mapID == targetMapID then @@ -2266,8 +2090,6 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) local currentInfo = GetMapInfo(currentMapID) DebugPrint("[EasyFind] Current map:", currentInfo and currentInfo.name or "nil", "ID:", currentMapID) - -- Resolve legacy mapIDs: if the current map has a same-named child zone - -- with a valid rect, use that instead (e.g. TBC IQD 122 -> Midnight 2432) local resolved = ResolveZoneForMap(targetMapID, currentMapID) if resolved ~= targetMapID then targetMapID = resolved @@ -2276,16 +2098,13 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) targetParentMapID = GetMapParentID(targetMapID, targetInfo) end - -- CASE 1: We're already on the target's parent map - just highlight the zone! + -- CASE 1: already on target's parent map. if currentMapID == targetParentMapID then DebugPrint("[EasyFind] CASE 1: Already on target parent, highlighting zone") - -- Cities parented directly to the continent (IF, UC, TB, Shattrath) - -- need to route through their containing zone first. Only redirect - -- when the candidate zone's rect fully encloses the target's rect - -- (otherwise adjacent zones like Icecrown get falsely matched). - -- Skip when the zone has no highlight info (WoW bug: Uldum, Vale of - -- Eternal Blossoms) - let it fall through to direct highlight + overlay. + -- Continent-parented cities (IF, UC, TB, Shattrath) route through + -- their containing zone. Require full rect enclosure to avoid + -- adjacent-zone false matches; skip for WoW-bugged Uldum/Vale. if currentInfo and currentInfo.mapType == Enum.UIMapType.Continent and targetInfo.mapType == Enum.UIMapType.Zone then local ok, cL, cR, cT, cB = pcall(GetMapRectOnMap, targetMapID, currentMapID) @@ -2298,9 +2117,7 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) local ok2, sL, sR, sT, sB = pcall(GetMapRectOnMap, containing.mapID, currentMapID) if ok2 and sL and cL >= sL and cR <= sR and cT >= sT and cB <= sB then local containArea = (sR - sL) * (sB - sT) - -- Only route through containing zone if target is - -- city-sized (< 25% of container area). Large zones - -- that appear "inside" another are WoW API bugs. + -- < 25% gates city-sized targets; larger ratios are API bugs. if targetArea < containArea * 0.25 then DebugPrint("[EasyFind] CASE 1: city inside", containing.name, "- routing through it") self.pendingZoneHighlight = targetMapID @@ -2311,8 +2128,7 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) end end end - -- Center returned the city itself; check surrounding points - -- and verify spatial containment + -- Center returned the city itself; check surrounding zones. local surrounding = FindSurroundingZone(currentMapID, targetMapID, cL, cR, cT, cB, 1) if surrounding then local ok2, sL, sR, sT, sB = pcall(GetMapRectOnMap, surrounding.mapID, currentMapID) @@ -2331,8 +2147,7 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) end end - -- Keep pending so reguiding works if user clicks wrong zone. - -- OnMapChanged checks arrival (newMapID == pending) to stop the chain. + -- Keep pending so OnMapChanged can stop the chain on arrival. self.pendingZoneHighlight = targetMapID C_Timer.After(0.05, function() self:HighlightZone(targetMapID) @@ -2340,9 +2155,8 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) return end - -- CASE 1b: Current map physically contains the target even though the API - -- parent chain doesn't link them (e.g. Stormwind inside Elwynn Forest). - -- Verify via continent projection + GetMapInfoAtPosition. + -- CASE 1b: current map contains target but API chain doesn't link them + -- (e.g. Stormwind inside Elwynn Forest). if currentInfo and currentInfo.mapType == Enum.UIMapType.Zone then local cL, cR, cT, cB = GetMapRectViaContinent(targetMapID, currentMapID) if cL then @@ -2361,7 +2175,6 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) end end - -- Build paths from root (World) to each map local targetParentPath = self:GetMapPath(targetParentMapID) local currentPath = self:GetMapPath(currentMapID) @@ -2374,7 +2187,6 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) DebugPrint(" ", i, p.name, "ID:", p.mapID) end - -- Find the DEEPEST common ancestor (DCA) local dcaIndex = 0 local dcaMapID = nil for i = 1, mmin(#targetParentPath, #currentPath) do @@ -2420,10 +2232,8 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) return end - -- CASE 2b: Current zone-level map geographically contains the target even - -- though it's not in the API parent chain (e.g. Azuremyst Isle contains - -- Exodar, but Exodar's API parent is Kalimdor). Try HighlightZone directly - -- before sending the user backwards via breadcrumbs. + -- CASE 2b: zone-level current contains target geographically though not + -- via API chain (e.g. Azuremyst contains Exodar, parented to Kalimdor). if currentInfo and currentInfo.mapType == Enum.UIMapType.Zone then local cL, cR, cT, cB = GetMapRectViaContinent(targetMapID, currentMapID) if cL then @@ -2439,17 +2249,15 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) end end - -- CASE 3: Current map is BELOW the deepest common ancestor + -- CASE 3: below DCA, zoom out. DebugPrint("[EasyFind] CASE 3: Need to zoom OUT to DCA, highlighting breadcrumb") self:HighlightBreadcrumbForNavigation(dcaMapID, targetMapID, targetParentPath, dcaIndex) end --- Get the full path from World/root to a given map function MapSearch:GetMapPath(mapID) return GetMapPath(mapID) end --- Highlight a breadcrumb button to guide user to zoom out toward the target function MapSearch:HighlightBreadcrumbForNavigation(dcaMapID, finalTargetMapID, targetParentPath, dcaIndex) DebugPrint("[EasyFind] HighlightBreadcrumbForNavigation: DCA=", dcaMapID, "finalTarget=", finalTargetMapID) @@ -2458,7 +2266,7 @@ function MapSearch:HighlightBreadcrumbForNavigation(dcaMapID, finalTargetMapID, local navBar = WorldMapFrame.NavBar if not navBar then DebugPrint("[EasyFind] No NavBar found, direct nav to DCA") - -- CRITICAL: Set pending BEFORE SetMapID because SetMapID triggers OnMapChanged synchronously! + -- Must set pending BEFORE SetMapID: SetMapID triggers OnMapChanged sync. self.pendingZoneHighlight = finalTargetMapID DebugPrint("[EasyFind] Set pendingZoneHighlight BEFORE SetMapID:", finalTargetMapID) WorldMapFrame:SetMapID(dcaMapID) @@ -2467,10 +2275,8 @@ function MapSearch:HighlightBreadcrumbForNavigation(dcaMapID, finalTargetMapID, DebugPrint("[EasyFind] Searching for breadcrumb button for DCA ID:", dcaMapID) - -- Try to find a visible breadcrumb button for the DCA local buttonToHighlight = self:FindBreadcrumbButton(navBar, dcaMapID) - -- If not found, try each ancestor going up from DCA if not buttonToHighlight then DebugPrint("[EasyFind] DCA button not found, trying ancestors...") for i = dcaIndex - 1, 1, -1 do @@ -2492,12 +2298,10 @@ function MapSearch:HighlightBreadcrumbForNavigation(dcaMapID, finalTargetMapID, DebugPrint("[EasyFind] Button found and shown, highlighting it") self:ShowBreadcrumbHighlight(buttonToHighlight, finalTargetMapID) else - -- Walk the current map's parent chain (highest first) and find - -- the first ancestor that has a visible breadcrumb button. DebugPrint("[EasyFind] No button found, walking current path for fallback") local currentMapID = WorldMapFrame:GetMapID() local currentPath = self:GetMapPath(currentMapID) - for i = 1, #currentPath - 1 do -- skip current map itself + for i = 1, #currentPath - 1 do local breadcrumbBtn = self:FindBreadcrumbButton(navBar, currentPath[i].mapID) if breadcrumbBtn and breadcrumbBtn:IsShown() then buttonToHighlight = breadcrumbBtn @@ -2510,7 +2314,7 @@ function MapSearch:HighlightBreadcrumbForNavigation(dcaMapID, finalTargetMapID, self:ShowBreadcrumbHighlight(buttonToHighlight, finalTargetMapID) else DebugPrint("[EasyFind] No breadcrumb at all, navigating directly to DCA") - -- CRITICAL: Set pending BEFORE SetMapID because SetMapID triggers OnMapChanged synchronously! + -- Set pending BEFORE SetMapID: SetMapID fires OnMapChanged sync. self.pendingZoneHighlight = finalTargetMapID DebugPrint("[EasyFind] Set pendingZoneHighlight BEFORE SetMapID:", finalTargetMapID) WorldMapFrame:SetMapID(dcaMapID) @@ -2518,38 +2322,30 @@ function MapSearch:HighlightBreadcrumbForNavigation(dcaMapID, finalTargetMapID, end end --- Find a breadcrumb button for a given map ID function MapSearch:FindBreadcrumbButton(navBar, mapID) DebugPrint("[EasyFind] FindBreadcrumbButton looking for mapID:", mapID) - -- The NavBar in WoW uses a different structure - buttons are direct children - -- Let's iterate through children to find the right button for i = 1, select("#", navBar:GetChildren()) do local child = select(i, navBar:GetChildren()) if child.GetID and child:GetID() == mapID then DebugPrint("[EasyFind] Found button via GetID:", mapID) return child end - -- Also check for navButton property or data if child.data and child.data.id == mapID then DebugPrint("[EasyFind] Found button via data.id:", mapID) return child end end - -- Check the navigation list - the button might be the entry itself if navBar.navList then DebugPrint("[EasyFind] navList has", #navBar.navList, "entries") for i, buttonData in ipairs(navBar.navList) do DebugPrint("[EasyFind] navList[" .. i .. "] id:", buttonData.id, "type:", type(buttonData)) - -- The buttonData itself might BE the button frame if buttonData.id == mapID then - -- Check if buttonData is a frame with Show/IsShown if buttonData.IsShown and buttonData:IsShown() then DebugPrint("[EasyFind] buttonData itself is the button frame!") return buttonData end - -- Or maybe it has a different button reference if buttonData.Button then DebugPrint("[EasyFind] Found buttonData.Button") return buttonData.Button @@ -2560,10 +2356,8 @@ function MapSearch:FindBreadcrumbButton(navBar, mapID) DebugPrint("[EasyFind] navBar.navList is nil!") end - -- Check home button (usually World/Cosmic) if navBar.home and navBar.home:IsShown() then - -- Cosmic map is always the home button; API name "Cosmic" differs - -- from the display name "World" so ID/text checks fail + -- Cosmic API name differs from "World" display name, so check mapType. local targetInfo = GetMapInfo(mapID) if targetInfo and targetInfo.mapType == Enum.UIMapType.Cosmic then DebugPrint("[EasyFind] Cosmic map requested, returning home button") @@ -2574,7 +2368,6 @@ function MapSearch:FindBreadcrumbButton(navBar, mapID) if homeMapID == mapID then return navBar.home end - -- Home button might match by text instead of ID if not homeMapID and navBar.home.GetText then local homeText = navBar.home:GetText() if homeText and targetInfo and homeText == targetInfo.name then @@ -2586,7 +2379,6 @@ function MapSearch:FindBreadcrumbButton(navBar, mapID) DebugPrint("[EasyFind] No home button or not shown") end - -- Last resort: look for WorldMapNavBarButton frames local buttonName = "WorldMapNavBarButton" for i = 1, 10 do local mapBtn = _G[buttonName .. i] @@ -2596,7 +2388,6 @@ function MapSearch:FindBreadcrumbButton(navBar, mapID) end end - -- Text-based fallback: match button text to the map name local targetName = GetMapInfo(mapID) targetName = targetName and targetName.name if targetName then @@ -2615,7 +2406,6 @@ function MapSearch:FindBreadcrumbButton(navBar, mapID) return nil end --- Show the highlight effect on a breadcrumb button function MapSearch:ShowBreadcrumbHighlight(button, finalTargetMapID) DebugPrint("[EasyFind] ShowBreadcrumbHighlight, finalTarget:", finalTargetMapID) @@ -2634,7 +2424,7 @@ function MapSearch:ShowBreadcrumbHighlight(button, finalTargetMapID) pulse:SetDuration(0.8) hl.pulseAnim = pulseAnim - -- Extra gold layers to boost brightness (single LockHighlight is too dim) + -- Extra gold layers: a single LockHighlight is too dim. local GLOW_LAYERS = 3 hl.glowTextures = {} for i = 1, GLOW_LAYERS do @@ -2646,8 +2436,6 @@ function MapSearch:ShowBreadcrumbHighlight(button, finalTargetMapID) hl.glowTextures[i] = g end - -- Unlock the previous button's highlight when this frame hides, - -- regardless of which clear path triggered it. hl:SetScript("OnHide", function(self) for _, g in ipairs(self.glowTextures) do g:Hide() end if self.button then @@ -2661,8 +2449,7 @@ function MapSearch:ShowBreadcrumbHighlight(button, finalTargetMapID) end end) - -- Indicator pointing to button - parented to UIParent so it's not clipped - -- by WorldMapFrame when extending above the map edge + -- Parented to UIParent so it isn't clipped above the map edge. local bcIndFrame = CreateFrame("Frame", nil, UIParent) bcIndFrame:SetFrameStrata("TOOLTIP") bcIndFrame:SetFrameLevel(301) @@ -2690,7 +2477,6 @@ function MapSearch:ShowBreadcrumbHighlight(button, finalTargetMapID) local hl = self.breadcrumbHighlight - -- Unlock previous button if we're switching to a different one if hl.button and hl.button ~= button then if hl.button.UnlockHighlight then hl.button:UnlockHighlight() end local prevTex = hl.button.GetHighlightTexture and hl.button:GetHighlightTexture() @@ -2708,7 +2494,6 @@ function MapSearch:ShowBreadcrumbHighlight(button, finalTargetMapID) hlTex:SetVertexColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1) end - -- Copy the button's highlight texture into our stacked glow layers for i = 1, #hl.glowTextures do local g = hl.glowTextures[i] if hlTex then @@ -2743,11 +2528,8 @@ function MapSearch:ShowBreadcrumbHighlight(button, finalTargetMapID) DebugPrint("[EasyFind] ShowBreadcrumbHighlight - SET pendingZoneHighlight to:", finalTargetMapID) end --- Reposition the breadcrumb indicator for the current map mode. --- In maximized mode, breadcrumbs are at the screen top edge so the indicator --- goes below the button pointing up. In windowed mode it goes above pointing down. --- Called from ShowBreadcrumbHighlight and from RepositionForMapMode so the --- indicator stays correct when the user toggles between maximized and windowed. +-- Maximized: breadcrumbs at screen top, indicator goes below pointing up. +-- Windowed: indicator goes above pointing down. function MapSearch:UpdateBreadcrumbPosition() local hl = self.breadcrumbHighlight if not hl or not hl.indicatorFrame then return end @@ -2773,7 +2555,6 @@ function MapSearch:UpdateBreadcrumbPosition() end end --- Check if current map is a continent (has zone children) function MapSearch:IsOnContinentMap() local mapID = WorldMapFrame:GetMapID() if not mapID then return false end @@ -2781,15 +2562,12 @@ function MapSearch:IsOnContinentMap() local mapInfo = GetMapInfo(mapID) if not mapInfo then return false end - -- Continent type is 2, World is 1 return mapInfo.mapType == Enum.UIMapType.Continent or mapInfo.mapType == Enum.UIMapType.World end function MapSearch:HookWorldMap() WorldMapFrame:HookScript("OnShow", function() - -- Restore pins only if the player is in the pin's zone. - -- Map opens to the player's current zone by default, so if they - -- left the zone the pin was in, it's gone. + -- Only restore pins when the player is still in the pinned zone. if activePinState then local currentMapID = WorldMapFrame:GetMapID() local playerMapID = GetBestMapForUnit("player") @@ -2806,7 +2584,6 @@ function MapSearch:HookWorldMap() activePinState = nil end end - -- Show tracked rares when no search pins to restore if not activePinState and EasyFind.db.alwaysShowRares then C_Timer.After(0, function() self:UpdateRareTracking() @@ -2815,8 +2592,7 @@ function MapSearch:HookWorldMap() end) WorldMapFrame:HookScript("OnHide", function() - -- Hide high-strata visuals that would paint through the closed map. - -- activePinState is preserved so they restore on reopen. + -- activePinState is preserved so visuals restore on reopen. self:ClearHighlight() self:ClearZoneHighlight() self.pendingWaypoint = nil @@ -2838,23 +2614,18 @@ function MapSearch:HookWorldMap() DebugPrint("[EasyFind] OnMapChanged - new map:", newMapInfo and newMapInfo.name or "nil", "ID:", newMapID) DebugPrint("[EasyFind] OnMapChanged - pendingZoneHighlight:", self.pendingZoneHighlight) - -- Snapshot pendingZoneHighlight BEFORE clearing. - -- pendingWaypoint is NOT wiped by ClearZoneHighlight so no snapshot needed. + -- Snapshot pendingZoneHighlight before ClearZoneHighlight wipes it. local savedPendingZone = self.pendingZoneHighlight self:ClearHighlight() self:ClearZoneHighlight() - -- Clear breadcrumb highlight if self.breadcrumbHighlight then self.breadcrumbHighlight:Hide() end - -- If we have both a pending zone AND a pending waypoint, check if the - -- entrance is actually visible on the current map via the Encounter Journal - -- API. This handles sub-zones (e.g. "City of Threads - Lower" inside - -- Azj-Kahet) where the entrance pin is visible on the parent zone map - -- but not on the continent map. + -- Handles sub-zones (e.g. "City of Threads - Lower" inside Azj-Kahet) + -- where the entrance is on the parent zone map but not the continent. if savedPendingZone and self.pendingWaypoint and self.pendingWaypoint.mapID then local wp = self.pendingWaypoint local currentMapID = WorldMapFrame:GetMapID() @@ -2863,8 +2634,7 @@ function MapSearch:HookWorldMap() if entrances then for _, entrance in ipairs(entrances) do if entrance.name and entrance.position then - -- Match by proximity - entrance coords are in current map space - -- Use GetMapRectOnMap to project wp coords to current map for comparison + -- Match by proximity after projecting wp into current map space. local ok, left, right, top, bottom = pcall(GetMapRectOnMap, wp.mapID, currentMapID) if ok and left and (right - left) > 0 then local projX = left + wp.x * (right - left) @@ -2888,15 +2658,12 @@ function MapSearch:HookWorldMap() end end - -- Check if we have a pending zone to highlight (step-by-step navigation) if savedPendingZone then - -- Match by ID or by name (handles zones with multiple mapIDs - -- like Dalaran which has different IDs per expansion) + -- Match by ID or name (Dalaran has different IDs per expansion). local pendingInfo = GetMapInfo(savedPendingZone) local arrivedByName = pendingInfo and newMapInfo and pendingInfo.name == newMapInfo.name if newMapID == savedPendingZone or arrivedByName then - -- Arrived at the target zone - stop reguiding DebugPrint("[EasyFind] OnMapChanged - arrived at target zone:", savedPendingZone) if self.pendingWaypoint then local wp = self.pendingWaypoint @@ -2907,44 +2674,38 @@ function MapSearch:HookWorldMap() end) end else - -- Wrong zone or intermediate step - use full path-based navigation - -- so multi-level chains (Cosmic → Azeroth → EK → Dun Morogh) - -- work correctly instead of trying to render directly. + -- Use full path-based nav so multi-level chains + -- (Cosmic -> Azeroth -> EK -> Dun Morogh) work. DebugPrint("[EasyFind] OnMapChanged - reguiding to:", savedPendingZone) C_Timer.After(0.1, function() self:HighlightZoneOnMap(savedPendingZone) end) end elseif self.pendingWaypoint then - -- We arrived at a zone with a pending waypoint (e.g. dungeon entrance) local wp = self.pendingWaypoint self.pendingWaypoint = nil DebugPrint("[EasyFind] OnMapChanged - showing pending waypoint at:", wp.x, wp.y) C_Timer.After(0.1, function() - self:ClearZoneHighlight() -- Belt-and-suspenders: nuke any lingering zone highlight + self:ClearZoneHighlight() self:ShowWaypointAt(wp.x, wp.y, wp.icon, wp.category) end) else DebugPrint("[EasyFind] OnMapChanged - no pending, clearing highlights") - -- Navigated to a different map - discard any stale pin state if activePinState and activePinState.mapID ~= newMapID then activePinState = nil end self:ClearZoneHighlight() - -- Re-scan rares for the new zone if EasyFind.db.alwaysShowRares then C_Timer.After(0, function() self:UpdateRareTracking() end) end end end) - -- Live-refresh always-on rare pins when rares spawn or despawn nearby. local vignetteFrame = CreateFrame("Frame") vignetteFrame:RegisterEvent("VIGNETTES_UPDATED") vignetteFrame:SetScript("OnEvent", function() - -- Don't wipe a hover preview; the refresh will happen when - -- the preview ends or the user interacts again. + -- Don't wipe a hover preview. if MapSearch._previewing then return end if EasyFind.db.alwaysShowRares then MapSearch:UpdateRareTracking() @@ -2980,14 +2741,11 @@ function MapSearch:GetRelatedCategories(category) local related = {category} local catData = CATEGORIES[category] - -- Only add parent, NOT siblings - -- Siblings are only included when searching for the parent category itself + -- Parent only, not siblings: "pvp" should not show "auction house". if catData and catData.parent then tinsert(related, catData.parent) - -- Do NOT add sibling categories - that causes "pvp" to show "auction house" end - -- Add children of this category (if searching for a parent like "service" or "travel") for catName, data in pairs(CATEGORIES) do if data.parent == category then tinsert(related, catName) @@ -2997,11 +2755,9 @@ function MapSearch:GetRelatedCategories(category) return related end --- Continent-wide cache: maps lowercased instance name → owner zone mapID. --- Built once per continent by walking the map hierarchy. Used to reject --- adjacent-zone bleed without a strict whitelist (entrances with no owner --- in the hierarchy are kept - benefit of the doubt). -local continentInstanceOwners = {} -- [continentID] = { [lowerName] = ownerZoneMapID } +-- [continentID][lowerName] = ownerZoneMapID. Rejects adjacent-zone bleed +-- without a strict whitelist (entrances with no owner get benefit of doubt). +local continentInstanceOwners = {} local function GetContinentInstanceOwners(continentID) if continentInstanceOwners[continentID] then @@ -3019,9 +2775,8 @@ local function GetContinentInstanceOwners(continentID) if ownerZoneID then owners[slower(child.name)] = ownerZoneID end - -- Don't recurse into dungeon/raid sub-floors else - -- First Zone encountered becomes the owner; sub-zones inherit it + -- First Zone becomes the owner; sub-zones inherit. local newOwner = ownerZoneID if not newOwner and mt == Enum.UIMapType.Zone then newOwner = child.mapID @@ -3037,20 +2792,6 @@ local function GetContinentInstanceOwners(continentID) return owners end --- Scan dungeon/raid entrances for the given map using the Encounter Journal API. --- Returns POI-style entries with name, position, category (dungeon/raid), and the zone mapID. --- --- For zone-level maps (parent is Continent), two scans are performed: --- 1) The zone itself - filtered by continent-wide instance ownership so entrances --- that belong to a DIFFERENT zone are rejected (e.g. Grim Batol appearing on --- the Wetlands map when it belongs to Twilight Highlands). Entrances with no --- owner in the map hierarchy are kept (benefit of the doubt). --- 2) The parent continent - to catch entrances the EJ API only returns for a --- neighboring zone. Continent entrances owned by the current zone are included --- with coordinates projected to zone space. --- Check if a named instance entrance is visible on a given map. --- Checks both EJ dungeons/raids and delve area POIs. --- Returns x, y or nil. function MapSearch:FindEntranceOnMap(name, mapID) local nameNorm = normalizeName(name) if GetDungeonEntrancesForMap then @@ -3094,13 +2835,12 @@ function MapSearch:ScanDungeonEntrances(mapID) local parentInfo = mapInfo and mapInfo.parentMapID and GetMapInfo(mapInfo.parentMapID) local parentLabel = mapInfo and mapInfo.name or "" - -- For zone-level maps (parent is Continent), use the continent-wide ownership - -- map to filter adjacent-zone bleed. + -- Zone-level maps use continent-wide ownership to filter adjacent bleed. local useContinent = parentInfo and parentInfo.mapType == Enum.UIMapType.Continent local continentID = useContinent and parentInfo.mapID or nil local owners = continentID and GetContinentInstanceOwners(continentID) or nil - -- Pre-compute zone ↔ continent projection rect (for pass 2 coordinate conversion) + -- Pre-compute zone <-> continent projection rect for pass 2. local canProject = false local zL, zR, zT, zB if useContinent and continentID then @@ -3184,25 +2924,18 @@ function MapSearch:ScanDungeonEntrances(mapID) return results end --- Lazily-built cache of dungeon/raid/delve entrances across the entire world. --- Built once on first global search, then reused. local globalInstanceCache local cachedAllFlightMasters local ResetSearchPoisCache --- Invalidate map caches when Blizzard signals the map/world state may --- have become richer. Without this, a first-search call runs before --- some lazy map APIs have finished populating and both caches end up --- partial; later searches reuse the stale cache and miss descendant --- zones (e.g. Northrend's children) entirely. +-- Map state may grow as Blizzard lazy-loads it; invalidate so a first-search +-- call doesn't fix a partial cache (missing Northrend children, etc.). local localScanCache = nil local staticLocationCache = {} local emptyStaticLocations = {} local emptyFlightMasters = {} --- Cache of promoted instance POIs (zone-style entries with breadcrumb --- paths) so BuildResults doesn't allocate ~300 new tables per keystroke. --- Built once when globalInstanceCache + cachedWorldZones are ready, --- invalidated alongside them. +-- Promoted zone-style POIs with breadcrumb paths, so BuildResults doesn't +-- allocate ~300 tables per keystroke. local promotedInstancePOIs = nil local function ReleaseGlobalMapCaches() @@ -3234,9 +2967,7 @@ do promotedInstancePOIs = nil localScanCache = nil wipe(staticLocationCache) - -- Use namespace lookups for reset helpers defined further down - -- in the file; locals declared after this invalidator block - -- aren't visible to its closure. + -- Reset helpers are below this block, so go through the namespace. if ns.MapSearch.ResetSearchZonesCache then ns.MapSearch.ResetSearchZonesCache() end if ns.MapSearch.ResetSearchPoisCache then ns.MapSearch.ResetSearchPoisCache() end if C_Timer and C_Timer.After then @@ -3249,12 +2980,8 @@ do end) end --- Local-scope caches (SearchZones local_, SearchPOIs local_, --- localScanCache) are tied to whichever map WorldMapFrame currently --- shows: its direct child zones, dungeon entrances, etc. The --- character-zone events above DON'T fire when the player navigates --- the world map UI to another zone, so without this hook a previous --- map's child zones would bleed into "This Zone (currentMap)" results. +-- Character-zone events don't fire when the player browses the world map +-- UI, so flush local-scope caches on map change to avoid bleed. do local function FlushLocalCaches() localScanCache = nil @@ -3276,14 +3003,8 @@ do end end --- Runs every scan needed for local-mode BuildResults in one shot and --- caches the result keyed on mapID. Typing `delve` used to rescan the --- current map's POIs, dungeon entrances, flight masters, and vignettes --- on every keystroke: each scan calls Blizzard APIs repeatedly and --- adds up to hundreds of milliseconds per search in a busy zone. A --- 1-second TTL is long enough to coalesce a typing burst but short --- enough that a freshly-placed pin shows up on the user's next natural --- pause. +-- Caches all local-mode scans by mapID. Per-keystroke scans without this +-- run hundreds of ms in busy zones. local function GetLocalScans(self, mapID) mapID = mapID or (WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID()) or 0 local includeFlightMasters = MapTabFlightPathsEnabled() @@ -3312,17 +3033,13 @@ function MapSearch:GetGlobalInstanceCache() local seen = {} -- deduplicate exact same name+mapID pair local nameSeen = {} -- one entry per instance name (shallowest zone wins) - -- Recursively collect all mapIDs from the Cosmic map (946). - -- The recursion visits parent zones before their children, so the first - -- entry discovered for a given dungeon name is always the shallowest - -- (most general) zone - e.g. Azj-Kahet before City of Threads. - -- We keep only that first entry to avoid duplicate search results and - -- to maximise the chance that entranceMapID == the user's current map. + -- Walks Cosmic (946). Parent before child, so the shallowest zone owns + -- each dungeon (Azj-Kahet before City of Threads) and entranceMapID is + -- most likely to match the user's current map. local function collectMaps(parentMapID) local children = GetMapChildrenInfo(parentMapID, nil, false) if not children then return end for _, child in ipairs(children) do - -- Dungeon entrances from the encounter journal local entrances = self:ScanDungeonEntrances(child.mapID) for _, e in ipairs(entrances) do local key = e.name .. "|" .. child.mapID @@ -3336,7 +3053,6 @@ function MapSearch:GetGlobalInstanceCache() end end - -- Delve entrances via dedicated API (separate from EJ dungeons/raids) if GetDelvesForMap then local delveIDs = GetDelvesForMap(child.mapID) if delveIDs then @@ -3371,7 +3087,6 @@ function MapSearch:GetGlobalInstanceCache() end end - -- Static locations with whitelisted categories (e.g. future delve POIs) local locs = STATIC_LOCATIONS[child.mapID] if locs then local mapInfo = GetMapInfo(child.mapID) @@ -3466,8 +3181,6 @@ function MapSearch:WarmUISearchCaches() end end --- Scan flight masters for the given map using the TaxiMap API --- Returns POI-style entries with name, position, and flightmaster category function MapSearch:ScanFlightMasters(mapID) mapID = mapID or WorldMapFrame:GetMapID() if not mapID then return {} end @@ -3480,19 +3193,16 @@ function MapSearch:ScanFlightMasters(mapID) local playerFaction = UnitFactionGroup("player") local FPFaction = Enum.FlightPathFaction - -- Only filter adjacent-zone bleed on zone-level maps (parent is Continent). + -- Only filter adjacent-zone bleed on zone-level maps. local fmMapInfo = GetMapInfo(mapID) local fmParentInfo = fmMapInfo and fmMapInfo.parentMapID and GetMapInfo(fmMapInfo.parentMapID) local fmShouldFilter = fmParentInfo and fmParentInfo.mapType == Enum.UIMapType.Continent - -- Continent-level scans need per-node zone resolution so each FM - -- gets the actual child zone it belongs to (Hillsbrad, EP, etc.) - -- rather than the continent. MapTab uses parentMapID for zone-level - -- sub-grouping under the continent header. + -- Continent scans resolve per-node so each FM gets the child zone it + -- belongs to; MapTab uses parentMapID for sub-grouping under continent. local resolvePerNode = (fmMapInfo and fmMapInfo.mapType == Enum.UIMapType.Continent) or fmShouldFilter for _, node in ipairs(nodes) do if node.name and node.position then - -- Skip flight paths restricted to the opposing faction local skip = false if node.faction and FPFaction then if (node.faction == FPFaction.Horde and playerFaction ~= "Horde") @@ -3525,9 +3235,8 @@ function MapSearch:ScanFlightMasters(mapID) x = x, y = y, parentMapID = nodeParentMapID, - -- (x,y) are valid on this map's coordinate system. - -- Used to gate hover previews so an FM scanned in - -- one zone doesn't render a pin in another. + -- coordMapID gates hover previews so an FM scanned + -- in one zone doesn't render a pin in another. coordMapID = mapID, keywords = {"flight", "fly", "taxi", "fp", "flight master"}, } @@ -3540,8 +3249,6 @@ function MapSearch:ScanFlightMasters(mapID) return results end --- Scan flight masters across ALL zone-type maps for global search --- Results are cached since flight point positions don't change mid-session function MapSearch:ScanAllFlightMasters() if cachedAllFlightMasters then return cachedAllFlightMasters end if not C_TaxiMap or not C_TaxiMap.GetTaxiNodesForMap then return {} end @@ -3557,19 +3264,14 @@ function MapSearch:ScanAllFlightMasters() for _, child in ipairs(children) do if child.name then local mt = child.mapType - -- Zones only. A continent scan returns the same nodes - -- as its child zones (just expressed in continent - -- coordinates), and the recursion below already covers - -- every zone, so scanning continents would just - -- produce a duplicate of every FM with a coordMapID - -- that's wrong for whichever map the player is viewing. + -- Zones only: continent scans duplicate child-zone results + -- with a coordMapID wrong for whatever the player views. if mt == Enum.UIMapType.Zone then local nodes = self:ScanFlightMasters(child.mapID) for _, node in ipairs(nodes) do local key = node.name .. "|" .. child.mapID if not seen[key] then seen[key] = true - -- Add zone path prefix like dungeon entrances do local mapInfo = GetMapInfo(child.mapID) node.pathPrefix = mapInfo and mapInfo.name or "" tinsert(allNodes, node) @@ -3595,8 +3297,6 @@ function MapSearch:ScanAllFlightMasters() return allNodes end --- Scan dungeon entrances across ALL zone-type maps for global search. --- Results are cached since instance discovery doesn't change mid-session. function MapSearch:GetStaticLocations(mapID) mapID = mapID or (WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID()) if not mapID then return emptyStaticLocations end @@ -3607,14 +3307,13 @@ function MapSearch:GetStaticLocations(mapID) local results = {} - -- Get built-in static locations local locations = STATIC_LOCATIONS[mapID] if locations then for _, loc in ipairs(locations) do local entry = { name = loc.name, category = loc.category, - icon = loc.icon, -- nil is fine, GetCategoryIcon will handle it + icon = loc.icon, isStatic = true, mapID = mapID, coordMapID = mapID, @@ -3626,8 +3325,7 @@ function MapSearch:GetStaticLocations(mapID) end end - -- Also check EasyFindDevDB for dev/testing (raw POIs from recorder) - -- Skip dev POIs whose names already exist in built-in static locations + -- Dev POIs (recorder) skipped when their name already exists in static. if includeDevPOIs then local staticNames = {} if locations then @@ -3640,7 +3338,7 @@ function MapSearch:GetStaticLocations(mapID) local entry = { name = poi.label, category = poi.category or "unknown", - icon = nil, -- Let category icon be used + icon = nil, isStatic = true, mapID = mapID, coordMapID = mapID, @@ -3673,9 +3371,8 @@ function MapSearch:ScanVignettes(mapID) if info and info.name and not info.isDead then local atlas = info.atlasName if atlas == "VignetteKill" or atlas == "VignetteKillElite" then - -- Keep the Map tab strict to the viewed map. Falling back - -- to the player's map makes "This Zone" show unrelated rares - -- when the user is browsing another zone. + -- Stay strict to the viewed map; player-map fallback would + -- show unrelated rares when the user browses another zone. local pos = GetVignettePosition(guid, mapID) if pos then local entry = { @@ -3707,21 +3404,18 @@ function MapSearch:UpdateRareTracking() local mapID = WorldMapFrame:GetMapID() if not mapID then return end - -- Only show rare pins when viewing the player's zone local playerMapID = GetBestMapForUnit("player") if mapID ~= playerMapID then self:ClearHighlight() return end - -- Clear caches on zone change if mapID ~= rareTrackMapID then wipe(rareTrackCache) wipe(rareDeadGUIDs) rareTrackMapID = mapID end - -- Merge fresh scan into cache, skipping GUIDs confirmed dead local rares = self:ScanVignettes() local activeGUIDs = {} for _, rare in ipairs(rares) do @@ -3732,8 +3426,7 @@ function MapSearch:UpdateRareTracking() end end - -- Prune: if a rare was in range last update but is now gone, it died/despawned. - -- Mark as dead so it won't re-enter the cache from corpse vignettes. + -- Mark rares that left range as dead to block corpse-vignette resurrects. for guid, rare in pairs(rareTrackCache) do if not activeGUIDs[guid] then if rare.inRange then @@ -3743,7 +3436,6 @@ function MapSearch:UpdateRareTracking() end end - -- Build display list from cache local individuals = {} for _, rare in pairs(rareTrackCache) do if rare.x and rare.y then @@ -3766,32 +3458,25 @@ function MapSearch:ScanMapPOIs(mapID) local canvas = WorldMapFrame.ScrollContainer and WorldMapFrame.ScrollContainer.Child if not canvas then return pois end - -- First: Use WoW's API to get Area POIs directly (boats, zeppelins, portals, etc) - -- Only include POIs we can categorize as useful (travel, services) - -- Skip generic area POIs like landmarks, zone markers, events local areaPOIs = GetAreaPOIForMap(mapID) if areaPOIs then for _, poiID in ipairs(areaPOIs) do local poiInfo = GetAreaPOIInfo(mapID, poiID) - -- Skip POIs that are shown on this map but primarily - -- belong to an adjacent map (e.g. a boat dock near a zone - -- border that renders on both zones' maps). Keeps each POI - -- attributed to its home zone only. + -- Skip POIs that render on this map but belong to an adjacent one. if poiInfo and poiInfo.isPrimaryMapForPOI == false then poiInfo = nil end if poiInfo and poiInfo.name then - local category = nil -- Start with nil, only add if we categorize it + local category = nil local poiName = slower(poiInfo.name or "") local desc = slower(poiInfo.description or "") - -- Only categorize POIs we actually want to show if sfind(poiName, "zeppelin") or sfind(poiName, "airship") or sfind(desc, "zeppelin") then category = "zeppelin" elseif sfind(poiName, "boat") or sfind(poiName, "ship") or sfind(poiName, "ferry") or sfind(desc, "boat") then category = "boat" elseif sfind(poiName, "portal") and not sfind(poiName, "dark portal") or sfind(desc, "teleport") then - -- Include portals but not "The Dark Portal" which is a landmark + -- "The Dark Portal" is a landmark, not a real portal. category = "portal" elseif sfind(poiName, "tram") or sfind(desc, "tram") then category = "tram" @@ -3819,14 +3504,13 @@ function MapSearch:ScanMapPOIs(mapID) category = "chromie" end - -- Only add POIs we've explicitly categorized (skips generic landmarks, zone markers, events, etc.) if category then local entry = { name = poiInfo.name, - pin = nil, -- API-based, no pin reference + pin = nil, pinType = category, category = category, - icon = nil, -- Use category icon, not textureIndex (which is an atlas) + icon = nil, isStatic = true, x = poiInfo.position.x, y = poiInfo.position.y, @@ -3837,7 +3521,6 @@ function MapSearch:ScanMapPOIs(mapID) end end - -- Delve entrances via dedicated API (not returned by GetAreaPOIForMap) if GetDelvesForMap then local delveIDs = GetDelvesForMap(mapID) if delveIDs then @@ -3860,7 +3543,6 @@ function MapSearch:ScanMapPOIs(mapID) end end - -- Second: Scan all children of the map canvas for pins for i = 1, select("#", canvas:GetChildren()) do local pin = select(i, canvas:GetChildren()) if pin and pin:IsShown() then @@ -3871,11 +3553,8 @@ function MapSearch:ScanMapPOIs(mapID) end end - -- Dedupe: when both the API path and the canvas-children scan report the - -- same POI (same category at the same coordinates), keep the canvas-scan - -- entry because it carries a live pin reference. SelectResult uses that - -- reference to glow the native icon in place instead of stamping an - -- overlay icon on top. + -- Dedupe: prefer the canvas-scan entry since its live pin reference lets + -- SelectResult glow the native icon in place. local deduped = {} local seenByKey = {} for _, poi in ipairs(pois) do @@ -3898,8 +3577,6 @@ function MapSearch:ScanMapPOIs(mapID) return PreparePOIList(deduped) end --- Generic glow/waypoint atlases stacked on every native pin. Skipped when --- looking for the pin's distinguishing icon. local PIN_SKIP_ATLAS = { ["Waypoint-MapPin-Tracked"] = true, ["Waypoint-MapPin-Untracked"] = true, diff --git a/MapTab.lua b/MapTab.lua index eb074ca..470e92a 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -16,9 +16,6 @@ local GameTooltip = GameTooltip local GameTooltip_Hide = GameTooltip_Hide local C_Timer = C_Timer --- --------------------------------------------------------------------------- --- Tab geometry --- --------------------------------------------------------------------------- local TAB_W, TAB_H = 42, 55 local TAB_ICON_SIZE = 20 local TAB_ICON_GOLD = {1.00, 0.82, 0.00} @@ -32,7 +29,6 @@ local GetAncestorNames = MapUtils.GetAncestorNames local ExpandZoneAbbrev = MapUtils.ExpandZoneAbbrev local BuildFullBreadcrumb = MapUtils.BuildBreadcrumb --- Result row layout local ROW_HEIGHT = 22 local ROW_ICON_SIZE = 17 local SECTION_HEADER_H = 22 @@ -44,10 +40,6 @@ local SECTION_POOL_RETAIN = 12 MapTab.GetTopAncestor = function(_, mapID) return GetTopAncestor(mapID) end --- Children of a map from Blizzard's world hierarchy (non-recursive). --- Used to populate a group on-demand when the user expands a matched --- parent zone that didn't have any query-matched children. Cached per --- session; invalidated on the same events that clear the zone cache. local worldChildrenCache = {} local function GetWorldChildren(mapID) if not mapID or mapID == 0 then return nil end @@ -59,12 +51,10 @@ local function GetWorldChildren(mapID) worldChildrenCache[mapID] = result return result end - -- Use the generic zone icon for every child regardless of - -- Blizzard's mapType. The API marks instanced cities (Dalaran, - -- etc.) as Dungeon, so an mt-based dungeon glyph mis-fires on - -- cities. Synthesized expansions of a continent never contain - -- real dungeons anyway: those live inside zones, not directly - -- under the continent. + -- Blizzard tags instanced cities (Dalaran, etc.) as Dungeon, so an + -- mt-based dungeon glyph mis-fires on them. Synthesized expansions + -- of a continent never contain real dungeons anyway. Use the + -- generic zone icon for every child. local children = GetMapChildrenInfo(mapID, nil, false) if children then for i = 1, #children do @@ -85,10 +75,6 @@ local function GetWorldChildren(mapID) end end end - -- Blizzard returns child maps in an internal order (often by mapID - -- creation, which lines up with expansion-release order, not - -- alphabetical). Sort by name so the displayed list scans cleanly - -- under any expanded continent. table.sort(result, function(a, b) return a.name < b.name end) worldChildrenCache[mapID] = result return result @@ -103,11 +89,7 @@ end local initialized = false local tabFrame --- Forward declaration: RefreshCurrentSearch is defined later but --- captured by closures (e.g. delete button in CreateResultRow) that --- are compiled before its definition. Without this upvalue, Lua --- resolves the name as a global and later fails with "attempt to call --- global 'RefreshCurrentSearch' (a nil value)". +-- Forward declared: closures below capture this before RunSearch defines it. local RefreshCurrentSearch local ReleaseMapTabMemory local panel @@ -120,19 +102,16 @@ end function MapTab:IsEnabled() return IsMapSearchEnabled() end --- The Blizzard QuestMapFrame display mode that was active when our panel --- took over. SetDisplayMode(nil) clears qmf.displayMode, so when the map --- closes + reopens, Blizzard has no mode to restore and the sidebar --- renders blank. We stash the prior mode here and put it back on hide. +-- SetDisplayMode(nil) clears qmf.displayMode, leaving Blizzard with no +-- mode to restore on map close+reopen (renders blank sidebar). Stash the +-- prior mode and put it back on hide. local prevBlizzardDisplayMode local rowPool = {} local headerPool = {} local rowPoolCursor = 1 local headerPoolCursor = 1 --- Ephemeral collapse state: scoped to the current query text. Reset --- when the search text changes so fresh matches always default to --- expanded (auto-expand on parent match). User clicks on +/- within --- the same query keep their effect until the text changes. +-- Collapse state scoped to the current query text. Reset on text change +-- so fresh matches default to expanded (auto-expand on parent match). local sessionCollapsed = {} local sessionCollapsedQuery = nil local lastQueryGen = 0 @@ -173,34 +152,19 @@ local function SchedulePendingSearch(editBox, typed, grew) pendingSearchEditBox = editBox pendingSearchFrame:Show() end --- Tracks the query that produced the currently-displayed results. The --- search box text can diverge from this (e.g. clicking a recent-search --- row runs a search without populating the box), so result-click and --- Enter-press paths read this instead of the editbox to push the right --- string onto the recents list. +-- Search box text can diverge from the active query (clicking a recent +-- row runs a search without populating the box). Result-click and Enter +-- paths read this instead of the editbox. local currentQuery = "" --- Text of the last rendered search. Used to decide whether a re-render --- is a refresh (keep scroll position) or a brand-new query (reset to --- the top). Pin toggles, filter changes, and group collapses all hit --- the refresh path because they re-run RunSearch with unchanged text. local lastRenderedQuery --- Keyboard navigation state. navRowIndex 0 = nothing highlighted; 1..N = --- index into visibleNavRows (array of frames in display order, rows and --- group headers interleaved). Rebuilt on every RenderRows. local navRowIndex = 0 local visibleNavRows = {} -local navFrame -- created lazily by EnsureNavFrame() --- Hold-to-step controller for nav keys; assigned by EnsureNavFrame. --- Declared up here so the editbox OnKeyUp hook (defined later in --- CreateSearchBox) can reach it. SearchBoxTemplate consumes OS-level --- auto-repeat OnKeyDown events, so unlike UI.lua's plain EditBox we --- can't rely on the OS to walk the list while a key is held. The --- ticker fires the action at an accelerating cadence on its own. +local navFrame +-- SearchBoxTemplate eats OS-level auto-repeat OnKeyDown, so unlike a +-- plain EditBox we can't rely on the OS to walk the list while a key is +-- held. The ticker fires the action at an accelerating cadence. local navKeyRepeat --- --------------------------------------------------------------------------- --- Tab select glow + icon tint helper --- --------------------------------------------------------------------------- local function FindAtlasTexture(frame, atlas) if not frame or not frame.GetRegions then return nil end for i = 1, frame:GetNumRegions() do @@ -238,25 +202,16 @@ local function RefreshSelectGlows() end end --- --------------------------------------------------------------------------- --- Panel show / hide: sibling content-panel behavior. --- Clicking our tab Hide()'s the three Blizzard content panels (Quests, --- Events, MapLegend) and Show()'s ours. Clicking a Blizzard tab: their own --- OnMouseUp Show()'s the right panel; we just hide ours. --- --------------------------------------------------------------------------- local function ShowOurPanel() if not IsMapSearchEnabled() then return end local qmf = _G["QuestMapFrame"] if not qmf or not panel then return end selectedIsOurs = true lastSelectedWasOurs = true - -- Measure Blizzard's SearchBox BEFORE we hide QuestsFrame, so its - -- GetLeft/GetRight return live values. Cache is reused for - -- subsequent shows if Blizzard's panel isn't visible later. + -- Must measure before hiding QuestsFrame so GetLeft/GetRight are live. if panel.MeasureBlizzardSearch then panel.MeasureBlizzardSearch() end - -- Call SetDisplayMode() with nil so QuestMapFrame formally leaves - -- its current official mode. This is the pattern LibWorldMapTabs - -- uses. Without it, clicking a Blizzard tab afterwards is a no-op + -- SetDisplayMode(nil) makes QuestMapFrame formally leave its current + -- mode. Without this, clicking a Blizzard tab afterwards is a no-op -- (same-mode transition) and the panel stays hidden. if qmf.SetDisplayMode then prevBlizzardDisplayMode = qmf.displayMode @@ -275,9 +230,9 @@ local function ShowOurPanel() if qmf.MapLegend then qmf.MapLegend:Hide() end end - -- LibWorldMapTabs (used by WorldQuestTab and similar) doesn't react - -- to a nil SetDisplayMode, so its content frames stay visible and - -- its tabs stay checked. Hide them via the lib's public API. + -- Third-party tab frameworks don't react to nil SetDisplayMode, so + -- their content frames stay visible and tabs stay checked. Tell them + -- via the public API. if LibStub then local ok, lwmt = pcall(LibStub, "LibWorldMapTabs", true) if ok and lwmt and lwmt.SetDisplayMode then @@ -298,11 +253,9 @@ local function HideOurPanel() if panel.outer then panel.outer:Hide() else panel:Hide() end if panel.searchBox then panel.searchBox:ClearFocus() end end - -- Restore Blizzard's display mode if we cleared it. If a Blizzard tab - -- was clicked, qmf.displayMode is already non-nil (the new tab set - -- it), so we leave that alone. Only restore when displayMode is nil, - -- which happens when the map simply closed while our panel was - -- active. Without this, reopening the map shows a blank sidebar. + -- Only restore when displayMode is nil (map closed while our panel + -- was active). If a Blizzard tab was clicked, qmf.displayMode is + -- already non-nil; leave it alone. local qmf = _G["QuestMapFrame"] if qmf and qmf.SetDisplayMode and qmf.displayMode == nil then local restore = prevBlizzardDisplayMode or qmf.QuestsFrame @@ -313,10 +266,6 @@ local function HideOurPanel() end end prevBlizzardDisplayMode = nil - -- Tear down any in-flight hover preview when the panel closes so the - -- zone outline / pin / saved-state don't survive into another tab. - -- Mirrors EndHoverPreview's full cleanup rather than just the - -- _previewing flag flip + pin clear it used to do. if ns.MapSearch and ns.MapSearch._previewing then if ns.MapSearch.EndHoverPreview then ns.MapSearch:EndHoverPreview() @@ -344,17 +293,12 @@ local function ShowPopup(isPinned, onPin, onGuide, onAddAlias) }) end --- --------------------------------------------------------------------------- --- Result rows (pooled) --- --------------------------------------------------------------------------- local function CreateResultRow(parent) local row = CreateFrame("Button", nil, parent) row:SetHeight(ROW_HEIGHT) - -- Activate on press, not release. With ButtonUp registration, WoW's - -- internal focus-transition machinery (when the search box is - -- focused) absorbs the mouseUp before OnClick fires, requiring a - -- second click to actually activate the row. Down registration runs - -- the action immediately on press so focus changes can't interfere. + -- Click on press, not release: with ButtonUp, WoW's focus-transition + -- machinery (when the search box is focused) absorbs the mouseUp + -- before OnClick fires, requiring a second click. row:RegisterForClicks("LeftButtonDown", "RightButtonUp") row:EnableMouse(true) row:HookScript("OnMouseDown", function() @@ -368,9 +312,6 @@ local function CreateResultRow(parent) icon:SetPoint("LEFT", row, "LEFT", 4, 0) row.icon = icon - -- Delete (X) button for recent-search rows only. Hidden by default; - -- shown on hover when data.isRecentSearch is true. Click removes - -- that query from EasyFind.db.mapTabRecentSearches. local deleteBtn = Utils.CreateClearButton(row, nil) deleteBtn:ClearAllPoints() deleteBtn:SetPoint("RIGHT", row, "RIGHT", -6, 0) @@ -389,9 +330,6 @@ local function CreateResultRow(parent) RefreshCurrentSearch() end) deleteBtn:SetScript("OnLeave", function(self) - -- Hide only when the mouse has truly left both the row and - -- this button. Without this, moving from button back onto the - -- row would leave a stuck visible X (row OnEnter already fired). if not row:IsMouseOver() then self:Hide() end end) row.deleteBtn = deleteBtn @@ -403,11 +341,8 @@ local function CreateResultRow(parent) text:SetWordWrap(false) row.text = text - -- Single highlight texture covers mouse hover AND keyboard - -- selection via LockHighlight, so both paths look identical: - -- Blizzard's tapered quest-log row glow atlas. Mouse hover shows - -- it automatically; UpdateNavHighlight calls LockHighlight on the - -- keyboard-selected row to pin it on. + -- One highlight texture covers mouse hover and keyboard selection + -- (via LockHighlight) so both look identical. row:SetHighlightAtlas("QuestLog-quest-glow-yellow") local hl = row:GetHighlightTexture() if hl then @@ -419,33 +354,28 @@ local function CreateResultRow(parent) end local function SetRowIcon(row, data) - -- Reset size on every placement. Pooled rows carry whatever size - -- their last occupant set (recent-search rows shrink to match text - -- height), so without this reset a recycled row keeps the shrunken - -- icon when it's reused for a regular result. + -- Pooled rows carry their last occupant's size (recent-search rows + -- shrink to text height). Reset on every placement. row.icon:SetSize(ROW_ICON_SIZE, ROW_ICON_SIZE) local icon = data.icon -- Dungeon/raid/delve/rare entries leave data.icon nil and rely on - -- the category icon. Resolve it here so SetRowIcon renders the - -- same texture the MapSearch dropdown does. + -- the category icon; resolve it so SetRowIcon matches the MapSearch + -- dropdown texture. if icon == nil and data.category and ns.MapSearch and ns.MapSearch.GetCategoryIcon then icon = ns.MapSearch.GetCategoryIcon(data.category) end Utils.SetIconTexture(row.icon, icon) end --- Timestamp of the most recent keystroke in the search box. Hover --- previews are suppressed for a short window afterwards, because every --- keystroke re-renders the result rows and WoW fires OnEnter for any --- row that appears under a stationary cursor, which was showing preview --- pins and highlighting the map without the user moving their mouse. +-- Suppress hover previews briefly after every keystroke: re-renders fire +-- OnEnter for rows that appear under a stationary cursor, which would +-- spuriously preview pins as the user types. local lastTypeTime = 0 local HOVER_PREVIEW_TYPING_GUARD = 0.3 --- fromKeyboard = true: bypass the typing guard. Keyboard navigation is --- explicit user intent so we always preview, unlike stray OnEnter --- events that fire when a re-rendered row lands under a stationary --- cursor during typing. +-- fromKeyboard bypasses the typing guard: keyboard nav is explicit user +-- intent, unlike stray OnEnter when a rendered row lands under a +-- stationary cursor during typing. local function HoverPreview(data, fromKeyboard) if not fromKeyboard and GetTime() - lastTypeTime < HOVER_PREVIEW_TYPING_GUARD then return end local MapSearch = ns.MapSearch @@ -467,17 +397,15 @@ RefreshCurrentSearch = function() MapTab:RunSearch(sb.GetTypedText and sb:GetTypedText() or sb:GetText() or "") end --- directOverride: optional bool forwarded to MapSearch:SelectResult. --- Passed as `false` by the right-click Guide menu to force breadcrumb/ --- teaching mode regardless of the user's default left-click setting. +-- directOverride is forwarded as `false` by the right-click Guide menu to +-- force breadcrumb/teaching mode regardless of the user's default +-- left-click setting. local function TriggerResultSelect(data, directOverride) local MapSearch = ns.MapSearch if MapSearch and MapSearch.SelectResult then MapSearch:SelectResult(data, directOverride) end - -- Intentionally do NOT re-render or hide results here. Unlike UI - -- search, the MapTab keeps its list visible after activation so - -- the user can click/preview adjacent results without losing the - -- search state. The OnMapChanged hook handles the "This Zone" - -- label refresh when navigation actually changes the map. + -- Don't re-render: MapTab keeps its list visible after activation so + -- the user can click/preview adjacent results. OnMapChanged handles + -- the "This Zone" label refresh. end local function RowOnClick(row, button) @@ -487,11 +415,6 @@ local function RowOnClick(row, button) if data.isRecentSearch then if data.query and panel and panel.searchBox then - -- Populate the search box exactly as if the user had typed - -- and submitted the recent query. OnTextChanged fires from - -- SetText and runs the search; the editbox stays unfocused - -- so the player can keep moving with WASD. Crucially the - -- clear button now has text to act on. panel.searchBox:SetText(data.query) panel.searchBox:ClearFocus() end @@ -507,9 +430,6 @@ local function RowOnClick(row, button) return end - -- Left-click on a real result: commit the current query to recents, - -- then navigate. Ensures recents only accumulate on intentional use - -- (Enter key or result click), not on incidental focus loss. MapTab:PushRecentSearch(currentQuery) TriggerResultSelect(data) end @@ -531,8 +451,6 @@ local function RowOnEnter(row) end end local function RowOnLeave(row) - -- Don't hide the delete button while the cursor is actually on it - -- (child-frame mouse capture would otherwise flicker the button). if row and row.deleteBtn and not row.deleteBtn:IsMouseOver() then row.deleteBtn:Hide() end @@ -562,7 +480,6 @@ local function ReleaseAllRows() for i = 1, #rowPool do rowPool[i]:Hide(); rowPool[i].data = nil end end --- Section label (non-clickable): "In This Zone" / "Across Azeroth". local function CreateSectionLabel(parent) local hdr = CreateFrame("Frame", nil, parent) hdr:SetHeight(SECTION_HEADER_H) @@ -604,17 +521,12 @@ local function ReleaseAllSectionLabels() for i = 1, #sectionLabelPool do sectionLabelPool[i]:Hide() end end --- Group header mirrors the UI search's quest-log tab style: QuestLog-tab --- atlas background, hover overlay, right-side +/- toggle button. Left --- click on the body navigates to the header's linked zone (when one --- was attached); clicking the +/- button toggles collapse. local GROUP_HEADER_H = 28 local function CreateGroupHeader(parent) local hdr = CreateFrame("Button", nil, parent) hdr:SetHeight(GROUP_HEADER_H) - -- See CreateResultRow: activate on press to bypass focus-transition - -- mouseUp absorption. + -- See CreateResultRow: press to bypass focus-transition absorption. hdr:RegisterForClicks("LeftButtonDown", "RightButtonUp") hdr:HookScript("OnMouseDown", function() if panel and panel.searchBox and panel.searchBox.HasFocus and panel.searchBox:HasFocus() then @@ -645,8 +557,6 @@ local function CreateGroupHeader(parent) panel.searchBox:ClearFocus() end end) - -- Expand hit rect so near-miss clicks still register on the toggle - -- instead of falling through to the header body's navigate action. toggleBtn:SetHitRectInsets(-10, -10, -6, -6) local toggleBtnBg = toggleBtn:CreateTexture(nil, "ARTWORK") toggleBtnBg:SetAllPoints() @@ -667,9 +577,6 @@ local function CreateGroupHeader(parent) self.btnBg:Hide() if not hdr:IsMouseOver() then hdr.hoverOverlay:Hide() - -- Mirror the header body's OnLeave: when neither the toggle - -- nor the header itself is hovered, the user has fully left - -- the group row and the hover preview should clear. ClearHoverPreview() end end) @@ -686,10 +593,6 @@ local function CreateGroupHeader(parent) hdr:SetScript("OnEnter", function(self) self.hoverOverlay:Show() self.label:SetTextColor(0.90, 0.88, 0.85, 1.0) - -- Group headers represent zone results (or pinned-zone roots) and - -- need the same hover preview as plain rows. Without this, hovering - -- a "Durotar" header while viewing Kalimdor produced no zone-area - -- highlight even though hovering a leaf row did. if self.navigateData then HoverPreview(self.navigateData) end end) hdr:SetScript("OnLeave", function(self) @@ -705,12 +608,9 @@ local function CreateGroupHeader(parent) return hdr end --- onToggle: called when the +/- button is clicked. Defaults to --- mutating sessionCollapsed[groupKey] for query-scoped collapse state; --- pinned-parent headers override with a callback that mutates the --- pin's stored `collapsed` flag instead. --- onRightClick: called when the header body is right-clicked. Used to --- open the pin/unpin popup for the header's navigateData. +-- onToggle defaults to mutating sessionCollapsed[groupKey] (query-scoped +-- collapse state); pinned-parent headers override with a callback that +-- mutates the pin's stored `collapsed` flag instead. local function AcquireGroupHeader(parent, labelText, groupKey, count, collapsed, navigateData, hasChildren, onToggle, onRightClick) local hdr = headerPool[headerPoolCursor] if not hdr then @@ -722,8 +622,6 @@ local function AcquireGroupHeader(parent, labelText, groupKey, count, collapsed, headerPoolCursor = headerPoolCursor + 1 hdr.label:SetText(labelText or "") hdr.toggleBtn.icon:SetAtlas(collapsed and "QuestLog-icon-expand" or "QuestLog-icon-shrink") - -- Hide the toggle button entirely when there are no children to - -- expand. Clicking it would otherwise look broken. hdr.toggleBtn:SetShown(hasChildren and true or false) hdr.groupKey = groupKey hdr.navigateData = navigateData @@ -742,9 +640,6 @@ local function AcquireGroupHeader(parent, labelText, groupKey, count, collapsed, end end) hdr:SetScript("OnClick", function(self, button) - -- Body click ONLY navigates, never toggles. Skip entirely if - -- the toggle button is under the cursor (WoW has occasionally - -- surprising click routing with nested clickable children). if self.toggleBtn and self.toggleBtn:IsMouseOver() then return end if button == "RightButton" then if onRightClick then onRightClick() end @@ -828,11 +723,8 @@ ReleaseMapTabMemory = function(trimFrames) end end --- --------------------------------------------------------------------------- --- Render --- --------------------------------------------------------------------------- --- Returns the stored SavedVariables pin list directly (not a copy) so --- mutations to fields like `collapsed` on a pinned parent persist. +-- Returns the SavedVariables pin list directly (not a copy) so mutations +-- to fields like `collapsed` on a pinned parent persist. local function BuildPinnedSection() local pins = EasyFind.db.pinnedMapItems if not pins or #pins == 0 then return nil end @@ -859,17 +751,16 @@ local function GroupBySharedParent(results) order[#order + 1] = g end if r.name == groupName then - -- Result IS the parent zone itself: attach as the header's - -- navigation target instead of rendering a duplicate row. + -- Result is the parent zone: attach as the header's nav + -- target instead of rendering a duplicate row. g.navigateData = r else g.items[#g.items + 1] = r end end end - -- Synthesize navigateData for groups whose parent zone wasn't itself - -- in the result set (e.g. "Northrend" filtered from local as a - -- Continent). The header still needs something to click-navigate to. + -- Synthesize navigateData for groups whose parent zone wasn't in the + -- result set. The header still needs something to click-navigate to. for _, e in ipairs(order) do if e.type == "group" and not e.navigateData and e.ancestorMapID then e.navigateData = { @@ -881,24 +772,19 @@ local function GroupBySharedParent(results) } end end - -- Demotion rule: a group keeps its header whenever it has a parent - -- zone (navigateData), even with zero children in the current - -- result set, so a continent with children in the world hierarchy - -- never displays as a solitary flat row. Single-child groups - -- without a matching parent still collapse to flat so standalone - -- leaves don't gain a useless wrapping header. + -- A group keeps its header whenever it has a parent zone, so a + -- continent never displays as a solitary flat row. Single-child + -- groups without a matching parent collapse to flat. for i = 1, #order do local e = order[i] if e.type == "group" and not e.navigateData and #e.items == 1 then order[i] = { type = "flat", data = e.items[1] } end end - -- Second-level: within each continent group, sub-bucket items by the - -- direct-child zone of the continent that contains them. A real zone - -- result (category="zone") becomes the subgroup header; everything - -- else (FMs, vendors, etc.) becomes children indented under it. - -- Items whose parent chain doesn't pass through any direct child of - -- the continent stay loose at the continent level. + -- Within each continent group, sub-bucket items by the direct-child + -- zone of the continent containing them. Zone results become subgroup + -- headers; everything else gets indented under them. Items that don't + -- map to a direct child stay loose at the continent level. for _, e in ipairs(order) do if e.type == "group" and e.items and #e.items > 0 and e.ancestorMapID then local subgroups, subgroupOrder, looseItems = {}, {}, {} @@ -921,10 +807,6 @@ local function GroupBySharedParent(results) looseItems[#looseItems + 1] = item end end - -- Any subgroup with at least one child gets a collapsible - -- header (synthesized if the zone itself wasn't matched). - -- A zone match with zero children renders as a plain loose - -- row. No point in a collapsible header for nothing. local keptOrder = {} for _, sub in ipairs(subgroupOrder) do if #sub.items == 0 and sub.headerData then @@ -954,11 +836,8 @@ end local function RenderRows(scrollChild, pinned, localEntries, globalEntries, recentList) ReleaseAllRows() ReleaseAllHeaders() - -- Reset keyboard-nav selection on every render. The underlying row - -- pool is recycled so a stale frame ref in visibleNavRows could - -- silently point at a frame that's been repositioned or released. - -- Also release navFrame's keyboard capture so we don't keep - -- swallowing keys when there's no highlighted row. + -- Reset nav selection: row pool is recycled so a stale frame ref in + -- visibleNavRows would point at a repositioned/released frame. navRowIndex = 0 wipe(visibleNavRows) if navFrame then @@ -989,9 +868,6 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece end do local pathText = FormatPathPrefix(data.pathPrefix) or "" - -- Under a group header, strip the redundant group prefix so - -- a Dalaran row under "Northrend" shows only its immediate - -- parent without repeating the group name. if groupName and pathText ~= "" then local prefix = groupName .. " > " if pathText:sub(1, #prefix) == prefix then @@ -1000,9 +876,6 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece pathText = "" end end - -- Collapse to just the immediate parent name. Breadcrumb - -- chains ("Northrend > Crystalsong Forest") become - -- single-segment ("Crystalsong Forest") for a quieter row. if pathText ~= "" then local last = pathText:match("[^>]+$") if last then pathText = last:gsub("^%s+", ""):gsub("%s+$", "") end @@ -1038,11 +911,9 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece y = y + GROUP_HEADER_H end - -- Right-click handler for regular result group headers: open the - -- pin popup bound to the header's zone (navigateData). Pinning a - -- parent here promotes it into the Pinned section on refresh, - -- carrying over the header's current collapsed state so the pinned - -- copy opens the same way the user was viewing it. + -- Pinning a parent here promotes it into the Pinned section, + -- carrying its current collapsed state so the pinned copy opens + -- the way the user was viewing it. local function headerRightClick(navigateData, capturedCollapsed) if not navigateData then return end local MapSearch = ns.MapSearch @@ -1065,14 +936,12 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece if e.type == "flat" then placeRow(e.data, 0, nil) elseif e.type == "version" then - -- Version group: same name, multiple mapIDs (e.g. Dalaran - -- exists in Northrend and Broken Isles). Default-collapsed - -- so the bare name shows; expanding lists each variant. - -- Their pathPrefix already disambiguates ("Crystalsong - -- Forest" / "Broken Isles"). Header navigates to newest. + -- Same name, multiple mapIDs (Dalaran exists in Northrend + -- and Broken Isles). Default-collapsed; header navigates + -- to newest mapID. local groupKey = sectionKey .. ":version:" .. e.name local stored = collapsedDb[groupKey] - local collapsed = stored ~= false -- nil → default collapsed + local collapsed = stored ~= false local capturedCollapsed = collapsed local onToggle = function() collapsedDb[groupKey] = not capturedCollapsed and true or false @@ -1089,17 +958,11 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece else local groupKey = sectionKey .. ":" .. e.name local collapsed = collapsedDb[groupKey] == true - -- Two cases for what shows under a group: - -- (1) Parent itself matches the query (real, not - -- synthesized, navigateData): show ALL world- - -- hierarchy children. "I asked for Eastern - -- Kingdoms, give me the whole continent." - -- (2) Parent didn't match: group exists only because - -- multiple children matched (or one child + a - -- synthesized parent header for click-to-navigate): - -- show just the matched children. Surfacing every - -- sibling under EK because the user typed "fp" - -- (matching Founder's Point inside EK) was wrong. + -- If the parent itself matched (real, not synthesized + -- navigateData), show ALL world-hierarchy children. + -- Otherwise show just the matched children: surfacing + -- every sibling under a continent because one child + -- matched would be wrong. local items local usingWorldChildren = false local parentMatched = e.navigateData and not e.navigateData.synthesized @@ -1140,14 +1003,10 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece end else -- Group duplicate-named children into nested - -- version sub-groups. Blizzard's hierarchy keeps - -- multiple mapIDs sharing a display name (Arathi - -- Highlands warfront variants, old Dalaran Crater - -- + revisions, etc.). Show one collapsible header - -- per name; expanding lists each variant tagged - -- by mapID so they're distinguishable. Default- - -- collapsed; clicking the header navigates to - -- the highest-mapID (newest) variant. + -- version sub-groups (Arathi Highlands warfront + -- variants, Dalaran Crater + revisions, etc.). + -- One collapsible header per name; default- + -- collapsed; header navigates to highest mapID. local byName, order = {}, {} for _, item in ipairs(items) do local n = item.name or "" @@ -1181,10 +1040,8 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece true, subToggle, subRClick, 18) if not subCollapsed then for _, variant in ipairs(variants) do - -- Clone so we don't mutate the - -- per-session worldChildrenCache - -- entry. pathPrefix flows through - -- placeRow's gray-suffix path. + -- Clone: mutating worldChildrenCache + -- entries would poison the cache. placeRow({ name = variant.name, category = variant.category, @@ -1226,9 +1083,6 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece if row.deleteBtn then row.deleteBtn:Hide() end row.text:SetTextColor(0.85, 0.85, 0.85) row.text:SetText(query) - -- Match the magnifying glass to the text's rendered - -- height so the icon's top/bottom line up with the text - -- rather than overshooting like the result-row icons. local textH = row.text:GetStringHeight() or 0 if textH < 8 then textH = 12 end row.icon:SetSize(textH, textH) @@ -1283,10 +1137,9 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece scrollChild:SetHeight(math.max(1, y + 4)) end --- mapType values that, when a ZONE result belongs to them, should not --- appear in the "This Zone" local section. These are too coarse to be --- meaningfully "local" (e.g. when the user is on the Azeroth world map, --- Northrend is technically a direct child but is its own continent). +-- ZONE results with these mapTypes are too coarse to be "local" +-- (continents are direct children of the Azeroth world map but aren't +-- meaningfully "in this zone"). local EXCLUDE_FROM_LOCAL_MAPTYPES = {} if Enum and Enum.UIMapType then EXCLUDE_FROM_LOCAL_MAPTYPES[Enum.UIMapType.Cosmic] = true @@ -1330,11 +1183,8 @@ local function ResultIsOnViewedMap(data, viewedMapID) or data.parentMapID == viewedMapID end --- Filter by mapTabFilters (category bucket) and strip duplicates that --- already appear in a previous list. Preserves result ordering from --- BuildResults. When isLocal is true, also excludes continent-level and --- broader zone results so the "This Zone" section doesn't list entire --- continents as local content. +-- isLocal=true also excludes continent-level zone results so "This Zone" +-- doesn't list entire continents. local function FilterAndDedupe(results, seen, isLocal) local MapSearch = ns.MapSearch local getBucket = MapSearch and MapSearch.GetFilterBucket @@ -1347,12 +1197,9 @@ local function FilterAndDedupe(results, seen, isLocal) local excludeLocal = isLocal and r.isZone and r.zoneMapType and EXCLUDE_FROM_LOCAL_MAPTYPES[r.zoneMapType] if not excludeLocal then - -- FMs are now scanned both locally (current map) and globally - -- (every zone). The local scan uses the viewed map's coords - -- while the global scan uses each home zone's own coords, - -- so the same FM has different (x,y) in each set. Key FMs - -- by name alone so the local pass claims it first and the - -- global pass dedupes cleanly. + -- FMs are scanned both locally and globally with different + -- (x,y) per scan. Key by name so the local pass claims + -- first and the global pass dedupes cleanly. local key = ResultDedupeKey(r) if not seen[key] then seen[key] = true @@ -1367,11 +1214,9 @@ local function FilterAndDedupe(results, seen, isLocal) return out end --- Does a single token match a POI via any available facet? Token must --- already be lowercase. Checks name, keywords, category (with plural/ --- singular flex), pathPrefix, ancestor zone names, and (for ancestors) --- falls back to expanding the token through ZONE_ABBREVIATIONS so "nr" --- resolves against "northrend". +-- Token must be lowercase. Checks name, keywords, category (with plural +-- flex), pathPrefix, ancestor names; falls back to abbrev expansion so +-- "nr" resolves against "northrend". local function POIMatchesToken(poi, t) if poi.name and poi.name:lower():find(t, 1, true) then return true end if poi.keywords then @@ -1400,10 +1245,9 @@ local function POIMatchesToken(poi, t) return false end --- Multi-token search: for each token, call BuildResults to get a broad --- candidate set, union them by identity, then keep only POIs where every --- token matches some facet. Enables queries like "northrend raid", --- "org fp", "dalaran bank" where each word narrows the result set. +-- Per token, build a candidate set; union by identity; keep only POIs +-- where every token matches some facet. Enables "northrend raid", +-- "org fp", "dalaran bank". local function BuildMultiTokenResults(tokens, isGlobal) local MapSearch = ns.MapSearch if not MapSearch or not MapSearch.BuildResults then return {} end @@ -1453,9 +1297,8 @@ function MapTab:RunSearch(text) aliasMatches = ns.Aliases:GetMatches(text:lower()) end - -- Preserve scroll when the query is unchanged (refresh path: pin - -- toggles, group collapse, filter changes). Reset to the top on a - -- fresh query so new result sets always start from the first row. + -- Preserve scroll on refresh paths (pin toggle, collapse, filter). + -- Reset to top on a fresh query. local preserveScroll = scrollFrame and text == lastRenderedQuery local savedScroll = preserveScroll and scrollFrame:GetVerticalScroll() or 0 lastRenderedQuery = text @@ -1465,10 +1308,8 @@ function MapTab:RunSearch(text) scrollFrame:SetVerticalScroll(math.min(savedScroll, maxScroll)) end - -- Reset ephemeral collapse state when the query text changes so - -- each new search starts with every matched parent auto-expanded. - -- Same-text refreshes (e.g. toggle button → RefreshCurrentSearch) - -- preserve the state. + -- Reset collapse state on text change so each new search starts with + -- every matched parent auto-expanded. Same-text refreshes preserve. if text ~= sessionCollapsedQuery then wipe(sessionCollapsed) sessionCollapsedQuery = text @@ -1478,8 +1319,6 @@ function MapTab:RunSearch(text) local showRecent = EasyFind.db.mapTabShowRecent local recentList = showRecent and EasyFind.db.mapTabRecentSearches local limit = MapTab.GetRecentLimit and MapTab.GetRecentLimit() or 3 - -- Respect the tunable display cap even if the saved list got - -- larger (e.g., user shrank the cap after it was already full). if recentList and #recentList > limit then local trimmed = {} for i = 1, limit do trimmed[i] = recentList[i] end @@ -1503,17 +1342,14 @@ function MapTab:RunSearch(text) ReleaseAllRows(); ReleaseAllHeaders(); return end - -- Split query into whitespace-separated tokens; multi-token queries - -- switch to per-token matching with ancestor awareness so "northrend - -- raid" means "raids under Northrend" rather than a literal name - -- match on the full string. + -- Multi-token queries switch to per-token matching with ancestor + -- awareness so "northrend raid" means "raids under Northrend". local tokens = {} for tok in text:gmatch("%S+") do tokens[#tokens + 1] = tok end local multiToken = #tokens > 1 - -- BuildResults returns a reference to a reusable module-level - -- table. The second call wipes it before refilling, so we must - -- shallow-copy the first result set before calling again. + -- BuildResults returns a reusable module-level table; the second + -- call wipes it. Shallow-copy before calling again. local seen = {} local localRaw if multiToken then @@ -1524,10 +1360,7 @@ function MapTab:RunSearch(text) for i = 1, #localRawRef do localRaw[i] = localRawRef[i] end end if myGen ~= lastQueryGen then return end - -- Local-first dedup (so a POI that's truly in the current zone - -- wins local ownership over global). Continent-level zone - -- results are filtered out of local via isLocal=true so they - -- don't pollute "This Zone" on a world/continent map. + -- Local-first dedup so a POI in the current zone wins local ownership. local localFiltered = FilterAndDedupe(localRaw, seen, true) local localEntries local globalRaw @@ -1558,12 +1391,10 @@ function MapTab:RunSearch(text) end localEntries = GroupBySharedParent(localFiltered) - -- A continent/world-type zone match (e.g. "Eastern Kingdoms" while - -- viewing EK) is dropped from local by EXCLUDE_FROM_LOCAL_MAPTYPES, - -- so its "This Zone" group ends up with synthesized navigateData - -- and parentMatched stays false - auto-expand never fires. Promote - -- the synthesized header back to the real result so the renderer's - -- parentMatched check sees it. + -- A continent-type zone match (e.g. EK while viewing EK) gets + -- dropped from local by EXCLUDE_FROM_LOCAL_MAPTYPES, leaving its + -- group with synthesized navigateData so auto-expand never fires. + -- Promote the synthesized header back to the real result. for i = 1, #localRaw do local r = localRaw[i] if r and r.isZone and r.zoneMapID then @@ -1577,10 +1408,9 @@ function MapTab:RunSearch(text) end end - -- Pull duplicate-named zones out of globalFiltered into version groups. - -- Names matching 2+ entries collapse into one header that lists every - -- variant (highest-mapID first as a "newest" heuristic). The header - -- starts collapsed; clicking it navigates to the newest variant. + -- Duplicate-named zones collapse into one header that lists every + -- variant (highest mapID first as "newest"). Header starts collapsed + -- and navigates to the newest variant. local versionGroups do local byName = {} @@ -1627,10 +1457,8 @@ function MapTab:RunSearch(text) end end - -- Drop any global group whose continent matches the currently-viewed - -- map. The 'this zone' section already covers everything inside - -- that continent, so surfacing it again under 'across the world' is - -- pure redundancy. Belt-and-suspenders against any dedup miss. + -- 'This zone' already covers the viewed continent, so drop any + -- global group whose continent matches it. local viewedMapID = WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID() if viewedMapID then for i = #globalEntries, 1, -1 do @@ -1641,13 +1469,9 @@ function MapTab:RunSearch(text) end end - -- Stash top N result names (locals first, then globals) for - -- autocomplete. Ghost/Tab picks the first name whose lowercase - -- prefix matches the typed text, so a fuzzy-winning non-prefix - -- match doesn't prevent a slightly-lower-scored prefix match from - -- being suggested. Covers cases like typing "dragon i" when the - -- top scorer is "Dragonblight" (no space) but "Dragon Isles" is - -- further down. + -- Ghost/Tab picks the first NAME whose lowercase prefix matches the + -- typed text, so a fuzzy-winning non-prefix doesn't shadow a slightly + -- lower-scored prefix match. Stash a window of candidates here. local CANDIDATE_CAP = 12 local candidates = {} if localFiltered then @@ -1675,19 +1499,11 @@ function MapTab:RunSearch(text) restoreScroll() end --- --------------------------------------------------------------------------- --- Keyboard navigation helpers. Wiring: the search box captures arrow / --- Alt+J/K / Enter / Esc while focused and consumes them, falling back --- to typing for anything else. When the user navigates down from the --- search box, focus drops onto navFrame, which captures j/k/Enter/Esc --- directly so the user can keep navigating without reclaiming keyboard --- focus on the editbox. Re-typing or pressing Esc hands focus back. --- --------------------------------------------------------------------------- - --- Forward declaration: EnsureNavFrame's OnKeyDown closure captures --- HandleNavKey as an upvalue, but HandleNavKey is defined below because --- it in turn references SetNavRowIndex / MoveNavSelection that are --- declared between the two. +-- Search box captures arrow / Alt+J/K / Enter / Esc while focused and +-- falls back to typing. Navigating down hands focus to navFrame, which +-- captures j/k/Enter/Esc so the user can keep moving without reclaiming +-- the editbox. Re-typing or Esc hands focus back. + local HandleNavKey local function EnsureNavFrame() @@ -1717,9 +1533,6 @@ local function UpdateNavHighlight() local f = visibleNavRows[i] if f then local selected = (i == navRowIndex) - -- Group headers (hoverOverlay present): mirror mouse hover - -- exactly: show the same hoverOverlay atlas and brighten - -- the label. Same pattern UI.lua uses for its headerTab. if f.hoverOverlay then f.hoverOverlay:SetShown(selected) if f.label then @@ -1735,9 +1548,6 @@ local function UpdateNavHighlight() end end elseif f.LockHighlight then - -- Leaf row: reuse the button's built-in hover texture - -- (set via SetHighlightAtlas in CreateResultRow) so - -- keyboard and mouse share one texture. if selected then f:LockHighlight() else f:UnlockHighlight() end end end @@ -1746,10 +1556,6 @@ local function UpdateNavHighlight() local f = visibleNavRows[navRowIndex] if f then Utils.ScrollToButton(panel.scrollFrame, f) end end - -- Map preview mirrors the mouse-hover path: whichever row the - -- user is focused on (keyboard or mouse) pops its waypoint/zone - -- highlight on the world map. Keyboard passes fromKeyboard=true to - -- bypass the typing-guard that suppresses spurious OnEnter events. if navRowIndex > 0 then local f = visibleNavRows[navRowIndex] local data = f and (f.data or f.navigateData) @@ -1767,12 +1573,8 @@ local function SetNavFrameCapture(on) local nf = EnsureNavFrame() if not nf then return end Utils.SafeCallMethod(nf, "EnableKeyboard", on and true or false) - -- Releasing keyboard capture defuses any in-flight hold-to-step - -- ticker. Without this, a missed OnKeyUp (e.g. user clicked the - -- map mid-hold so the editbox+navFrame both lost keyboard input - -- before the release fired) would leave repeatActive on, and the - -- ticker would resume firing the move action the next time - -- navFrame became visible. + -- A missed OnKeyUp (e.g. click on map mid-hold) would leave the + -- ticker firing the move action next time navFrame became visible. if not on and navKeyRepeat then navKeyRepeat.Stop() end end @@ -1803,28 +1605,15 @@ local function ActivateNavSelection() return false end --- Handle a nav key arriving from either the editbox OnKeyDown (search --- box focused) or the navFrame OnKeyDown (search box unfocused). Returns --- true if the key was consumed. `keepSearchFocus` is true when the call --- site is the editbox; false when it's the navFrame. +-- keepSearchFocus is true from editbox OnKeyDown, false from navFrame. HandleNavKey = function(key, keepSearchFocus) local alt = IsAltKeyDown() if key == "DOWN" or (alt and key == "J") then if #visibleNavRows == 0 then return false end - -- Keying into results always drops editbox focus and hands - -- keyboard capture to navFrame, so subsequent keys move the - -- selection without retyping in the box. Applies to both - -- arrow and Alt+J behave identically. if keepSearchFocus and panel and panel.searchBox then panel.searchBox:ClearFocus() end SetNavFrameCapture(true) - -- Hold-to-step via the OnUpdate ticker. SearchBoxTemplate - -- swallows OS-level auto-repeat OnKeyDown events, so a held - -- key would only fire once without our own ticker. The - -- editbox path needs its own OnKeyUp to stop the repeat - -- (paired with the OnKeyDown that started it); see the - -- editBox:HookScript("OnKeyUp", ...) below in CreateSearchBox. if navKeyRepeat then navKeyRepeat.Start(key, function() MoveNavSelection(1) end) else @@ -1833,9 +1622,7 @@ HandleNavKey = function(key, keepSearchFocus) return true elseif key == "UP" or (alt and key == "K") then if #visibleNavRows == 0 then return false end - -- Up from the first row (or from no selection): exit back to - -- the search box so the user can resume typing symmetrically - -- with how Down enters the results from the editbox. + -- Up from the first row exits back to the search box. if navRowIndex <= 1 then SetNavRowIndex(0) SetNavFrameCapture(false) @@ -1853,21 +1640,15 @@ HandleNavKey = function(key, keepSearchFocus) end return true elseif key == "SPACE" then - -- Space inside the search box is a literal character and must - -- not be consumed. While the navFrame is active (focus on a - -- results row), Space toggles the highlighted group header's - -- collapse. On a leaf row it does nothing and is still - -- consumed so it doesn't leak through to a player keybind. + -- Space in the editbox is a literal character; in navFrame it + -- toggles the highlighted group header's collapse. if keepSearchFocus then return false end if navRowIndex > 0 then local f = visibleNavRows[navRowIndex] if f and f.toggleBtn and f.toggleBtn:IsShown() then - -- The toggle click triggers a full RefreshCurrentSearch - -- which wipes visibleNavRows and navRowIndex. Snapshot - -- the header's groupKey, fire the click, then re-find - -- the same header in the freshly-rendered set so the - -- user can spam Space to collapse/expand repeatedly - -- without losing their selection. + -- Toggle triggers a refresh that wipes visibleNavRows. + -- Snapshot groupKey and reselect after re-render so the + -- user can spam Space without losing selection. local savedGroupKey = f.groupKey local handler = f.toggleBtn:GetScript("OnClick") if handler then handler(f.toggleBtn) end @@ -1887,13 +1668,9 @@ HandleNavKey = function(key, keepSearchFocus) elseif key == "ENTER" then if navRowIndex > 0 then ActivateNavSelection() - -- Match the mouse-click activation flow: a clicked row - -- ends with searchBox unfocused, navRowIndex=0, and - -- navFrame keyboard off. Without mirroring that state, - -- the next Esc would clear editbox focus, then navFrame - -- (still keyboard-enabled with navRowIndex > 0) would - -- pick up the propagated key and re-focus the search - -- bar via the Esc branch, instead of closing the map. + -- Mirror mouse-click flow: unfocus searchBox, drop nav + -- selection, disable navFrame keyboard. Otherwise the next + -- Esc would refocus the bar instead of closing the map. SetNavRowIndex(0) SetNavFrameCapture(false) if panel and panel.searchBox then panel.searchBox:ClearFocus() end @@ -1902,9 +1679,8 @@ HandleNavKey = function(key, keepSearchFocus) return false elseif key == "ESCAPE" then if not keepSearchFocus then - -- In navFrame: first Esc clears selection and refocuses - -- search. A second Esc (with nothing highlighted) propagates - -- to WoW so the map closes. + -- First Esc clears selection and refocuses search; second + -- (nothing highlighted) propagates so the map closes. if navRowIndex > 0 then SetNavRowIndex(0) SetNavFrameCapture(false) @@ -1913,8 +1689,6 @@ HandleNavKey = function(key, keepSearchFocus) end return false else - -- In editbox: drop keyboard focus but keep text + results - -- visible so the player can still see what they searched. if panel and panel.searchBox then panel.searchBox:ClearFocus() end return true end @@ -1922,13 +1696,7 @@ HandleNavKey = function(key, keepSearchFocus) return false end --- --------------------------------------------------------------------------- --- Search box + filter cog (styled like QuestScrollFrame.SearchBox + --- SettingsDropdown) --- --------------------------------------------------------------------------- local function CreateSearchBox(parent) - -- Use Blizzard's SearchBoxTemplate so chrome, magnifying glass, and - -- clear button are pixel-identical to the Quest Log search bar. local editBox = CreateFrame("EditBox", nil, parent, "SearchBoxTemplate") editBox:SetSize(301, 20) editBox:SetAutoFocus(false) @@ -1937,19 +1705,15 @@ local function CreateSearchBox(parent) editBox.Instructions:SetText("Search for POIs, zones, instances...") end - -- Reject auto-focus on creation. WoW will silently focus visible - -- EditBoxes after creation despite SetAutoFocus(false). When the - -- WorldMap is open during /reload this editbox auto-focuses, and - -- its OnKeyDown handler sets SetPropagateKeyboardInput(false) for - -- every key -- which silently eats SPACE/WASD even though the bar - -- looks unfocused. Reject any focus that arrives within the first - -- couple frames after creation. + -- WoW silently focuses visible EditBoxes after creation despite + -- SetAutoFocus(false). On /reload with WorldMap open, this editbox + -- auto-focuses; its OnKeyDown sets SetPropagateKeyboardInput(false) + -- for every key, silently eating SPACE/WASD even though the bar + -- looks unfocused. Reject focus within the first couple frames. editBox._blockAutoFocus = true editBox:HookScript("OnEditFocusGained", function(self) if self._blockAutoFocus then self:ClearFocus() end end) - -- Allow legitimate user clicks to focus by clearing the block on - -- OnMouseDown (which fires before focus is gained). editBox:HookScript("OnMouseDown", function(self) self._blockAutoFocus = nil end) editBox:ClearFocus() C_Timer.After(0, function() @@ -1964,22 +1728,15 @@ local function CreateSearchBox(parent) end end - -- SearchBoxTemplate's built-in clear button only clears the text + - -- focus. Without this hook the result list would re-render in its - -- "empty query" state (pinned + recent), which feels like the - -- clear didn't take. Wipe everything visible so the results frame - -- is truly empty after pressing X. + -- SearchBoxTemplate's clear button only clears text+focus; without + -- this hook the list re-renders to its "empty query" state (pinned + -- + recent), which feels like the clear didn't take. if editBox.clearButton then editBox.clearButton:HookScript("OnClick", function() if ReleaseMapTabMemory then ReleaseMapTabMemory(false) end end) end - -- Walks panel.topResultCandidates in scoring order and returns the - -- first name whose lowercase prefix matches the query. Lets - -- autocomplete fall back past a fuzzy winner that doesn't prefix- - -- match (e.g. "dragon i" skipping "Dragonblight" to suggest - -- "Dragon Isles"). local function FindPrefixCandidate(q) if not panel or q == "" then return nil end local qLower = q:lower() @@ -2016,12 +1773,9 @@ local function CreateSearchBox(parent) UpdateClear(self) end) - -- Keyboard nav: consume arrow / Alt+J/K / Esc while the editbox - -- is focused. WoW editboxes default to propagating every keystroke - -- to the binding system, which fires player keybinds while the user - -- is typing, so we unconditionally clamp propagation to false at - -- the end. ENTER routes through HandleNavKey, which activates a - -- highlighted row or falls through to push-to-recents below. + -- WoW editboxes default to propagating every keystroke to the + -- binding system, firing player keybinds while the user types. + -- Always clamp propagation to false at the end. editBox:HookScript("OnKeyDown", function(self, key) if self.HasAutocomplete and self:HasAutocomplete() and self.AcceptAutocomplete then if key == "RIGHT" or key == "ARROWRIGHT" then @@ -2034,7 +1788,6 @@ local function CreateSearchBox(parent) return end end - -- Keep nav keys from racing a pending search render. if pendingSearchTimer and (key == "DOWN" or key == "UP" or (IsAltKeyDown() and (key == "J" or key == "K"))) then @@ -2043,23 +1796,17 @@ local function CreateSearchBox(parent) HandleNavKey(key, true) Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) end) - -- Stop the hold-to-step ticker on key release. The OnKeyDown that - -- started the repeat fires from the editbox, and WoW pairs OnKeyUp - -- to the same frame regardless of focus changes mid-press, so the - -- navFrame OnKeyUp handler isn't always reached. Mirror it here so - -- a release on a key whose down event was on the editbox still - -- stops the repeat -- otherwise it would keep ticking after the - -- user lets go. + -- WoW pairs OnKeyUp to the original frame regardless of focus + -- changes mid-press, so the navFrame handler isn't always reached. editBox:HookScript("OnKeyUp", function(_, key) if navKeyRepeat and navKeyRepeat.IsKey(key) then navKeyRepeat.Stop(key) end end) - -- Force focus on click. SearchBoxTemplate doesn't always grab focus - -- back cleanly after the user has been clicking around in result - -- rows or hovering pins on the map; the explicit SetFocus + nav - -- keyboard release here makes the caret reappear every time. + -- SearchBoxTemplate doesn't always grab focus cleanly after clicks + -- around results or map pins; explicit SetFocus makes the caret + -- reappear every time. editBox:HookScript("OnMouseDown", function(self) self:SetFocus() if navFrame then @@ -2076,8 +1823,6 @@ local function CreateSearchBox(parent) end) editBox:HookScript("OnEnterPressed", function(self) - -- If a row is highlighted, HandleNavKey already activated it on - -- OnKeyDown(ENTER) and we shouldn't also commit to recents. if navRowIndex > 0 then return end local current = self:GetText() or "" local typed = self.GetTypedText and self:GetTypedText() or current @@ -2095,8 +1840,6 @@ local function CreateSearchBox(parent) return editBox end --- Push a query into the recent-searches list. Deduped (case-insensitive), --- most-recent-first, capped at RECENT_MAX. Persists via SavedVariables. local RECENT_ABSOLUTE_MAX = 20 local function GetRecentLimit() local n = EasyFind.db and EasyFind.db.mapTabRecentCount @@ -2106,10 +1849,6 @@ local function GetRecentLimit() end MapTab.GetRecentLimit = GetRecentLimit --- Re-render the Map Tab if its panel is currently visible. Used by --- Options callbacks that mutate settings affecting the rendered list --- (e.g., recent-search count, show-recent toggle) so the change is --- reflected without waiting for the next keystroke or map change. function MapTab:RefreshIfOpen() if panel and panel:IsShown() and RefreshCurrentSearch then RefreshCurrentSearch() @@ -2144,9 +1883,8 @@ local FILTER_OPTIONS = { { key = "rares", label = "Rares" }, } --- Attach the Auto-track Rares sub-row under the Rares filter. The row --- appears only while the parent Rares filter is checked, and mirrors --- `alwaysShowRares` in SavedVariables (shared with Options.lua). +-- Sub-row that only shows while the parent Rares filter is checked. +-- Mirrors `alwaysShowRares` in SavedVariables (shared with Options.lua). local AUTO_TRACK_ROW_H = 18 local function AttachAutoTrackRow(dropdown) local raresRow @@ -2229,15 +1967,10 @@ local function CreateFilterCog(parent) return cog end --- --------------------------------------------------------------------------- --- Side tab button --- --------------------------------------------------------------------------- local function CreateTabFrame(qmf) local tab = CreateFrame("Frame", "EasyFindMapSearchTab", qmf) - -- Match the Blizzard side-tab size exactly by copying MapLegendTab. - -- WQT achieves this by inheriting LargeSideTabButtonTemplate; we - -- don't use the template (for control over our custom icon), so we - -- mirror its resolved size here. + -- Match Blizzard's side-tab size by copying MapLegendTab; the + -- template-inheritance path would override our custom icon. local refW, refH = qmf.MapLegendTab:GetSize() if not refW or refW == 0 then refW, refH = TAB_W, TAB_H end tab:SetSize(refW, refH) @@ -2246,9 +1979,6 @@ local function CreateTabFrame(qmf) tab.displayMode = "EasyFindMapSearch" tab:EnableMouse(true) - -- Background, select glow, and hover glow use Blizzard atlases at - -- their native sizes (useAtlasSize=true) so they match the side - -- tabs regardless of what hardcoded constants we might drift from. local bg = tab:CreateTexture(nil, "BACKGROUND") bg:SetAtlas("QuestLog-tab-side", true) bg:SetPoint("CENTER", tab, "CENTER", 0, 0) @@ -2284,31 +2014,15 @@ local function CreateTabFrame(qmf) return tab end --- --------------------------------------------------------------------------- --- Content panel: sibling of QuestsFrame / MapLegend / EventsFrame. --- Replicates the Quests-tab chrome: paper backdrop, gold decorative --- border, search bar + cog at top, scrollable content below. --- --------------------------------------------------------------------------- +-- Sibling content panel: paper backdrop, gold border, search + cog at +-- top, scrollable content below. local function CreatePanel(qmf) - -- Match WorldQuestTab's pattern (LibWorldMapTabs): anchor to - -- QuestMapFrame.ContentsAnchor with top inset for the top bar and - -- right inset so the MinimalScrollBar template sits outside the - -- bordered content area. Paper texture fills the frame; the border - -- comes from QuestLogBorderFrameTemplate (same one Blizzard uses). local anchor = qmf.ContentsAnchor or qmf.QuestsFrame or qmf - -- Outer container spans the whole ContentsAnchor; the bordered - -- result area (p) starts below the search bar. Matches Blizzard's - -- layout where the SearchBox sits above the parchment. local outer = CreateFrame("Frame", "EasyFindMapSearchOuter", qmf) outer:SetAllPoints(anchor) outer:EnableMouse(false) - -- p is the ListContainer equivalent. Sized like WQT_WorldQuestFrame: - -- ContentsAnchor with insets y=-29 top, x=-22 right. This is a - -- larger rect than QuestScrollFrame, which is why WQT's paper - -- pattern renders at the same scale as Blizzard's: the atlas - -- stretches over a larger area. local p = CreateFrame("Frame", "EasyFindMapSearchPanel", outer) p:SetPoint("TOPLEFT", anchor, "TOPLEFT", 0, -29) p:SetPoint("BOTTOMRIGHT", anchor, "BOTTOMRIGHT", -22, 0) @@ -2316,40 +2030,28 @@ local function CreatePanel(qmf) p.outer = outer outer:Hide() - -- Paper backdrop fills p exactly, matches WQT_ListContainer.Background - -- (TOPLEFT + BOTTOMRIGHT to ListContainer). No bleed past the border. local paper = p:CreateTexture(nil, "BACKGROUND", nil, -1) paper:SetAtlas("QuestLog-main-background", true) paper:SetAllPoints(p) p.paper = paper - -- Gold border frame (Blizzard template used by QuestScrollFrame). - -- The template carries its own anchors that extend the frame a few - -- pixels outside its parent so the nineslice interior lines up with - -- the parent's rect. Do NOT call SetAllPoints or we override those - -- anchors and the border draws shrunken inside the parent. This is - -- exactly how WQT's - -- with no anchors works. + -- Don't SetAllPoints on the border: the template carries its own + -- anchors that extend the frame a few pixels outside its parent so + -- the nineslice interior lines up with the parent's rect. local border = CreateFrame("Frame", nil, p, "QuestLogBorderFrameTemplate") border:SetFrameLevel(p:GetFrameLevel() + 2) - -- The template inherits NineSlicePanel which spans the full rect with - -- mouse enabled. Sitting two levels above the result rows it ate the - -- first click on every row (loss of editbox focus + border absorption - -- meant the user had to click twice). Border is decorative chrome, it - -- never needs mouse input. + -- Border is decorative chrome; it never needs mouse input. Leaving + -- it on ate the first click on every row. border:EnableMouse(false) p.border = border - -- Cog at fixed top-right position, matching WQT SettingsButton: - -- TOPRIGHT relative to the list container at (19, 25), size 15x16. local cog = CreateFilterCog(outer) cog:SetSize(15, 16) cog:SetPoint("TOPRIGHT", p, "TOPRIGHT", 19, 25) p.cog = cog - -- Search box: fallback position (used if QuestScrollFrame.SearchBox - -- isn't measurable yet). AlignSearchBoxToBlizzard() re-anchors it to - -- exactly mirror the Quests tab search box once the map is shown. + -- Fallback search-box position; AlignToBlizzardSearch re-anchors it + -- to mirror the Quests tab box once measurable. local searchBox = CreateSearchBox(outer) searchBox:ClearAllPoints() searchBox:SetHeight(20) @@ -2370,10 +2072,6 @@ local function CreatePanel(qmf) if key == "rares" and dropdown.UpdateAutoTrackRow then dropdown:UpdateAutoTrackRow() end - -- The UI search bar's map results follow the same cog - -- settings, so a toggle here should refresh that view - -- too. Without this, a stale UI dropdown could keep - -- showing flight masters after the user disabled them. local uiMod = ns.UI local uiSb = uiMod and uiMod.searchFrame and uiMod.searchFrame.editBox if uiMod and uiMod.OnSearchTextChanged @@ -2384,9 +2082,6 @@ local function CreatePanel(qmf) end ) AttachAutoTrackRow(dropdown) - -- Replace the shared dropdown's default anchor (right edge of - -- the outer panel) with Blizzard's convention: TOPLEFT of the - -- menu aligned just below the cog's BOTTOMLEFT. cog:SetScript("OnClick", function() if dropdown:IsShown() then dropdown:Hide() @@ -2399,11 +2094,8 @@ local function CreatePanel(qmf) p.filterDropdown = dropdown end - -- Copy QuestScrollFrame.SearchBox's anchors AND explicit size onto - -- our search box. Frame inspector confirms Blizzard uses a fixed - -- 301x20 SetSize, so we re-apply it after ClearAllPoints to - -- guarantee our frame matches, otherwise the last flex-anchored - -- width lingers and we resolve to a different rect. + -- Blizzard's box uses fixed 301x20 SetSize; re-apply it after + -- ClearAllPoints or the last flex-anchored width lingers. p.AlignToBlizzardSearch = function() local qsb = _G["QuestScrollFrame"] and QuestScrollFrame.SearchBox if not qsb then return end @@ -2423,10 +2115,9 @@ local function CreatePanel(qmf) end p.MeasureBlizzardSearch = p.AlignToBlizzardSearch - -- Scroll area covers the paper region. local scrollFrame = CreateFrame("ScrollFrame", nil, p) scrollFrame:SetPoint("TOPLEFT", p, "TOPLEFT", 4, -4) - -- Leaves 28px at the bottom for the "Show recent searches" checkbox. + -- 28px bottom inset for the "Show recent searches" checkbox. scrollFrame:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", -4, 28) scrollFrame:EnableMouseWheel(true) p.scrollFrame = scrollFrame @@ -2446,8 +2137,6 @@ local function CreatePanel(qmf) end) p.scrollChild = scrollChild - -- MinimalScrollBar template placed outside the panel right edge, - -- matching WQT's anchor offsets (+8 x, +2/-4 y). local scrollBar = CreateFrame("EventFrame", nil, p, "MinimalScrollBar") scrollBar:SetFrameStrata("HIGH") scrollBar:SetPoint("TOPLEFT", p, "TOPRIGHT", 8, 2) @@ -2484,8 +2173,6 @@ local function CreatePanel(qmf) emptyMsg:SetText("|cff999999Start typing to search for POIs, zones, dungeons, and raids.|r") p.emptyMsg = emptyMsg - -- "Show recent searches" checkbox pinned at the panel's bottom-left, - -- inside the border. Persists to EasyFind.db.mapTabShowRecent. local recentCheck = CreateFrame("CheckButton", "EasyFindMapTabRecentCheck", p, "UICheckButtonTemplate") recentCheck:SetSize(20, 20) recentCheck:SetPoint("BOTTOMLEFT", p, "BOTTOMLEFT", 4, 4) @@ -2502,11 +2189,6 @@ local function CreatePanel(qmf) end) p.recentCheck = recentCheck - -- "Auto expand headers" checkbox to the right of the recent toggle. - -- When on (default), a matched zone header lists every child the - -- world hierarchy says lives under it. When off, only children - -- whose names actually match the query show up. Handy when you - -- want a focused list instead of a continent's full roster. local expandCheck = CreateFrame("CheckButton", "EasyFindMapTabAutoExpandCheck", p, "UICheckButtonTemplate") expandCheck:SetSize(20, 20) expandCheck:SetPoint("LEFT", recentLabel, "RIGHT", 12, -1) @@ -2543,34 +2225,25 @@ local function CreatePanel(qmf) return p end --- --------------------------------------------------------------------------- --- Focus entry for keybind (/ef map search focus) --- --------------------------------------------------------------------------- function MapTab:Focus() if not IsMapSearchEnabled() then return false end if not initialized then self:Initialize() end -- ToggleWorldMap is a global available before Blizzard_WorldMap - -- loads; calling it loads the addon and shows the map. After it - -- returns, WorldMapFrame and QuestMapFrame exist so Initialize - -- can finish wiring up our tab and panel. + -- loads; calling it loads the addon and shows the map. if not WorldMapFrame or not WorldMapFrame:IsShown() then if ToggleWorldMap then ToggleWorldMap() end if not initialized then self:Initialize() end end - -- If init still hasn't completed (rare race during first load), - -- the ADDON_LOADED → Initialize callback at the bottom of this - -- file consumes _pendingFocus to retry once the panel exists. + -- If init hasn't completed (rare race), ADDON_LOADED at the bottom + -- consumes _pendingFocus to retry once the panel exists. if not panel or not tabFrame then MapTab._pendingFocus = true return false end - -- Synchronous tab swap: same path the user's tab click takes. - -- The OnMouseUp handler invokes ShowOurPanel(). local clickHandler = tabFrame:GetScript("OnMouseUp") if clickHandler then clickHandler(tabFrame, "LeftButton") end - -- Re-apply on the next frame to defend against any async tab - -- restoration logic (Blizzard or third-party) that fires after - -- this frame's OnShow chain settles. + -- Re-apply on next frame against async tab restoration that fires + -- after this OnShow chain settles. C_Timer.After(0, function() if not panel then return end if not panel:IsShown() and clickHandler then @@ -2590,21 +2263,14 @@ function MapTab:OpenWithQuery(query) if ToggleWorldMap then ToggleWorldMap() end if not initialized then self:Initialize() end end - -- Init not ready (rare first-load race): stash the query so the - -- ADDON_LOADED → Initialize callback can replay it once the panel - -- exists. Mirrors _pendingFocus. + -- ADDON_LOADED replays this once the panel exists. if not panel or not tabFrame then MapTab._pendingQuery = query return false end - -- Order matters: set the search text BEFORE invoking the tab's - -- OnMouseUp. ShowOurPanel reads the current editbox text and runs - -- a synchronous RunSearch on every show, so seeding the text first - -- means the panel's first render already uses our query instead of - -- briefly flashing an empty / recent-searches state. ClearFocus - -- prevents the editbox from grabbing keyboard input. The user - -- triggered this by clicking a result, so they expect to keep - -- moving with WASD. + -- Set text BEFORE invoking the tab's OnMouseUp: ShowOurPanel reads + -- the editbox text and runs a synchronous RunSearch, so seeding + -- first avoids a flash of empty / recent-searches state. if panel.searchBox then panel.searchBox:SetText(query or "") panel.searchBox:ClearFocus() @@ -2617,8 +2283,6 @@ function MapTab:OpenWithQuery(query) clickHandler(tabFrame, "LeftButton") end if panel.searchBox and panel:IsShown() then - -- Re-apply in case some other path (e.g. tab swap focus - -- restoration) blanked the text on this frame. if panel.searchBox:GetText() ~= (query or "") then panel.searchBox:SetText(query or "") end @@ -2628,13 +2292,9 @@ function MapTab:OpenWithQuery(query) return true end --- --------------------------------------------------------------------------- --- Initialize (hook Blizzard tab clicks + fullscreen-hide) --- --------------------------------------------------------------------------- --- Find the lowest shown sibling tab on QuestMapFrame and anchor our --- tab below it. Mirrors LibWorldMapTabs' PlaceTabs(): discovers tabs --- dynamically by the `displayMode` field so we don't care which --- specific Blizzard/third-party tabs exist. +-- Anchors our tab below the lowest shown sibling tab on QuestMapFrame. +-- Discovers tabs by the `displayMode` field so it works across Blizzard +-- and third-party tab additions. local function PlaceTab() if not tabFrame then return end local qmf = _G["QuestMapFrame"] @@ -2676,9 +2336,8 @@ function MapTab:Initialize() panel = CreatePanel(qmf) PlaceTab() - -- Hide our panel whenever any other tab gets selected. Blizzard - -- tabs call qmf:SetDisplayMode(mode); LibWorldMapTabs tabs call - -- lwmt:SetDisplayMode(mode). Hook both so we catch every switch. + -- Hook both qmf and the third-party tab framework's SetDisplayMode + -- so we catch every tab switch. if qmf.SetDisplayMode then hooksecurefunc(qmf, "SetDisplayMode", function(_, displayMode) if displayMode then @@ -2703,11 +2362,9 @@ function MapTab:Initialize() end end - -- Re-place on map show and once more on next frame so - -- late-registering third-party tabs are picked up. Also opportunistic: - -- measure Blizzard's SearchBox when the map opens with their panel - -- visible, so our alignCache is populated even if the user hasn't - -- clicked our tab yet. + -- Re-place on next frame to pick up late-registering third-party + -- tabs. Opportunistically measure Blizzard's SearchBox so the align + -- cache populates before the user ever clicks our tab. if WorldMapFrame then WorldMapFrame:HookScript("OnShow", function() PlaceTab() @@ -2731,10 +2388,8 @@ function MapTab:Initialize() ShowOurPanel() end end - -- If a Focus() request opened the map, switch immediately - -- so the native tab does not flash first. The next-frame - -- pass defends against late tab restoration from Blizzard - -- or another map-tab addon. + -- Switch immediately so the native tab doesn't flash first; + -- next-frame pass defends against late tab restoration. if MapTab._pendingFocus then MapTab._pendingFocus = nil if panel then @@ -2752,9 +2407,6 @@ function MapTab:Initialize() end end) end - -- Mirror handling for OpenWithQuery: replay the deferred - -- query once the tab + panel exist. Cleared even if - -- _pendingFocus was also set (focus path takes precedence). if MapTab._pendingQuery ~= nil and not MapTab._pendingFocus then local q = MapTab._pendingQuery MapTab._pendingQuery = nil @@ -2798,7 +2450,6 @@ function MapTab:Initialize() end) end - -- Hide our tab in fullscreen map (matches Blizzard's tab behavior). if WorldMapFrame and WorldMapFrame.IsMaximized then local function UpdateTabVisibility() if not tabFrame then return end @@ -2821,20 +2472,15 @@ function MapTab:Initialize() UpdateTabVisibility() end - -- Re-render when the displayed map changes (e.g. the user right- - -- clicks to zoom out to the parent zone). The "This Zone (...)" - -- label and its contents are zone-scoped, so they need to refresh - -- to reflect the new map. + -- "This Zone" label and contents are zone-scoped, so refresh when + -- the displayed map changes (right-click to zoom out, etc.). hooksecurefunc(WorldMapFrame, "OnMapChanged", function() if selectedIsOurs and panel and panel:IsShown() then RefreshCurrentSearch() end end) - -- If a Focus() request came in before init completed (very first - -- press of the keybind on a fresh login while Blizzard_WorldMap - -- was still loading), consume the pending flag now that the tab - -- and panel exist. + -- Consume any Focus() that came in before init completed. if MapTab._pendingFocus then MapTab._pendingFocus = nil SafeAfter(0, function() MapTab:Focus() end) @@ -2846,7 +2492,6 @@ function MapTab:Initialize() end end --- Blizzard_WorldMap is on-demand; hook the moment it loads. local eventFrame = CreateFrame("Frame") eventFrame:RegisterEvent("ADDON_LOADED") eventFrame:RegisterEvent("PLAYER_LOGIN") diff --git a/Options.lua b/Options.lua index 77b94a4..782c996 100644 --- a/Options.lua +++ b/Options.lua @@ -236,8 +236,6 @@ local function StyleSelectorButton(btnFrame, height) end) end --- Helper to create a flyout selector (button + dropdown panel + toggle + click-away) --- Returns: btnFrame, btnText, flyout local function CreateFlyoutSelector(parent, globalPrefix, width, anchor, initialText) local btnFrame = CreateFrame("Button", globalPrefix .. "Button", parent, "BackdropTemplate") btnFrame:SetSize(width, 22) @@ -258,8 +256,6 @@ local function CreateFlyoutSelector(parent, globalPrefix, width, anchor, initial return btnFrame, btnText end --- Create the flyout panel for a selector, with toggle and click-away behavior --- Returns: flyout frame local function CreateFlyoutPanel(btnFrame, globalPrefix, width, numChoices) local flyout = CreateFrame("Frame", globalPrefix .. "Flyout", btnFrame, "BackdropTemplate") flyout:SetSize(width, numChoices * 20 + 6) @@ -295,7 +291,6 @@ local function CreateFlyoutPanel(btnFrame, globalPrefix, width, numChoices) return flyout end --- Add simple text options to a flyout panel local function AddFlyoutOptions(flyout, choices, itemWidth, onSelect) for i, name in ipairs(choices) do local flyoutBtn = CreateFrame("Button", nil, flyout) @@ -326,9 +321,8 @@ local function AddFlyoutOptions(flyout, choices, itemWidth, onSelect) end end --- Helper to create a Raycast-style toggle row (anchored manually by caller). --- This remains a CheckButton so existing option code can keep using --- SetChecked/GetChecked and OnClick callbacks. +-- Stays a CheckButton so existing option code can keep using SetChecked / +-- GetChecked and OnClick callbacks. local function CreateCheckbox(parent, name, label, tooltipText, compact, width) local frameName = name and ("EasyFindOptions" .. name .. "Checkbox") or nil local rowH = compact and 22 or 28 @@ -836,9 +830,8 @@ function Options:Initialize() local WINDOW_H = 408 local SIDEBAR_W = 132 local FRAME_W = WINDOW_W - SIDEBAR_W - 46 - local COL_LEFT = 4 -- Left column offset within content frames + local COL_LEFT = 4 - -- Create the main options frame (same footprint as the onboarding wizard) optionsFrame = CreateFrame("Frame", "EasyFindOptionsFrame", UIParent, "BackdropTemplate") ns.optionsFrame = optionsFrame optionsFrame:SetSize(WINDOW_W, WINDOW_H) @@ -887,24 +880,20 @@ function Options:Initialize() divider:SetWidth(1) divider:SetColorTexture(1, 1, 1, 0.08) - -- Sidebar title local title = optionsFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") title:SetPoint("TOPLEFT", sidebar, "TOPLEFT", 4, -6) title:SetText("Settings") title:SetTextColor(TEXT_BODY[1], TEXT_BODY[2], TEXT_BODY[3], 1) optionsFrame.titleText = title - -- Close button local closeBtn = CreateModernCloseButton(optionsFrame) optionsFrame.closeBtn = closeBtn - -- Content host (all tabs render inside this) local contentBorder = CreateFrame("Frame", nil, optionsFrame) contentBorder:SetPoint("TOPLEFT", optionsFrame, "TOPLEFT", SIDEBAR_W + 32, -46) contentBorder:SetPoint("BOTTOMRIGHT", optionsFrame, "BOTTOMRIGHT", -14, 14) optionsFrame.contentBorder = contentBorder - -- Left-sidebar tab system local tabFrames = {} local tabButtons = {} @@ -962,7 +951,6 @@ function Options:Initialize() btn:SetScript("OnClick", function() SwitchToTab(index) end) tinsert(tabButtons, btn) - -- Content frame in the main pane local content = CreateFrame("Frame", nil, contentBorder) content:SetAllPoints(contentBorder) content:Hide() @@ -971,7 +959,6 @@ function Options:Initialize() return content end - -- Keybind helpers (defined early since Section 4 needs them) local function GetCurrentKeybindText(action) local key1, key2 = GetBindingKey(action) if key1 then return key1 end @@ -1004,11 +991,10 @@ function Options:Initialize() StopCapture(self, action) return end - -- Reject bare SPACE/ENTER/movement keys -- they're vital - -- defaults (jump, accept, WASD) and silently overwriting - -- them on a stray keypress during capture has bricked - -- spacebar after a /reload more than once. Only bind - -- these when modified. + -- Bare SPACE / ENTER / WASD silently overwriting jump, + -- accept, or movement on a stray capture-keypress has + -- bricked spacebar after /reload before. Only bind these + -- when modified. local hasMod = IsAltKeyDown() or IsControlKeyDown() or IsShiftKeyDown() if not hasMod and (key == "SPACE" or key == "ENTER" or key == "W" or key == "A" or key == "S" or key == "D") then @@ -1041,8 +1027,6 @@ function Options:Initialize() keybindBtn:HookScript("OnLeave", GameTooltip_Hide) end - -- SECTION 1: General - -- HOME TAB local homeTab = CreateTab("Home") local homeIcon = homeTab:CreateTexture(nil, "ARTWORK") homeIcon:SetSize(48, 48) @@ -1097,8 +1081,6 @@ function Options:Initialize() local sec3 = CreateTab("General & Binds") - -- General tab layout (no inner border, content fills the tab) - local loginMessageCheckbox = CreateCheckbox(sec3, "LoginMessage", "Show Login Message", "When enabled, shows a short \"EasyFind loaded!\" message in chat when you log in.\n\nDisable to keep chat cleaner.") loginMessageCheckbox:SetPoint("TOPLEFT", sec3, "TOPLEFT", 8, -8) @@ -1300,7 +1282,6 @@ function Options:Initialize() local RESET_BTN_W = 120 - -- SECTION 2: UI Search local sec1 = CreateTab("Search") local resizeUIBtn = CreateModernButton(sec1) @@ -1433,7 +1414,6 @@ function Options:Initialize() StaticPopup_Show("EASYFIND_RESET_UI_POS") end) - -- SECTION 3: Map local sec2 = CreateTab("Map") local mapEnableCheckbox = CreateCheckbox(sec2, "EnableMap", "Enable Map Search Module", @@ -1462,7 +1442,6 @@ function Options:Initialize() end end) - -- Gold separator under enable checkbox local mapSep = sec2:CreateTexture(nil, "ARTWORK") mapSep:SetPoint("TOPLEFT", sec2, "TOPLEFT", 6, -40) mapSep:SetPoint("RIGHT", sec2, "RIGHT", -6, 0) @@ -1641,11 +1620,8 @@ function Options:Initialize() } UpdateMapToggleVisual() - -- SECTION 4: Keyboard Shortcuts local sec4 = CreateTab("Shortcuts") - -- Shortcuts tab layout (no inner border) - local shortcutText = sec4:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") shortcutText:SetPoint("TOPLEFT", sec4, "TOPLEFT", 8, -8) shortcutText:SetWidth(FRAME_W - 60) @@ -1670,7 +1646,6 @@ function Options:Initialize() .. "|cFF00FF00/ef|r open options |cFF00FF00/ef c|r clear highlights and pins\n" ) - -- SECTION 5: Aliases local aliasesTab = CreateTab("Aliases") local aliasTitle = aliasesTab:CreateFontString(nil, "OVERLAY", "GameFontNormal") @@ -1875,8 +1850,6 @@ function Options:Initialize() end end) - -- Reset buttons (tips on Home tab) - StaticPopupDialogs["EASYFIND_RESET_ALL"] = { text = "Reset all EasyFind settings to defaults?", button1 = "Reset", @@ -1962,7 +1935,6 @@ function Options:Initialize() preferredIndex = 3, } - -- Reset buttons inside General tab (below last slider) local resetAllBtn = CreateModernButton(sec3) resetAllBtn:SetSize(RESET_BTN_W, 20) resetAllBtn:SetPoint("BOTTOMLEFT", sec3, "BOTTOMLEFT", 8, 8) @@ -1987,7 +1959,6 @@ function Options:Initialize() StaticPopup_Show("EASYFIND_RESET_POSITIONS") end) - -- FEEDBACK TAB local feedbackTab = CreateTab("Feedback") local feedbackDesc = feedbackTab:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") @@ -2097,14 +2068,12 @@ function Options:RegisterWithBlizzardOptions() Options.embedded = true Options.embedding = true - -- Hide standalone chrome optionsFrame.titleText:Hide() optionsFrame.closeBtn:Hide() optionsFrame.bgTex:Hide() optionsFrame:SetBackdrop(nil) optionsFrame:SetScale(1) - -- Reparent into Blizzard panel optionsFrame:SetParent(self) optionsFrame:ClearAllPoints() optionsFrame:SetPoint("TOPLEFT", self, "TOPLEFT", 0, 34) @@ -2129,8 +2098,6 @@ function Options:RegisterWithBlizzardOptions() InterfaceOptions_AddCategory(panel) end - -- Hook the root Blizzard settings frame so the search bar hides whenever - -- settings are open (regardless of which tab is active). local settingsRoot = SettingsPanel or InterfaceOptionsFrame if settingsRoot then local restoreOnClose = false @@ -2155,12 +2122,10 @@ function Options:Show() self:Initialize() end - -- If called standalone while embedded in Blizzard panel, pull out if self.embedded and not self.embedding then self:RestoreStandalone() end - -- Refresh values from saved vars (nil-safe in case init partially failed) SyncOptionControls() if not self.embedded and optionsFrame.bgTex then diff --git a/Perf.lua b/Perf.lua index 1c27887..475a208 100644 --- a/Perf.lua +++ b/Perf.lua @@ -8,20 +8,6 @@ local C_Timer = C_Timer local mfloor = math.floor local mhuge = math.huge --- --------------------------------------------------------------------- --- Frame-time recorder. An OnUpdate sampler captures every render --- frame's elapsed delta while a scenario is running. From those --- samples we derive avg, worst-case, and the count of frames slower --- than 1/50s (perceptible stutter). --- --------------------------------------------------------------------- - --- --------------------------------------------------------------------- --- Per-table memory snapshot. Uses the same exposed-table list as --- /efd mem so we can attribute heap deltas to specific structures --- (uiSearchData, lootItemCache, hier/result pools, UI scratch, etc.) --- without depending on EasyFindDev being loaded. --- --------------------------------------------------------------------- - local function EstimateSize(val, visited) if val == nil then return 0 end if not visited then visited = {} end @@ -116,9 +102,6 @@ local sampleMax = 0 local stutterCount = 0 local STUTTER_THRESHOLD = 1 / 50 --- Per-frame heap sampling alongside frame timings. Heap is sampled every --- recording frame so we capture the live churn during a scenario, not --- just bookended snapshots. Numbers are KB (collectgarbage("count")). local heapStartKB = 0 local heapMinKB = mhuge local heapMaxKB = 0 @@ -188,9 +171,6 @@ local function stopAndReport(label) color = "|cffffaa00" end - -- Heap stats relative to scenario start. peak = max during scenario; - -- delta = heap at scenario end vs scenario start (post-GC churn); - -- churn = peak - start (transient allocation pressure during scenario). local heapPeakDelta = heapMaxKB - heapStartKB local heapEndDelta = heapLastKB - heapStartKB local heapColor = "|cff66ff66" @@ -225,13 +205,8 @@ local function stopAndReport(label) agg.heapNetDeltaKB = agg.heapNetDeltaKB + heapEndDelta end --- --------------------------------------------------------------------- --- Search invocation. Bypasses the 50ms editbox debounce so each --- "keystroke" runs on the frame it was scheduled, exactly like real --- typing does once the debounce window expires. Mirrors the editbox --- text so any code reading :GetText() stays consistent. --- --------------------------------------------------------------------- - +-- Bypasses the 50ms editbox debounce so each simulated keystroke runs +-- on the frame it was scheduled. local function fireSearch(text) if not ns.UI or not ns.UI.OnSearchTextChanged then return end if ns.UI.searchFrame and ns.UI.searchFrame.editBox then @@ -243,12 +218,7 @@ end local function yieldNextFrame(fn) C_Timer.After(0, fn) end --- --------------------------------------------------------------------- --- Generic step driver. Walk a sequence of intermediate query strings, --- each separated by `keyDelay` seconds. keyDelay <= 0 yields one frame --- (16ms at 60fps), simulating a held key. --- --------------------------------------------------------------------- - +-- keyDelay <= 0 yields one frame (~16ms), simulating a held key. local function runSequence(seq, keyDelay, onDone) local i = 0 local function step() @@ -267,11 +237,6 @@ local function runSequence(seq, keyDelay, onDone) step() end --- --------------------------------------------------------------------- --- Sequence builders. Each returns an array of intermediate query --- strings to feed the search box, simulating one logical interaction. --- --------------------------------------------------------------------- - local function seqType(text) local s = {} for i = 1, #text do s[i] = text:sub(1, i) end @@ -293,7 +258,6 @@ local function seqTypeThenErase(text) return s end --- Type prefix `pre`, backspace `n` chars, then append `suf`. local function seqTypeBackspaceType(pre, n, suf) local s = seqType(pre) local cur = pre @@ -308,7 +272,6 @@ local function seqTypeBackspaceType(pre, n, suf) return s end --- Oscillate: type the word, erase it, repeat `repeats` times. local function seqOscillate(word, repeats) local s = {} for r = 1, repeats do @@ -318,8 +281,6 @@ local function seqOscillate(word, repeats) return s end --- Type a chain of space-separated words, character by character (so --- every intermediate state goes through the search). local function seqWordChain(words) local s = {} local cur = "" @@ -336,12 +297,6 @@ local function seqWordChain(words) return s end --- --------------------------------------------------------------------- --- Scenario orchestration. Each scenario records its own sample window --- so per-scenario fps numbers are independent. Brief pause between --- scenarios so a tail-end render doesn't bleed into the next sample. --- --------------------------------------------------------------------- - local function chain(scenarios, onDone) local i = 0 local function next() @@ -367,7 +322,6 @@ function Perf:Run() return end - -- reset aggregate agg.scenarios = 0 agg.sumAvg = 0 agg.sumWorst = 0 @@ -381,7 +335,6 @@ function Perf:Run() agg.heapWorstChurnLabel = nil agg.heapNetDeltaKB = 0 - -- GC baseline so per-scenario heap stats start from a clean number. collectgarbageRef("collect") local runStartHeapKB = collectgarbageRef("count") local snapBefore = TakeSnapshot() @@ -393,9 +346,6 @@ function Perf:Run() if ns.UI and ns.UI.Show then ns.UI:Show(true) end local scenarios = { - -- - -- Forward typing at varying speeds. - -- { label = "type 'mountain' slow (150ms)", run = function(d) runSequence(seqType("mountain"), 0.15, d) end }, { label = "type 'mountain' fast (60ms)", @@ -413,12 +363,8 @@ function Perf:Run() { label = "type 'zzzqqq' (60ms, no matches)", run = function(d) runSequence(seqType("zzzqqq"), 0.06, d) end }, - -- - -- Erase from typed states. Erase paths cannot use the - -- prefix-extend incremental cache, so each backspace falls - -- back to a full scan over the dataset. This is the bucket - -- where fps drops are usually most painful. - -- + -- Erase paths cannot use the prefix-extend incremental cache; + -- each backspace re-scans the full dataset. { label = "erase 'mountain' slow (150ms)", run = function(d) runSequence(seqErase("mountain"), 0.15, d) end }, { label = "erase 'mountain' fast (60ms)", @@ -430,10 +376,6 @@ function Perf:Run() { label = "erase 37-char gibberish HOLD", run = function(d) runSequence(seqErase(GIBBERISH_LONG), 0, d) end }, - -- - -- Type + erase combinations. These mirror real correction - -- patterns: typo, backspace a few chars, type the right ones. - -- { label = "type 'mountin' -> back 2 -> 'ain' (typo fix)", run = function(d) runSequence(seqTypeBackspaceType("mountin", 2, "ain"), 0.06, d) end }, { label = "type 'rai' -> back 1 -> 'ider' (typo fix)", @@ -447,9 +389,6 @@ function Perf:Run() { label = "type 37-char gibberish then HOLD-backspace to empty", run = function(d) runSequence(seqTypeThenErase(GIBBERISH_LONG), 0, d) end }, - -- - -- Multi-word queries. - -- { label = "chain 'icc boss' (60ms)", run = function(d) runSequence(seqWordChain({"icc","boss"}), 0.06, d) end }, { label = "chain 'mount tank dungeon' (60ms)", @@ -457,10 +396,6 @@ function Perf:Run() { label = "chain 5 words 'icc boss tank rare elite' (60ms)", run = function(d) runSequence(seqWordChain({"icc","boss","tank","rare","elite"}), 0.06, d) end }, - -- - -- Real-world stress: keep banging the bar with new queries - -- back to back without ever clearing all the way to empty. - -- { label = "5x random word swap stress", run = function(d) local words = {"mount","mage","heal","raid","tank"} @@ -494,10 +429,6 @@ function Perf:Run() agg.worst * 1000, mfloor(1 / agg.worst + 0.5), agg.worstLabel or "?")) - -- Memory summary: peak heap during the run, worst per-scenario - -- transient churn, and the net retained heap delta after all - -- scenarios. A negative net means GC reclaimed more than was - -- allocated; a large positive net is a leak. local runEndHeapKB = collectgarbageRef("count") local netRunDeltaKB = runEndHeapKB - runStartHeapKB local netColor = "|cff66ff66" @@ -515,8 +446,6 @@ function Perf:Run() netRunDeltaKB, agg.scenarios, runStartHeapKB / 1024, runEndHeapKB / 1024)) - -- Per-table snapshot diff to attribute the net delta to - -- specific caches/pools rather than just "the heap". collectgarbageRef("collect") local snapAfter = TakeSnapshot() EasyFind:Print(" ") diff --git a/Rescaler.lua b/Rescaler.lua index 17f94cc..1ae5f04 100644 --- a/Rescaler.lua +++ b/Rescaler.lua @@ -26,17 +26,17 @@ local DEFAULT_BAR_HEIGHT = ns.SEARCHBAR_HEIGHT or 30 local DEFAULT_RESULTS_HEIGHT = 280 local activeMode = nil -local activeSearchBar = nil -- the mock search bar being rescaled -local liveSearchBar = nil -- real search bar hidden while editing +local activeSearchBar = nil +local liveSearchBar = nil local liveResultsFrame = nil local liveContainerFrame = nil local liveState = nil -local backdrop = nil -- full-screen dim -local barOverlay = nil -- glow around search bar -local donePanel = nil -- instruction + Done button -local mockSearchBar = nil -- inert mock bar for resize mode -local mockWindowFrame = nil -- unified background behind mock bar/results -local previewResults = nil -- fake results frame for preview +local backdrop = nil +local barOverlay = nil +local donePanel = nil +local mockSearchBar = nil +local mockWindowFrame = nil +local previewResults = nil local function GetResultsHeight() return EasyFind.db.uiResultsHeight or DEFAULT_RESULTS_HEIGHT @@ -65,8 +65,6 @@ local function GetFontScale() return EasyFind.db.fontSize or 1.0 end --- Helpers - local function ClampWidth(v) return mmax(MIN_WIDTH, mmin(MAX_WIDTH, v)) end @@ -187,7 +185,6 @@ local function CreateDimLabel(parent, anchor, relPoint, xOff, yOff, prefix) return box end --- Create a resize handle at a given edge/corner local function CreateHandle(parent, point, xOff, yOff, cursor, isHorizontal) local handle = CreateFrame("Button", nil, parent) handle:SetFrameLevel(parent:GetFrameLevel() + 10) @@ -209,7 +206,6 @@ local function CreateHandle(parent, point, xOff, yOff, cursor, isHorizontal) handle:EnableMouse(true) handle:RegisterForDrag("LeftButton") - -- Visual indicator on hover local tex = handle:CreateTexture(nil, "OVERLAY") tex:SetAllPoints() tex:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0) @@ -248,7 +244,6 @@ local function CreateScaleHandle(parent, point, xOff, yOff, flipH, flipV) return handle end --- Create a golden glow overlay around a target frame local function CreateGlowOverlay(name, parent, target) local glow = CreateFrame("Frame", name, parent, "BackdropTemplate") glow:SetFrameStrata("FULLSCREEN_DIALOG") @@ -325,8 +320,6 @@ local function CreateModernButton(parent, text, width, height) return btn end --- Preview results (fake rows to show results area) - local function CreatePreviewResults(parent, targetFrame, width, heightPx, anchorAbove, leftAligned, flushDock) local fontScale = GetFontScale() local rowH = PREVIEW_ROW_H * fontScale @@ -516,8 +509,6 @@ local function CreateMockWindowFrame(parent, searchBar, preview, anchorAbove) return frame end --- Dimension label wiring - local function WireDimLabel(box, getter, setter) box:SetText(mfloor(getter() + 0.5)) box:SetScript("OnEnterPressed", function(self) @@ -534,8 +525,6 @@ local function WireDimLabel(box, getter, setter) end) end --- Width drag handler - local function SetupWidthDrag(handle, getWidth, setWidth, widthLabel, side) handle:SetScript("OnDragStart", function(self) self.dragging = true @@ -552,9 +541,8 @@ local function SetupWidthDrag(handle, getWidth, setWidth, widthLabel, side) cx = cx / UIParent:GetEffectiveScale() if self.lastX then local dx = cx - self.lastX - -- Right edge: positive dx = wider. Left edge: negative dx = wider. if side == "LEFT" then dx = -dx end - -- Width changes apply to both sides, so each edge moves half + -- Width applies symmetrically so each edge contributes half. local newW = ClampWidth(getWidth() + dx * 2) setWidth(newW) if widthLabel and not widthLabel:HasFocus() then @@ -565,8 +553,6 @@ local function SetupWidthDrag(handle, getWidth, setWidth, widthLabel, side) end) end --- Height drag handler - local function SetupHeightDrag(handle, preview, heightBox, anchorAbove) handle:SetScript("OnDragStart", function(self) self.dragging = true @@ -624,8 +610,6 @@ local function SetupCornerDrag(handle, getWidth, setWidth, widthBox, preview, he end) end --- Build overlays for a target - local function BuildUnifiedWindowOverlay(parent, searchBar, preview, anchorAbove) local overlay = CreateGlowOverlay("EasyFindRescaleWindowGlow", parent, searchBar) if anchorAbove then @@ -686,8 +670,6 @@ local function BuildUnifiedWindowOverlay(parent, searchBar, preview, anchorAbove return overlay end --- Done panel - local function CreateDonePanel(parent) local BACK_W = 110 local DONE_W = 80 @@ -709,8 +691,6 @@ local function CreateDonePanel(parent) return panel end --- Full-screen dim backdrop - local function GetOrCreateBackdrop() if backdrop then return backdrop end backdrop = CreateFrame("Frame", "EasyFindRescaleBackdrop", UIParent) @@ -736,8 +716,6 @@ local function GetOrCreateBackdrop() return backdrop end --- Enter rescale mode - function Rescaler:Enter(mode) if activeMode then self:Exit() @@ -779,13 +757,11 @@ function Rescaler:Enter(mode) local getBarWidth = function() return mockSearchBar:GetWidth() end local setBarWidth = setUiWidth - -- Hide options panel local optPanel = _G["EasyFindOptionsFrame"] if optPanel and optPanel:IsShown() then optPanel:Hide() end - -- Dim backdrop local bg = GetOrCreateBackdrop() bg:Show() SafeCallMethod(bg, "EnableKeyboard", true) @@ -794,7 +770,6 @@ function Rescaler:Enter(mode) mockSearchBar:Show() activeSearchBar = mockSearchBar - -- Preview results (fake rows so user sees the results area) local resultsAbove = EasyFind.db.uiResultsAbove local currentTotal = GetUnifiedWindowHeight() local currentRatio = GetSearchBarHeight() / mmax(1, currentTotal) @@ -808,11 +783,10 @@ function Rescaler:Enter(mode) if mockWindowFrame and mockWindowFrame.UpdateLayout then mockWindowFrame:UpdateLayout() end end) - -- One overlay around the combined bar + results silhouette. barOverlay = BuildUnifiedWindowOverlay(bg, mockSearchBar, previewResults, resultsAbove) barOverlay:Show() - -- If height label is near screen edge, flip it inside the overlay + -- Flip height label inside the overlay when near a screen edge. local resizeEdge = resultsAbove and barOverlay:GetTop() or barOverlay:GetBottom() local screenLimit = resultsAbove and UIParent:GetTop() or 0 local nearEdge = resultsAbove and (resizeEdge and screenLimit and (screenLimit - resizeEdge) < 40) @@ -827,8 +801,6 @@ function Rescaler:Enter(mode) barOverlay.heightInside = true end - -- Wire unified window width. The bar and preview resize in lockstep - -- because the current UI uses one combined silhouette. local function setBarWidthAndPreview(w) setBarWidth(w) previewResults:SetWidth(mockSearchBar:GetWidth()) @@ -842,7 +814,6 @@ function Rescaler:Enter(mode) barOverlay.widthBox:SetText(mfloor(defW + 0.5)) end) - -- Wire unified window height. The search bar strip and result rows resize together. barOverlay.heightBox:SetText(mfloor(GetUnifiedWindowHeight() + 0.5)) barOverlay.heightBox:SetScript("OnEnterPressed", function(self) local val = tonumber(self:GetText()) @@ -878,7 +849,6 @@ function Rescaler:Enter(mode) SetupCornerDrag(barOverlay.scaleHandle, getBarWidth, setBarWidthAndPreview, barOverlay.widthBox, previewResults, barOverlay.heightBox, resultsAbove) SetupHeightDrag(barOverlay.heightHandle, previewResults, barOverlay.heightBox, resultsAbove) - -- Done panel donePanel = CreateDonePanel(bg) donePanel:SetPoint("TOP", barOverlay, "BOTTOM", 0, -50) donePanel.doneBtn:SetScript("OnClick", function() @@ -890,12 +860,9 @@ function Rescaler:Enter(mode) donePanel:Show() end --- Exit rescale mode - function Rescaler:Exit(reopenOptions) if not activeMode then return end - -- Clean up all overlay frames if barOverlay then if barOverlay.leftHandle then barOverlay.leftHandle:SetScript("OnUpdate", nil) end if barOverlay.rightHandle then barOverlay.rightHandle:SetScript("OnUpdate", nil) end diff --git a/UI.lua b/UI.lua index fb67210..698434b 100644 --- a/UI.lua +++ b/UI.lua @@ -2507,7 +2507,6 @@ end -- its normal subtext when selection moves away. local actionHintRow --- Restore the canonical pathSubtext on the row currently showing a hint. local function ClearActionHint() if actionHintRow and actionHintRow.pathSubtext then actionHintRow.pathSubtext:SetText(GetFlatSubtext(actionHintRow.data)) @@ -2685,10 +2684,8 @@ local function SetRowIcon(btn, kind, value, iconSize) btn.icon:Show() end --- THEME DEFINITIONS local THEMES = {} --- Modern: quest-log style - raised tab headers, golden tree lines, grey border THEMES["Modern"] = { rowHeight = 20, indentPx = 20, -- matches INDENT_PX so tree lines align @@ -2700,7 +2697,6 @@ THEMES["Modern"] = { btnWidth = 366, iconSize = 15, pathIconSize = 13, - -- fonts pathFont = ns.SEARCHBAR_FONT, leafFont = ns.LEAF_FONT, pathColor = {0.65, 0.60, 0.55, 1.0}, -- muted gray-tan (normal state) @@ -2716,24 +2712,18 @@ THEMES["Modern"] = { {0.85, 0.65, 0.15, 0.80}, {0.85, 0.65, 0.15, 0.80}, }, - -- icons for collapse/expand (Classic left-side only) expandIcon = "Interface\\Buttons\\UI-PlusButton-Up", collapseIcon = "Interface\\Buttons\\UI-MinusButton-Up", - -- highlight highlightTex = "Interface\\QuestFrame\\UI-QuestTitleHighlight", selectionColor = {0.25, 0.5, 0.9, 0.35}, - -- header bar disabled (headerTab used instead) showHeaderBar = false, - -- header tab: quest-log style with atlas textures showHeaderTab = false, headerTabAtlas = "QuestLog-tab", -- WoW atlas for tab background headerHighlightAlpha = 0.40, -- highlight layer alpha - -- +/- button atlases expandAtlas = "QuestLog-icon-expand", -- plus sign atlas collapseAtlas = "QuestLog-icon-shrink", -- minus sign atlas toggleNormalAlpha = 0.60, -- muted yellow (normal state) toggleHoverAlpha = 1.0, -- bright yellow (hover state) - -- separators off showSeparators = false, separatorColor = {0.5, 0.45, 0.3, 0.35}, -- results backdrop - grey tooltip border, quest log background @@ -2745,7 +2735,6 @@ THEMES["Modern"] = { resultsBgAtlas = "QuestLog-main-background", -- quest log dark background resultsBackdropColor = {0.12, 0.10, 0.08, 0.95}, resultsBackdropBorderColor = {0.50, 0.48, 0.45, 1.0}, -- grey - -- search bar style searchBarRounded = true, -- rounded Common-Input-Border style } @@ -2852,13 +2841,11 @@ function UI:LayoutResultShortcut(row) end function UI:CreateUnearnedTooltip() - -- Create simple tooltip frame unearnedTooltip = CreateFrame("Frame", "EasyFindUnearnedTooltip", UIParent, "BackdropTemplate") unearnedTooltip:SetFrameStrata("TOOLTIP") unearnedTooltip:SetFrameLevel(9999) unearnedTooltip:SetClampedToScreen(true) - -- Simple black background with border unearnedTooltip:SetBackdrop({ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", edgeFile = TOOLTIP_BORDER, @@ -2868,14 +2855,12 @@ function UI:CreateUnearnedTooltip() unearnedTooltip:SetBackdropColor(0, 0, 0, 0.95) unearnedTooltip:SetBackdropBorderColor(0.6, 0.6, 0.6, 1) - -- Text with larger font local text = unearnedTooltip:CreateFontString(nil, "OVERLAY", "GameFontHighlight") text:SetPoint("CENTER", 0, 0) text:SetText("Currency not yet earned") text:SetTextColor(1, 1, 1, 1) unearnedTooltip.text = text - -- Auto-size tooltip to fit text with padding local textWidth = text:GetStringWidth() local textHeight = text:GetStringHeight() unearnedTooltip:SetSize(textWidth + 20, textHeight + 16) -- Add padding @@ -2987,7 +2972,6 @@ function UI:CreateSearchFrame() searchFrame:EnableMouse(true) searchFrame:SetClampedToScreen(true) - -- Apply saved position or default if EasyFind.db.uiSearchPosition then local pos = EasyFind.db.uiSearchPosition searchFrame:SetPoint(pos[1], UIParent, pos[2], pos[3], pos[4]) @@ -3058,7 +3042,6 @@ function UI:CreateSearchFrame() searchFrame.modeBtn = iconHolder searchFrame.iconHolder = iconHolder - -- Editbox local editBox = CreateFrame("EditBox", "EasyFindSearchBox", searchFrame) editBox:SetHeight(contentSz) editBox:SetPoint("LEFT", iconHolder, "RIGHT", 0, 0) @@ -3435,7 +3418,6 @@ function UI:CreateSearchFrame() end) end - -- Filter button (inside search bar, flush right) local filterBtn = CreateFrame("Button", "EasyFindUIFilterButton", searchFrame) filterBtn:SetPoint("TOP", searchFrame, "TOP", 0, 0) filterBtn:SetPoint("BOTTOM", searchFrame, "BOTTOM", 0, 0) @@ -3534,7 +3516,6 @@ function UI:CreateSearchFrame() end) searchFrame.filterBtn = filterBtn - -- Anchor editBox right edge to the filter button zone. editBox:ClearAllPoints() editBox:SetPoint("LEFT", iconHolder, "RIGHT", 0, 0) editBox:SetPoint("RIGHT", filterBtn, "LEFT", -4, 0) @@ -3706,7 +3687,6 @@ function UI:CreateSearchFrame() end local function SetToolbarFocus(idx) - -- Clear previous button state local prevControls = GetToolbarControls() local prevTarget = prevControls[toolbarFocus] if prevTarget then @@ -4077,12 +4057,10 @@ function UI:CreateSearchFrame() end) searchFrame:SetScript("OnDragStop", function(self) self:StopMovingOrSizing() - -- Save position local point, _, relPoint, x, y = self:GetPoint() EasyFind.db.uiSearchPosition = {point, relPoint, x, y} end) - -- Apply saved scale self:UpdateScale() self:UpdateOpacity() @@ -4101,7 +4079,6 @@ function UI:CreateSearchFrame() hoverZone:Hide() searchFrame.hoverZone = hoverZone - -- Track whether the mouse is over the zone or the bar local smartShowVisible = false local smartShowTimer = nil @@ -4119,7 +4096,6 @@ function UI:CreateSearchFrame() if EasyFind.db.visible == false then return end -- Don't hide if the editbox has focus or contains text if searchFrame.editBox:HasFocus() or searchFrame.editBox:GetText() ~= "" then return end - -- Don't hide if results are showing if resultsFrame and resultsFrame:IsShown() then return end -- Don't hide while the player is actively resizing the bar if searchFrame.resizing then return end @@ -4165,7 +4141,6 @@ function UI:CreateSearchFrame() end end - -- UI search filter dropdown self:CreateUIFilterDropdown(filterBtn, searchFrame, editBox) searchFrame:HookScript("OnShow", function(self) @@ -4981,7 +4956,6 @@ function UI:BuildAppearanceSetOptionsPopup(StylePopup, CreateRadioTexture, optionsPopup:EnableMouse(true) optionsPopup:Hide() - -- Class selector button at the top local classBtn = CreateFrame("Button", nil, optionsPopup) classBtn:SetSize(OPTIONS_WIDTH - PAD * 2, CLASS_BTN_H) classBtn:SetPoint("TOPLEFT", optionsPopup, "TOPLEFT", PAD, -PAD) @@ -5131,7 +5105,6 @@ function UI:BuildAppearanceSetOptionsPopup(StylePopup, CreateRadioTexture, classPopup:Show() end) - -- Filter checkboxes local filterDefs = { { dbKey = "appearanceSetCollected", label = "Collected" }, { dbKey = "appearanceSetNotCollected", label = "Not Collected" }, @@ -5187,7 +5160,6 @@ function UI:BuildAppearanceSetOptionsPopup(StylePopup, CreateRadioTexture, end optionsPopup:SetSize(OPTIONS_WIDTH, -cy + PAD) - -- Hide nested class popup whenever the options popup itself hides. optionsPopup:HookScript("OnHide", function() classPopup:Hide() end) -- Outside-click: close immediately when the user clicks anywhere @@ -5268,7 +5240,6 @@ 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\\radio-off" local RADIO_ON_TEX = "Interface\\AddOns\\EasyFind\\radio-on" @@ -5287,8 +5258,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) frame:SetBackdropBorderColor(unpack(POPUP_BORDER_COLOR)) 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) @@ -5299,7 +5268,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) return tex, SetChecked end - -- "Uncheck All" toggle at the top local uncheckRow = CreateFrame("Button", nil, dropdown) uncheckRow:SetSize(DROPDOWN_WIDTH - 16, ROW_HEIGHT) uncheckRow:SetPoint("TOPLEFT", 8, -PADDING_TOP) @@ -5377,7 +5345,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) 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 @@ -5479,8 +5446,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) chev:SetPoint("RIGHT", -4, 0) chev:SetVertexColor(0.85, 0.85, 0.85, 1) row.flyoutChevron = chev - -- Anchor the label's right edge to the chevron so long names - -- truncate cleanly instead of running under it. label:SetPoint("RIGHT", chev, "LEFT", -4, 0) label:SetWordWrap(false) label:SetJustifyH("LEFT") @@ -6142,7 +6107,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end - -- Build class/spec data local CLASS_COLORS = RAID_CLASS_COLORS local allClassSpecs = {} for classIdx = 1, GetNumClasses() do @@ -6185,7 +6149,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) return allClassSpecs[1] end - -- Apply single selection and rebuild from cache local function ApplyFilterSelection() if ns.Database then if ns.Database.RefreshDynamicCategory then @@ -6233,7 +6196,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end lbl:SetText(colorStr .. cls.className .. " (" .. (sname or "?") .. ")|r") else - -- All specs for this class lbl:SetText(colorStr .. cls.className .. "|r") end end @@ -6304,11 +6266,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) return btn end - ------------------------------------------------------------------- - -- 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" @@ -6319,7 +6277,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) if specPopup:IsShown() then LayoutSpecPopup() end end) classFlyoutRows[#classFlyoutRows + 1] = allClassRow - -- Each class for _, cls in ipairs(allClassSpecs) do local clsRow = CreateRadioRow(classFlyout, "", { classID = cls.classID }, CLASSFLYOUT_WIDTH) -- Override label with class-colored text @@ -6424,7 +6381,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) 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") @@ -6434,12 +6390,10 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) 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 @@ -6472,7 +6426,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end - -- "All Specializations" row local allRow = specRadioRows[ri] if allRow then local children = { allRow:GetRegions() } @@ -6506,7 +6459,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) ri = ri + 1 end - -- Hide unused rows for hi = ri, MAX_SPECS do specRadioRows[hi]:Hide() end @@ -6518,9 +6470,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) specPopup:SetSize(POPUP_WIDTH, -py + 6) end - ------------------------------------------------------------------- - -- Spec selector dropdown bar - ------------------------------------------------------------------- local specSelectRow = CreateFrame("Button", nil, gearOptionsPopup) specSelectRow:SetSize(GEAR_POPUP_WIDTH - GEAR_POPUP_PAD * 2, 27) local specBg = specSelectRow:CreateTexture(nil, "BACKGROUND") @@ -6566,7 +6515,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) return rows end - -- Close on outside click specPopup:SetScript("OnShow", function(self) self:RegisterEvent("GLOBAL_MOUSE_DOWN") end) @@ -6584,7 +6532,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end) - -- 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 @@ -6604,11 +6551,9 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end) - -- Register loot popups as dropdown guard frames dropdownGuardFrames[#dropdownGuardFrames + 1] = specPopup dropdownGuardFrames[#dropdownGuardFrames + 1] = classFlyout - -- Close flyouts when dropdown hides dropdown:HookScript("OnHide", function() classFlyout:Hide() specPopup:Hide() @@ -6624,7 +6569,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) row.allClassSpecs = allClassSpecs row.lootSubRows = lootSubRows - -- Layout the controls inside the gear options popup, top-down. local gy = -GEAR_POPUP_PAD diffBtn:ClearAllPoints() diffBtn:SetPoint("TOPLEFT", gearOptionsPopup, "TOPLEFT", GEAR_POPUP_PAD, gy) @@ -6778,15 +6722,12 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) dropdownKbHighlight:Hide() local filters = EasyFind.db.uiSearchFilters 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] - -- Hide a child row entirely when its parent toggle is off local parentVisible = (not opt.parentKey) or (filters[opt.parentKey] ~= false) if not parentVisible then row:Hide() @@ -6800,14 +6741,12 @@ 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) @@ -6989,7 +6928,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end) - -- Toggle on filter button click toggleBtn:SetScript("OnClick", function() if dropdown:IsShown() then dropdown:Hide() @@ -7350,7 +7288,6 @@ function UI:CreateResultsFrame() resultShortcutFrame.shortcutButtons[i] = proxy end - -- Pin section separator line (golden, shown between pinned items and search results) local pinSeparator = scrollChild:CreateTexture(nil, "ARTWORK") pinSeparator:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.4) pinSeparator:SetHeight(1) @@ -7838,7 +7775,6 @@ end local function ShowInlineSettingDropdown(owner, opts, getCurrent, onSelect) local popup = GetInlineDropdownPopup() popup.owner = owner - -- Hide any prior rows in the pool. for i = 1, #inlineDropdownRows do inlineDropdownRows[i]:Hide() inlineDropdownRows[i]:SetScript("OnClick", nil) @@ -7868,7 +7804,6 @@ local function ShowInlineSettingDropdown(owner, opts, getCurrent, onSelect) popup:SetSize(popupW, popupH) popup:ClearAllPoints() popup:SetPoint("TOPRIGHT", owner, "BOTTOMRIGHT", 0, -2) - -- Build rows. local cur = getCurrent and getCurrent() or nil for i = 1, #opts do local opt = opts[i] @@ -7897,7 +7832,6 @@ UI.HideInlineSettingDropdown = function() end end --- Expand a container node: inject its database children into cachedHierarchical. function UI:CreateResultButton(index) local scrollChild = resultsFrame.scrollChild local resultRow = CreateFrame("Button", "EasyFindResultButton"..index, scrollChild, "SecureActionButtonTemplate") @@ -7920,7 +7854,6 @@ function UI:CreateResultButton(index) separator:Hide() resultRow.separator = separator - -- Tree connector textures per depth level resultRow.treeVert = {} -- vertical │ pass-through for ancestors resultRow.treeBranch = {} -- horizontal ─ branch connector resultRow.treeElbow = {} -- vertical half-line for └ / ├ @@ -8820,7 +8753,6 @@ function UI:CreateResultButton(index) repClip:SetClipsChildren(true) resultRow.repClip = repClip - -- Fill frame: same rounded shape as repBar, but colored; clipped by repClip local repFill = CreateFrame("Frame", nil, repClip, BackdropTemplateMixin and "BackdropTemplate") repFill:SetPoint("TOPLEFT", repBar, "TOPLEFT", 0, 0) repFill:SetPoint("BOTTOMRIGHT", repBar, "BOTTOMRIGHT", 0, 0) @@ -9219,7 +9151,6 @@ function UI:CreateResultButton(index) UI:SelectResult(self.data) end else - -- Classic: +/- icon on left side - 35px zone from icon start local cursorX = GetCursorPosition() local scale = self:GetEffectiveScale() local btnLeft = self:GetLeft() * scale @@ -9428,7 +9359,6 @@ function UI:CreateResultButton(index) AnchorTooltipAtCursor(GameTooltip, self) GameTooltip:SetMountBySpellID(self.icon.spellID) GameTooltip:Show() - -- Toy tooltip with live cooldown refresh elseif self.icon.toyItemID then local toyItemID = self.icon.toyItemID AnchorTooltipAtCursor(GameTooltip, self) @@ -9452,7 +9382,6 @@ function UI:CreateResultButton(index) GameTooltip:SetHyperlink(link) GameTooltip:Show() end - -- Outfit tooltip elseif self.icon.outfitID then AnchorTooltipAtCursor(GameTooltip, self) GameTooltip:SetText(self.data and self.data.name or "Outfit") @@ -9480,7 +9409,6 @@ function UI:CreateResultButton(index) GameTooltip:AddLine("", 0.5, 0.5, 0.5) end GameTooltip:Show() - -- Loot item tooltip elseif self.icon.lootItemID then AnchorGearTooltip(GameTooltip, self) local itemLink = self.data and ns.Database and ns.Database:GetLootItemLink(self.data) @@ -9490,7 +9418,6 @@ function UI:CreateResultButton(index) GameTooltip:SetItemByID(self.icon.lootItemID) end GameTooltip:Show() - -- Heirloom tooltip elseif self.icon.heirloomItemID then AnchorGearTooltip(GameTooltip, self) GameTooltip:SetItemByID(self.icon.heirloomItemID) @@ -9545,11 +9472,9 @@ function UI:CreateResultButton(index) if self.data and self.data.petID and BattlePetTooltip then BattlePetTooltip:Hide() end - -- Clear map preview if we were showing one if self.data and self.data.mapSearchResult and ns.MapSearch and ns.MapSearch.ClearUIPreview then ns.MapSearch:ClearUIPreview() end - -- Restore hint to whatever the keyboard has selected (or clear). if actionHintRow == self then ClearActionHint() local selRow = selectedIndex > 0 and resultButtons[selectedIndex] or nil @@ -9904,7 +9829,6 @@ function UI:OnSearchTextChanged(text, force) -- Showing everything keeps search at least as inclusive as the -- in-game tab regardless of what mode is selected. - -- Map Search: search static locations and dungeon entrances, merge into results local mapResults if not calculatorData and not calculatorLauncher and ns.MapSearch and ns.MapSearch.SearchForUI and ((quickFilter and quickFilter.key == "map") @@ -10037,7 +9961,6 @@ function UI:OnSearchTextChanged(text, force) end end --- Helper function to get icon from a button frame local function GetButtonIcon(frameName) local frame = _G[frameName] if not frame then return nil end @@ -10055,7 +9978,6 @@ local function GetButtonIcon(frameName) return nil end - -- Try common icon region names local iconRegions = {"Icon", "icon", "NormalTexture", "normalTexture"} for _, regionName in ipairs(iconRegions) do local region = frame[regionName] @@ -10088,7 +10010,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end if not resultsFrame then return end - -- Cache the FULL (unfiltered) list so collapse toggles can re-render cachedHierarchical = hierarchical -- Render-skip: if the input list is identical (same length, same @@ -10201,7 +10122,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if maxIconSize < (theme.iconSize or 16) then maxIconSize = theme.iconSize or 16 end if rowIconSize > maxIconSize then rowIconSize = maxIconSize end - -- Apply theme backdrop to results frame resultsFrame:SetBackdrop(theme.resultsBackdrop) if theme.resultsBackdropColor then resultsFrame:SetBackdropColor(unpack(theme.resultsBackdropColor)) @@ -10260,7 +10180,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end end - -- Count pin-related visible entries (header + pinned items) local pinSlots = 0 for vi = 1, visibleN do local entry = visible[vi] @@ -10467,7 +10386,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if resultRow.shortcutGroup then resultRow.shortcutGroup:Hide() end RestoreResultShortcutGutter(resultRow) - -- Tree connector drawing for d = 1, MAX_DEPTH do resultRow.treeVert[d]:Hide() resultRow.treeElbow[d]:Hide() @@ -10518,7 +10436,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end end - -- Header styling resultRow._isMatch = entry.isMatch and entry.isPathNode if entry.isPinHeader then -- Pin header: plain text + toggle icon + underline (no tab/gradient) @@ -10528,7 +10445,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.pinToggle:SetAtlas(collapseAtlas) resultRow.pinToggle:Show() resultRow.pinHeaderLine:Show() - -- Position text: left-aligned, right-bounded by toggle resultRow.text:ClearAllPoints() resultRow.text:SetPoint("LEFT", resultRow, "LEFT", 2, 0) resultRow.text:SetPoint("RIGHT", resultRow.pinToggle, "LEFT", -4, 0) @@ -10555,7 +10471,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.sectionLabelRight:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) resultRow.sectionLabelRight:Show() elseif theme.showHeaderTab and entry.isPathNode and resultRow.headerTab then - -- Quest-log raised tab header local tabInset = depth * indPx resultRow.headerTab:ClearAllPoints() resultRow.headerTab:SetPoint("TOPLEFT", resultRow, "TOPLEFT", tabInset, 0) @@ -10568,7 +10483,6 @@ 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) @@ -10665,7 +10579,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.isUnearnedCurrency = isUnearnedCurrency resultRow.isPathNode = entry.isPathNode -- Store for tooltip text - -- Position icon & text (non-tab, non-pin-header rows) if not entry.isPinHeader and not entry.isSectionHeader and not (theme.showHeaderTab and entry.isPathNode) then local indentPixels = depth * indPx resultRow.icon:ClearAllPoints() @@ -10767,7 +10680,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if resultRow.flatCatIcon then resultRow.flatCatIcon:Hide() end end - -- Set icon local iconSet = false -- Clear leftover cooldown sweep from prior render. Only the -- toy/ability/outfit branch re-enables it; every other branch @@ -10834,7 +10746,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end end - -- Amount text if quantity then resultRow.amountText:SetText(tostring(quantity)) if isUnearnedCurrency then @@ -10856,7 +10767,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.icon:ClearAllPoints() resultRow.icon:SetPoint("RIGHT", resultRow, "RIGHT", -5, 0) resultRow.icon:Show() - -- Anchor amount text to left of icon resultRow.amountText:ClearAllPoints() resultRow.amountText:SetPoint("RIGHT", resultRow.icon, "LEFT", -3, 0) else @@ -11060,7 +10970,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- 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 then local activeID = lastEquippedOutfitID or (C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetActiveOutfitID @@ -11156,7 +11065,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) 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 @@ -11231,7 +11139,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) else resultRow.amountText:Hide() - -- Reset amount text anchor for non-currency rows resultRow.amountText:ClearAllPoints() resultRow.amountText:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) end @@ -11412,7 +11319,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if step.sidebarIndex then local tab = _G["PaperDollSidebarTab" .. step.sidebarIndex] if tab then - -- Find the ARTWORK-layer texture (the actual icon region) for ri = 1, select("#", tab:GetRegions()) do local region = select(ri, tab:GetRegions()) if region and region:GetObjectType() == "Texture" @@ -11591,7 +11497,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- truncates at the checkbox instead of overlapping it. resultRow.text:SetPoint("RIGHT", resultRow.settingState, "LEFT", -4, 0) elseif settingType == "slider" and data.settingMin and data.settingMax then - -- Read current value local rawVal if Settings and Settings.GetSetting then local sok, settObj = pcall(Settings.GetSetting, data.settingVariable) @@ -11616,8 +11521,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) slider:SetValueStep(stepVal) slider:SetValue(numVal) slider._updating = false - -- Reset to default RIGHT anchor in case the previous - -- render of this row was a checkboxSlider entry. resultRow.settingSliderGroup:ClearAllPoints() resultRow.settingSliderGroup:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) resultRow.settingSliderGroup:Show() @@ -11842,7 +11745,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) -- Show pin indicator for pinned entries if entry.isPinned and resultRow.pinIcon then - -- Anchor pin icon to left edge of text, not the (possibly hidden) row icon resultRow.pinIcon:ClearAllPoints() resultRow.pinIcon:SetPoint("RIGHT", resultRow.text, "LEFT", 0, 0) resultRow.pinIcon:Show() @@ -11891,7 +11793,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if resultRow.headerTab and resultRow.headerTab:IsShown() then resultRow.headerTab:SetHeight(actualH) end - -- Reposition tree connectors for taller row if theme.showTreeLines and depth > 0 then local halfRow = actualH * 0.5 local xCenter = (depth - 1) * INDENT_PX + LINE_X_OFF @@ -12006,22 +11907,18 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end end - -- Calculate total content height vs max visible height local totalContentHeight = yOffset local hasScroll = totalContentHeight > maxVisibleHeight local visibleHeight = hasScroll and maxVisibleHeight or totalContentHeight - -- Size the results frame and scroll child resultsFrame:SetHeight(padT + padB + visibleHeight) resultsFrame.scrollChild:SetWidth(resultsFrame:GetWidth() - scrollInset) resultsFrame.scrollChild:SetHeight(totalContentHeight) - -- Position scroll frame inside results frame (accounting for padding) resultsFrame.scrollFrame:ClearAllPoints() resultsFrame.scrollFrame:SetPoint("TOPLEFT", resultsFrame, "TOPLEFT", 0, -padT) resultsFrame.scrollFrame:SetPoint("BOTTOMRIGHT", resultsFrame, "BOTTOMRIGHT", 0, padB) - -- Reset scroll position on new search (preserve on expand/collapse toggle) if not preserveScroll then resultsFrame.scrollFrame:SetVerticalScroll(0) end @@ -12082,7 +11979,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end) end - -- Reset keyboard selection whenever results change selectedIndex = 0 toggleFocused = false self:UpdateSelectionHighlight() @@ -12187,7 +12083,6 @@ function UI:CycleSettingDropdown(data, direction) return true end --- Apply a specific value picked from the dropdown popup (no cycle). function UI:SetSettingDropdownValue(data, value) if not data or not data.settingVariable then return false end if not WriteSettingVariable(data.settingVariable, value) then return false end @@ -12533,7 +12428,6 @@ function UI:JumpToNextSection(direction) startIdx = direction > 0 and 0 or visibleCount + 1 end - -- Find the first non-pinned row index (UI search section start) local uiSectionStart = 0 for i = 1, visibleCount do local row = resultButtons[i] @@ -12653,7 +12547,6 @@ function UI:ActivateSelected(source) local resultRow = resultButtons[selectedIndex] if resultRow and resultRow:IsShown() then if resultRow.isPathNode and toggleFocused then - -- Toggle collapse when focus is on the +/- control local key = (resultRow.pathNodeName or "") .. "_" .. (resultRow.pathNodeDepth or 0) collapsedNodes[key] = not collapsedNodes[key] if cachedHierarchical then @@ -12674,10 +12567,6 @@ function UI:ActivateSelected(source) 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. --- 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 @@ -12695,7 +12584,6 @@ end function UI:ApplyTransmogBrowseMode() if not TransmogFrame then return end - -- Collect vendor-only frames to hide local hidden = {} local outfitCollection = TransmogFrame.OutfitCollection if outfitCollection then @@ -12713,7 +12601,6 @@ function UI:ApplyTransmogBrowseMode() hidden[#hidden + 1] = outfitCollection.MoneyFrame end - -- Hide the Situations tab (vendor-only feature) local wardrobeCollection = TransmogFrame.WardrobeCollection local tabHeaders = wardrobeCollection and wardrobeCollection.TabHeaders if tabHeaders then @@ -12810,7 +12697,6 @@ function UI:ApplyTransmogBrowseMode() 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 @@ -13950,7 +13836,6 @@ function UI:SelectResult(data, forceGuide) return end - -- Map search result: open world map and search if data.mapSearchResult then if ns.MapSearch and ns.MapSearch.HandleUISearchClick then ns.MapSearch:HandleUISearchClick(data, forceGuide) @@ -14017,7 +13902,6 @@ function UI:SelectResult(data, forceGuide) self:DirectOpen(data) end elseif data.steps then - -- Step-by-step guide mode EasyFind:StartGuide(data) end end @@ -14607,7 +14491,6 @@ function UI:DirectOpen(data) executeFrom(1) end --- Helper function to click Character Frame sidebar buttons function UI:ClickCharacterSidebar(sidebarIndex) -- The sidebar buttons are PaperDollSidebarTab1/2/3 inside PaperDollSidebarTabs -- (confirmed via Frame Inspector) @@ -14656,7 +14539,6 @@ function UI:ClickCharacterSidebar(sidebarIndex) return false end --- Helper function to click an achievement or statistics category button function UI:ClickAchievementCategory(categoryName, categoryID) if not AchievementFrame or not AchievementFrame:IsShown() then return false @@ -14697,13 +14579,11 @@ function UI:ClickAchievementCategory(categoryName, categoryID) if finder then local elementData = finder(dataProvider, MatchesCategory) if elementData then - -- Expand parent if hidden if elementData.hidden and elementData.id and AchievementFrameCategories_ExpandToCategory then AchievementFrameCategories_ExpandToCategory(tonumber(elementData.id) or elementData.id) UpdateCategories() dataProvider = scrollBox.GetDataProvider and scrollBox:GetDataProvider() or dataProvider finder = dataProvider and (dataProvider.FindElementDataByPredicate or dataProvider.FindByPredicate) - -- Re-find after expanding elementData = finder and finder(dataProvider, MatchesCategory) if not elementData then return false end end @@ -15256,8 +15136,6 @@ function UI:OpenAchievementByID(achievementID) end --- Helper function to click a side tab (PvE Group Finder tabs) --- Helper to extract text from various button types function UI:GetButtonText(frame) return GetButtonText(frame) end @@ -15397,7 +15275,6 @@ function UI:HandleEscape() self:Hide() end --- Helper function to expand a currency header by name function UI:ExpandCurrencyHeader(headerName) -- Click the header button - this is what the game actually responds to. -- C_CurrencyInfo.ExpandCurrencyList exists but does not reliably trigger @@ -15422,7 +15299,6 @@ function UI:ExpandCurrencyHeader(headerName) return false end --- Helper function to expand a faction header by name function UI:ExpandFactionHeader(headerName) if not C_Reputation or not C_Reputation.GetNumFactions then return false end @@ -15441,7 +15317,6 @@ function UI:ExpandFactionHeader(headerName) return false end --- Helper function to open the player portrait right-click menu function UI:OpenPortraitMenu() if not PlayerFrame then return end @@ -15475,7 +15350,6 @@ function UI:OpenPortraitMenu() end end --- Helper function to click a portrait menu option by name function UI:ClickPortraitMenuOption(optionName) local optionNameLower = slower(optionName) @@ -15487,7 +15361,6 @@ function UI:ClickPortraitMenuOption(optionName) for i = 1, select("#", frame:GetChildren()) do local child = select(i, frame:GetChildren()) if child and child:IsShown() then - -- Check for text on this frame local text = nil if child.GetText then text = child:GetText() end if not text then @@ -15510,7 +15383,6 @@ function UI:ClickPortraitMenuOption(optionName) return false end - -- Search common dropdown/menu frames for i = 1, 5 do local dropdown = _G["DropDownList" .. i] if dropdown and dropdown:IsShown() then @@ -15663,10 +15535,8 @@ function UI:UpdateSmartShow() if not searchFrame then return end local enabled = EasyFind.db.smartShow if enabled then - -- Enable smart show: show hover zone, start hidden searchFrame.hoverZone:Show() if EasyFind.db.visible ~= false and not inCombat then - -- Start transparent - hover to reveal searchFrame:SetAlpha(0) searchFrame:Show() searchFrame.setSmartShowVisible(false) @@ -15739,21 +15609,17 @@ function UI:ShowWhatsNew(version) f:SetScript("OnDragStart", f.StartMoving) f:SetScript("OnDragStop", f.StopMovingOrSizing) - -- Close button (X) local closeBtn = CreateFrame("Button", nil, f, "UIPanelCloseButton") closeBtn:SetPoint("TOPRIGHT", -5, -5) - -- Title local title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") title:SetPoint("TOP", 0, -16) title:SetText("|cffFFD100EasyFind|r - New Features") - -- Version subtitle local verText = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") verText:SetPoint("TOP", title, "BOTTOM", 0, -4) verText:SetText("|cff999999v" .. (version or "?") .. "|r") - -- Feature body local body = f:CreateFontString(nil, "OVERLAY", "GameFontHighlight") body:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -58) body:SetWidth(f:GetWidth() - 32) @@ -15785,7 +15651,6 @@ function UI:ShowWhatsNew(version) f:Hide() end) - -- Auto-size frame height: 58 top padding + body + 12 gap + footer + 8 gap + button + 16 bottom padding f:SetHeight(58 + body:GetStringHeight() + 12 + footer:GetStringHeight() + 8 + okBtn:GetHeight() + 16) f:Show() end @@ -15862,7 +15727,6 @@ function UI:ShowFirstTimeSetup() if searchFrame.hoverZone then searchFrame.hoverZone:Hide() end searchFrame.setSmartShowVisible(true) - -- Block editbox interaction during setup searchFrame.setupMode = true searchFrame.editBox:EnableMouse(false) @@ -16231,11 +16095,9 @@ function UI:ShowFirstTimeSetup() local function FinishSetup() EasyFind.db.setupComplete = true - -- Save current position local point, _, relPoint, x, y = searchFrame:GetPoint() EasyFind.db.uiSearchPosition = {point, relPoint, x, y} - -- Destroy overlays & restore normal state searchFrame.setupMode = nil searchFrame.editBox:EnableMouse(true) UI:UpdateSearchBarTheme() -- restore proper backdrop colors @@ -16245,14 +16107,12 @@ function UI:ShowFirstTimeSetup() resizer:Hide() panel:Hide() - -- Restore shift-only drag searchFrame:SetScript("OnDragStart", function(self) if IsShiftKeyDown() then self:StartMoving() end end) - -- Apply preferences from setup checkboxes EasyFind.db.smartShow = smartShowCheckbox:GetChecked() UI:UpdateSmartShow() @@ -16285,11 +16145,9 @@ function UI:FlashLabel(labelText) local originalText = label:GetText() local originalR, originalG, originalB = label:GetTextColor() - -- Set to the hint text label:SetText(labelText) label:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) - -- Create flash animation local flashCount = 0 local ticker ticker = C_Timer.NewTicker(0.3, function() diff --git a/UIPins.lua b/UIPins.lua index 0c0d51d..7e2c9bc 100644 --- a/UIPins.lua +++ b/UIPins.lua @@ -16,9 +16,8 @@ local SIMPLE_FIELDS = { "itemID", "encounterID", "instanceID", "lootSlotName", "lootSourceName", "lootInstanceName", "lootSourceType", "transmogSetID", "factionID", "hasRepBar", "canQueue", "isPvP", "isPvE", - -- Map-search fields. Without these the row's render branch can't - -- recognize the entry as a map result, so the general icon goes - -- missing and the specific icon falls back to the LEFT slot. + -- Required for the row renderer to recognize a map result and + -- place icons in the correct slots. "mapSearchResult", "isZone", "mapID", "zoneName", "pathPrefix", "zoneMapType", "zoneParentMapID", "achievementID", @@ -127,9 +126,6 @@ function UIPins.Pin(data) if not list then return end local clean = CleanForStorage(data) clean.isPinned = true - -- If a stale entry exists (e.g. saved before a new field was added - -- to SIMPLE_FIELDS), replace it in place so the next render picks - -- up the fresh fields. local key = UIPins.GetKey(data) for i, existing in ipairs(list) do if UIPins.GetKey(existing) == key then diff --git a/Utils.lua b/Utils.lua index befbac8..9c956e2 100644 --- a/Utils.lua +++ b/Utils.lua @@ -46,15 +46,11 @@ Utils.tostring = tostring Utils.tonumber = tonumber Utils.ErrorHandler = ErrorHandler ---- Call a protected function safely, suppressing errors during combat lockdown. ---- Returns true + results on success, false on failure. function Utils.SafeCall(func, ...) if InCombatLockdown() then return false end return pcall(func, ...) end ---- Call a protected method safely (e.g. frame:SetPropagateKeyboardInput). ---- Usage: Utils.SafeCallMethod(frame, "SetPropagateKeyboardInput", false) function Utils.SafeCallMethod(obj, method, ...) if InCombatLockdown() then return false end if not obj then return false end @@ -63,9 +59,6 @@ function Utils.SafeCallMethod(obj, method, ...) return pcall(fn, obj, ...) end ---- Protected OnUpdate wrapper. If the handler errors, it self-cancels ---- to prevent per-frame error spam that can freeze the UI. ---- Pass nil handler to clear. function Utils.SafeOnUpdate(frame, handler) if not handler then frame:SetScript("OnUpdate", nil) @@ -80,8 +73,6 @@ function Utils.SafeOnUpdate(frame, handler) end) end ---- Protected C_Timer.After wrapper. Catches errors in the callback ---- so a crash in a delayed call doesn't propagate unhandled. function Utils.SafeAfter(delay, fn) C_Timer.After(delay, function() local ok, err = xpcall(fn, ErrorHandler) @@ -91,14 +82,6 @@ function Utils.SafeAfter(delay, fn) end) end ---- Key-repeat controller shared by UI search and MapTab nav. Holding ---- a key fires the action immediately, waits INITIAL seconds, then ---- ticks at a rate that accelerates from INITIAL toward FAST over ---- ACCEL seconds. Attach OnKeyUp to `Stop(key)` so releasing the key ---- stops the repeat. Pass the key so other keys pressed concurrently ---- don't cancel each other. ---- ---- Returns a table: { Start(key, action), Stop(key?), IsKey(key) }. function Utils.CreateKeyRepeat(frame, initialDelay, fastDelay, accelDuration) initialDelay = initialDelay or 0.30 fastDelay = fastDelay or 0.05 @@ -195,13 +178,9 @@ function Utils.AttachAutocomplete(editBox, opts) return false end - -- Abort if a user keystroke landed between when the search was - -- scheduled and now: WoW defers OnTextChanged by one frame, so - -- the in-flight char hasn't updated typedText yet but is already - -- in the editbox. Calling SetText here would overwrite it, and - -- the deferred OnTextChanged would then see no change vs. - -- typedText (we'd just set it to candidate) and silently drop - -- the keystroke. + -- WoW defers OnTextChanged by one frame, so the in-flight char + -- isn't in typedText yet. SetText here would overwrite it and the + -- deferred OnTextChanged would silently drop the keystroke. local liveText = editBox:GetText() or "" local liveCursor = editBox:GetCursorPosition() or #liveText local hasLiveSuggestion = currentCandidate ~= nil @@ -278,14 +257,9 @@ function Utils.AttachAutocomplete(editBox, opts) local prevLen = #typedText typedText = typed local grew = #typedText > prevLen - -- Smooth-extend: when the user types a character that matches - -- the next character of the current candidate, re-attach the - -- suggestion in-place instead of clearing it and waiting for - -- the throttled re-render. This is the "Chrome omnibox" feel: - -- the highlighted suffix shrinks character by character without - -- flicker. Safe because we run synchronously inside - -- OnTextChanged (post-keystroke), not from the throttle's - -- OnUpdate, so there's no in-flight char to clobber. + -- Smooth-extend the candidate in-place: safe here because we run + -- synchronously inside OnTextChanged (post-keystroke), not from + -- the throttle's OnUpdate, so there's no in-flight char to clobber. local extended = false if grew and currentCandidate and #typed < #currentCandidate @@ -307,13 +281,6 @@ function Utils.AttachAutocomplete(editBox, opts) if onTypedChanged then onTypedChanged(self, typedText, prevText, grew) end end) - -- OnChar smooth-extend hook removed: it raced with OnTextChanged - -- when the user typed quickly, calling SetText mid-keystroke and - -- occasionally swallowing the next character. OnTextChanged below - -- still handles smooth extension when the typed prefix continues - -- to match the candidate, so the suggestion still grows as you - -- type without an extra SetText pass per character. - editBox:HookScript("OnEditFocusLost", StripAutocomplete) local function AcceptAutocomplete(self, source, cursorPos) @@ -448,8 +415,6 @@ function Utils.AttachAutocomplete(editBox, opts) editBox.IsAutocompleteProgrammatic = function() return programmatic end end ---- Scroll a ScrollFrame so that the given child button is visible. ---- Uses the button's top/bottom relative to the scrollChild. function Utils.ScrollToButton(scrollFrame, button) if not scrollFrame or not button then return end local _, _, _, _, btnOffsetY = button:GetPoint(1) @@ -473,33 +438,23 @@ ns.EYE_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\eye" ns.DARK_PANEL_BG = {0.1, 0.1, 0.1, 0.95} ns.SEARCH_WINDOW_FILL_COLOR = {0.052, 0.052, 0.060} ns.RESULT_ICON_SIZE = 18 -ns.SEARCHBAR_HEIGHT = 30 -- base search bar frame height (before font scaling) -ns.SEARCHBAR_FILL = 0.55 -- fraction of bar height filled by text/icon -ns.SEARCHBAR_ICON_SCALE = 0.75 -- icon size relative to editBox height (font glyphs are shorter than line height) -ns.CLEAR_BTN_SIZE = 12 -- base clear button size (before font scaling) +ns.SEARCHBAR_HEIGHT = 30 +ns.SEARCHBAR_FILL = 0.55 +ns.SEARCHBAR_ICON_SCALE = 0.75 +ns.CLEAR_BTN_SIZE = 12 local EasyFindSearchFont = CreateFont("EasyFindSearchFont") local baseFont = Game15Font_Shadow or GameFontNormal EasyFindSearchFont:CopyFontObject(baseFont) EasyFindSearchFont:SetFont((baseFont:GetFont()), 12, select(3, baseFont:GetFont())) 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" --- 9-slice cap covers the leftmost / rightmost 37.5% of the texture --- (0..0.375, 0.625..1) -- the texture itself has the curve in the --- outer 64 tex px (a true semicircle, radius == half texture height) --- followed by 32 tex px of flat top/bottom that buffer the cap/mid --- 9-slice boundary. Cutting in the flat region lets the cap (rendered --- at one horizontal scale) and the mid (rendered at another) join +-- 9-slice cap = outer 37.5% of texture: curve lives in outer 64 px, +-- followed by 32 px flat that buffers the cap/mid join. Cutting in the +-- flat region lets cap and mid (rendered at different scales) join -- without a visible kink at the curve's tangent point. --- --- Display cap_w = 0.75 * h. The curve occupies the OUTER 2/3 of the --- cap (= 0.5 * h = h/2), which is the true semicircle proportion; --- the inner 1/3 (= 0.25 * h) is flat extension before mid begins. local CAP_TEX_RATIO = 0.375 local CAP_DISPLAY_RATIO = 0.75 local TC_LEFT = {0, CAP_TEX_RATIO, 0, 1} @@ -555,26 +510,12 @@ function ns.CreateSearchBorder(frame) ApplyCapWidths(frame) end --- --------------------------------------------------------------------------- --- Rounded-rect 9-slice (combined search bar + results dropdown silhouette) --- --- Used by the container frame that wraps both. Texture is 256x256 with --- corner radius 64 (= 25% on each side), so the 9-slice cap ratio is --- 0.25. Corner cells stay a fixed display size (cornerSize, normally --- h_bar / 2 so it visually matches the bar's pill caps); top/bottom --- edges stretch horizontally, left/right edges stretch vertically, --- center fills the rest. --- --- When the container's height equals 2 * cornerSize, the side edges --- collapse to zero and the silhouette becomes a horizontal pill -- --- same shape the bar alone wants. When the container grows downward --- (results open), only the side edges and center stretch; the --- corners keep their shape. That gives us the "Google search bar --- with dropdown" look in a single primitive. --- --------------------------------------------------------------------------- +-- 9-slice rounded-rect: corners stay fixed size (= bar height / 2 so they +-- match the bar's pill caps); edges stretch. When container height equals +-- 2 * cornerSize, the silhouette collapses to a horizontal pill. local COMBINED_TEX_FILL = "Interface\\AddOns\\EasyFind\\Textures\\CombinedFill" local COMBINED_TEX_BORDER = "Interface\\AddOns\\EasyFind\\Textures\\CombinedBorder" -local CR = 0.25 -- 9-slice corner ratio (cornerSize_tex / texSize) for the combined texture +local CR = 0.25 local TC9 = { tl = {0, CR, 0, CR }, @@ -604,26 +545,22 @@ local function CreateNineSlice(frame, layer, texPath, vertR, vertG, vertB, vertA end local function AnchorNineSlice(frame, n, cornerSize) - -- Corners: pinned to each frame corner with explicit size. n.tl:ClearAllPoints(); n.tl:SetPoint("TOPLEFT"); n.tl:SetSize(cornerSize, cornerSize) n.tr:ClearAllPoints(); n.tr:SetPoint("TOPRIGHT"); n.tr:SetSize(cornerSize, cornerSize) n.bl:ClearAllPoints(); n.bl:SetPoint("BOTTOMLEFT"); n.bl:SetSize(cornerSize, cornerSize) n.br:ClearAllPoints(); n.br:SetPoint("BOTTOMRIGHT"); n.br:SetSize(cornerSize, cornerSize) - -- Top edge stretches between the two top corners; same for bottom. n.tm:ClearAllPoints() n.tm:SetPoint("TOPLEFT", n.tl, "TOPRIGHT") n.tm:SetPoint("BOTTOMRIGHT", n.tr, "BOTTOMLEFT") n.bm:ClearAllPoints() n.bm:SetPoint("TOPLEFT", n.bl, "TOPRIGHT") n.bm:SetPoint("BOTTOMRIGHT", n.br, "BOTTOMLEFT") - -- Left/right edges stretch between top and bottom corners. n.ml:ClearAllPoints() n.ml:SetPoint("TOPLEFT", n.tl, "BOTTOMLEFT") n.ml:SetPoint("BOTTOMRIGHT", n.bl, "TOPRIGHT") n.mr:ClearAllPoints() n.mr:SetPoint("TOPLEFT", n.tr, "BOTTOMLEFT") n.mr:SetPoint("BOTTOMRIGHT", n.br, "TOPRIGHT") - -- Center fills between the four edges. n.mm:ClearAllPoints() n.mm:SetPoint("TOPLEFT", n.tl, "BOTTOMRIGHT") n.mm:SetPoint("BOTTOMRIGHT", n.br, "TOPLEFT") @@ -643,17 +580,12 @@ function ns.CreateRoundedRectBorder(frame) local border = CreateNineSlice(frame, "ARTWORK", COMBINED_TEX_BORDER, BORDER_R, BORDER_G, BORDER_B, 1) frame.combinedBorder = { fill = fill, border = border } ApplyContainerCornerSize(frame) - -- Keep corner cells a constant display size as the frame resizes - -- (results open / close, theme rescale, etc). The bar height drives - -- the corner; if a caller pins it via cbBarHeight the OnSizeChanged - -- still re-anchors against that pinned value. frame:HookScript("OnSizeChanged", ApplyContainerCornerSize) end --- The container's corner radius tracks the BAR height, not the --- container's own (which grows when results open). Callers must pin the --- bar height via this setter once on creation and again whenever --- fontSize / theme changes the bar's pixel height. +-- Corner radius tracks BAR height, not container height (which grows +-- when results open). Callers must pin it via this setter on creation +-- and again whenever fontSize / theme changes the bar's pixel height. function ns.SetRoundedRectBarHeight(frame, h) frame.cbBarHeight = h ApplyContainerCornerSize(frame) @@ -677,10 +609,6 @@ function ns.SetRoundedRectBorderFillColor(frame, r, g, b, a) end end --- A 1-px horizontal divider that runs across the inside of the --- container at the bottom of the bar's content area. Visible only --- when the results dropdown is open; it doubles as the "bar's --- bottom border" the user keeps as the search/results separator. function ns.CreateRoundedRectDivider(frame) if frame.combinedDivider then return frame.combinedDivider end local d = frame:CreateTexture(nil, "ARTWORK") @@ -691,9 +619,6 @@ function ns.CreateRoundedRectDivider(frame) return d end --- Position the divider at `yOffset` below the container's top, with a --- small inset from each side so it doesn't bleed into the rounded --- corners. yOffset should be the bar's height (= bar's bottom edge). function ns.SetRoundedRectDivider(frame, yOffset, shown) local d = frame.combinedDivider if not d then return end @@ -741,7 +666,6 @@ local frameTextScratch = {} function Utils.GetButtonText(btn) if not btn then return nil end - -- Named text children (covers virtually every Blizzard button style) for _, key in ipairs(BUTTON_TEXT_KEYS) do local child = btn[key] if child and child.GetText then @@ -750,13 +674,11 @@ function Utils.GetButtonText(btn) end end - -- Frame's own GetText (ButtonTemplate, etc.) if btn.GetText then local t = btn:GetText() if t then return t end end - -- Fallback: first FontString in regions for i = 1, select("#", btn:GetRegions()) do local region = select(i, btn:GetRegions()) if region and region.GetObjectType and region:GetObjectType() == "FontString" then @@ -782,7 +704,6 @@ function Utils.IsFrameOrChildMouseOver(frame) return false end --- Collects all text from a frame into a single string for fuzzy matching. function Utils.GetAllFrameText(frame) if not frame then return nil end wipe(frameTextScratch) @@ -815,36 +736,29 @@ function Utils.GetAllFrameText(frame) return nil end --- Checks multiple properties/textures to detect if a button is selected or expanded. function Utils.IsButtonSelected(btn) if not btn then return false end - -- Explicit selection properties if btn.isSelected then return true end if btn.selected then return true end - -- Expanded state (tree categories) if btn.collapsed == false then return true end if btn.isExpanded then return true end if btn.expanded then return true end - -- Highlight/selection textures if btn.highlight and btn.highlight:IsShown() then return true end if btn.selectedTexture and btn.selectedTexture:IsShown() then return true end if btn.SelectedTexture and btn.SelectedTexture:IsShown() then return true end - -- Background / selection highlight if btn.Selection and btn.Selection:IsShown() then return true end if btn.selection and btn.selection:IsShown() then return true end if btn.Background and btn.Background:IsShown() then return true end - -- Element data collapsed flag if btn.element and btn.element.collapsed == false then return true end return false end --- Walks a frame hierarchy for a clickable child with exact text match (case-insensitive). function Utils.SearchFrameTree(frame, targetTextLower, maxDepth) maxDepth = maxDepth or 6 local function search(f, depth) @@ -867,7 +781,6 @@ function Utils.SearchFrameTree(frame, targetTextLower, maxDepth) return search(frame, 0) end --- Walks a frame hierarchy for a clickable child whose text contains searchText. function Utils.SearchFrameTreeFuzzy(frame, searchTextLower, maxDepth) maxDepth = maxDepth or 6 local function search(f, depth) @@ -901,14 +814,12 @@ function Utils.ShallowCopy(src) return copy end --- pcall-wrapped IsShown for frames that may be forbidden. function Utils.IsFrameShown(frame) if not frame then return false end local ok, shown = pcall(frame.IsShown, frame) return ok and shown end --- Resolve a dotted path string (e.g. "PVEFrame.Tab1") to the actual frame. function Utils.GetFrameByPath(path) if not path then return nil end local parts = { strsplit(".", path) } @@ -923,7 +834,6 @@ function Utils.GetFrameByPath(path) return current end --- Thin scrollbar using minimal-scrollbar-* atlas textures, overlaid on the right edge. function Utils.CreateMinimalScrollBar(scrollFrame, parent) local THUMB_W = 3 local EDGE_INSET = 4 @@ -952,7 +862,6 @@ function Utils.CreateMinimalScrollBar(scrollFrame, parent) thumb:SetWidth(THUMB_W) thumb:EnableMouse(true) - -- Pill shape: rect body + masked half-circle caps top/bottom. local capH = THUMB_W * 0.5 local thumbBody = thumb:CreateTexture(nil, "ARTWORK") @@ -996,9 +905,6 @@ function Utils.CreateMinimalScrollBar(scrollFrame, parent) if not bar.isDragging then SetThumbNormal() end end) - -- Activity-driven visibility. Bar is shown but at alpha 0 by default; - -- each interaction bumps it to full opacity, and after FADE_HOLD - -- seconds of idle it fades back over FADE_OUT seconds. bar:SetAlpha(0) bar._lastActivity = 0 bar._fadingOut = false @@ -1152,8 +1058,6 @@ function Utils.CreateMinimalScrollBar(scrollFrame, parent) bar:EnableMouseWheel(true) bar:SetScript("OnMouseWheel", function(_, delta) ScrollByDelta(delta) end) - -- Route the host scrollFrame's wheel through the same eased path so - -- wheel events on the content (not just over the thumb) feel smooth. scrollFrame:EnableMouseWheel(true) scrollFrame:SetScript("OnMouseWheel", function(_, delta) ScrollByDelta(delta) end) @@ -1171,13 +1075,6 @@ function Utils.CreateMinimalScrollBar(scrollFrame, parent) return bar end ---- Scroll a ScrollBox to the first element matching matchFn. ---- Tries FindElementDataByPredicate → ScrollToElementData first. ---- Falls back to SetScrollPercentage(fallbackFraction) if the data provider ---- returns nothing (virtual providers with no stored collection). ---- @param scrollBox ScrollBox frame ---- @param matchFn function(elementData) -> bool ---- @param fallbackFraction number 0-1 or nil (skip fallback) function Utils.ScrollBoxScrollTo(scrollBox, matchFn, fallbackFraction) if not scrollBox then return end @@ -1199,10 +1096,6 @@ function Utils.ScrollBoxScrollTo(scrollBox, matchFn, fallbackFraction) end end ---- Find the first visible frame in a ScrollBox whose element data satisfies matchFn. ---- @param scrollBox ScrollBox frame ---- @param matchFn function(btn) -> bool (receives the visible frame, not raw element data) ---- @return Frame or nil function Utils.ScrollBoxFindButton(scrollBox, matchFn) if not scrollBox or not scrollBox.EnumerateFrames then return nil end for _, btn in scrollBox:EnumerateFrames() do @@ -1213,11 +1106,6 @@ function Utils.ScrollBoxFindButton(scrollBox, matchFn) return nil end ---- Click a button safely. Uses Click() which routes through the WoW frame ---- pipeline. Errors from protected functions (e.g. SetTab on Encounter ---- Journal tabs) are caught and suppressed. ---- @param btn Frame with Click or OnClick ---- @param mouseButton string default "LeftButton" function Utils.ClickButton(btn, mouseButton) if not btn then return false end mouseButton = mouseButton or "LeftButton" @@ -1233,8 +1121,6 @@ function Utils.ClickButton(btn, mouseButton) return false end --- Create a grey circle-X clear button (retail quest log style). --- Returns the button; caller must set OnClick and OnEnter scripts. function Utils.CreateClearButton(parent, globalName) local btn = CreateFrame("Button", globalName, parent) btn:SetSize(ns.CLEAR_BTN_SIZE, ns.CLEAR_BTN_SIZE) @@ -1257,13 +1143,9 @@ function Utils.CreateClearButton(parent, globalName) return btn end --- Pool of (menu, rows) tuples keyed by globalName. We never destroy --- WoW frames (they persist for the session), but we cycle through pool --- members so each open uses a freshly-laid-out menu rather than mutating --- one we just hid. This avoids a class of redraw / state-leak bugs where --- the second open of a cached menu inherits stale flags from the first --- close (e.g. backdrop tile dropped by Hide/Show, alpha stuck after --- click, etc.). +-- Pool cycles through members so each open uses a freshly-laid-out menu. +-- Avoids state-leak bugs where the second open of a cached menu inherits +-- stale flags from the first close (backdrop tile, alpha after click, etc). local cursorMenuPool = {} local cursorMenuCounter = 0 @@ -1311,8 +1193,6 @@ end function Utils.ShowCursorMenu(globalName, rows, opts) opts = opts or {} - -- Hide any sibling already-shown instance under this globalName so - -- we never have two cursor menus visible at once. HideOtherMenus(globalName, nil) local menu = FindFreeMenu(globalName) @@ -1484,10 +1364,6 @@ function Utils.ShowCursorMenu(globalName, rows, opts) end end - -- Auto-grow width to fit the widest row's label + icon. Use the - -- caller-provided width as a floor so short menus stay compact. - -- 8px LEFT pad + label + 4px gap + 14px icon (when present) + 8px - -- right pad ≈ label + 34. local needed = width local totalH = 0 for i = 1, shown do @@ -1507,8 +1383,6 @@ function Utils.ShowCursorMenu(globalName, rows, opts) local scale = UIParent:GetEffectiveScale() local x, y = GetCursorPosition() menu:ClearAllPoints() - -- Anchor TOPLEFT to cursor so rows extend down-right like every - -- standard right-click menu (Windows / macOS / Blizzard's own). menu:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", x / scale + (opts.offsetX or 0), y / scale + (opts.offsetY or 0)) menu:Show() @@ -1517,11 +1391,6 @@ end function Utils.ShowPinMenu(globalName, isPinned, onPin, onGuide, onAddAlias, opts, extra) local rows = {} - -- Base section: same three rows on every entry, in a fixed order - -- (Add Alias → Pin → Guide). Each is gated by whether the - -- underlying action is meaningful for the entry; an unsupported - -- one is simply omitted rather than shown disabled, but the - -- relative order of the rows that ARE shown is stable. if onAddAlias then rows[#rows + 1] = { text = "Add Alias", onClick = onAddAlias } end @@ -1530,8 +1399,6 @@ function Utils.ShowPinMenu(globalName, isPinned, onPin, onGuide, onAddAlias, opt rows[#rows + 1] = { text = "Guide", icon = ns.EYE_ICON_TEX, onClick = onGuide } end - -- Extras section: category-specific actions. Collect into a local - -- list first so we can decide whether to emit a separator above it. local extras = {} if extra and extra.onTrack then extras[#extras + 1] = { @@ -1592,24 +1459,6 @@ function Utils.SetIconTexture(textureObj, icon, fallback) end end --- --------------------------------------------------------------------------- --- Addon-wide font selection. --- --- ns.FONT_CHOICES drives the Options dropdown. Each FontString that opts --- into user-selectable fonts goes through ns.RegisterAddonFont(fs, weight, --- sizeOverride, flags) which: --- 1. snapshots the FontString's existing font (path/size/flags) as the --- "Default" baseline so we can revert without remembering Friz Quadrata --- paths, --- 2. records the requested addon weight ("regular" / "semibold" / "bold") --- so non-Default choices know which weight file to load, --- 3. tracks the FontString in a registry, --- 4. immediately applies the current user's choice. --- --- ns.RefreshAddonFont() re-applies the current EasyFind.db.font to every --- registered FontString -- call it from the Options selector callback. Modules --- that want to participate just call RegisterAddonFont after CreateFontString. --- --------------------------------------------------------------------------- local INTER_REGULAR = "Interface\\AddOns\\EasyFind\\Fonts\\Inter-Regular.ttf" local INTER_SEMIBOLD = "Interface\\AddOns\\EasyFind\\Fonts\\Inter-SemiBold.ttf" local INTER_BOLD = "Interface\\AddOns\\EasyFind\\Fonts\\Inter-Bold.ttf" diff --git a/Wizard.lua b/Wizard.lua index e9fbc2f..7ea5eb3 100644 --- a/Wizard.lua +++ b/Wizard.lua @@ -27,11 +27,6 @@ local TEXT_DIM = { 0.55, 0.55, 0.58 } local DOT_FILLED = "Interface\\COMMON\\Indicator-Yellow" --- Wizard FontStrings opt into the user-selectable font system. The --- registry tracks each one so the Options "Font" selector can re-apply --- on change without re-creating the wizard. By default it's "Default" --- (Friz Quadrata via the original GameFont template); picking "Inter" --- swaps each registered string to the matching Inter weight. local function ApplyInter(fs, weight, sizeOverride, flags) if ns.RegisterAddonFont then ns.RegisterAddonFont(fs, weight, sizeOverride, flags) @@ -44,11 +39,6 @@ local pageIdx = 1 local dots = {} local backBtn, nextBtn --- Two visual styles, both monochrome: --- solid : subtle dark fill, dim text that brightens on hover --- ghost : no fill at all, dim text that brightens on hover --- rounded : same TC9 rounded-pill silhouette the keybind buttons use, --- dark gray fill, white text always local function MakeButton(parent, text, variant, w) local b = CreateFrame("Button", nil, parent) local h = (variant == "rounded") and 26 or 24 @@ -62,15 +52,9 @@ local function MakeButton(parent, text, variant, w) if variant == "rounded" then ns.CreateRoundedRectBorder(b) - -- Half this gives the corner radius. ~5px corners read as - -- "rounded but not a pill" -- enough softness to feel - -- modern, not so much it loses button identity. ns.SetRoundedRectBarHeight(b, 10) ns.SetRoundedRectBorderBgAlpha(b, 1) - -- Hide the perimeter ring entirely; at 5-px corners the - -- 256-px border texture downscales to a stairstep that no - -- amount of pixel-snap tweaking smooths over. Just the fill - -- silhouette reads as a clean rounded rectangle. + -- Border ring hidden: 256px texture aliases badly at ~5px corners. if b.combinedBorder and b.combinedBorder.border then for _, t in pairs(b.combinedBorder.border) do t:Hide() @@ -185,8 +169,6 @@ end local function HeaderText(parent, text, font) local fs = parent:CreateFontString(nil, "OVERLAY", font or "GameFontNormalHuge") - -- Inter SemiBold at the size already implied by the GameFont - -- template (the SetFont call below preserves size automatically). ApplyInter(fs, "semibold") fs:SetText(text) fs:SetTextColor(TEXT_PRIM[1], TEXT_PRIM[2], TEXT_PRIM[3], 1) @@ -215,7 +197,6 @@ local function BuildPage1(parent) local version = ns.version or (C_AddOns and C_AddOns.GetAddOnMetadata and C_AddOns.GetAddOnMetadata("EasyFind", "Version")) or "2.0.0" local title = HeaderText(p, "Welcome to EasyFind v" .. version) title:SetPoint("TOP", logo, "BOTTOM", 0, -22) - -- 50% larger than the GameFontNormalHuge baseline. do local path, sz, fl = title:GetFont() if sz then title:SetFont(path, sz * 1.5, fl or "") end @@ -230,10 +211,7 @@ local function BuildPage1(parent) end local function FeatureTile(parent, atlas, file, coords, title, desc, onClick) - -- Holder owns the external anchor (TOPLEFT/TOPRIGHT in BuildPage2). - -- The tile inside is pinned to the holder's CENTER so SetScale - -- grows the tile symmetrically around its midpoint instead of - -- expanding from a corner. + -- Tile anchored to holder CENTER so hover SetScale grows symmetrically. local holder = CreateFrame("Frame", nil, parent) holder:SetSize(222, 112) @@ -242,11 +220,6 @@ local function FeatureTile(parent, atlas, file, coords, title, desc, onClick) tile:SetPoint("CENTER", holder, "CENTER", 0, 0) tile:RegisterForClicks("LeftButtonUp") - -- Soft rounded silhouette via the same TC9 fill the search bar - -- uses; corner radius = ~6px so the box reads as "rounded but not - -- a pill". Hide the bright perimeter ring (it aliases at small - -- corner sizes) and skip a separate edge layer -- the rounded - -- fill alone defines the shape. ns.CreateRoundedRectBorder(tile) ns.SetRoundedRectBarHeight(tile, 10) ns.SetRoundedRectBorderBgAlpha(tile, 0.95) @@ -287,9 +260,6 @@ local function FeatureTile(parent, atlas, file, coords, title, desc, onClick) body:SetPoint("RIGHT", tile, "RIGHT", -12, 0) ApplyInter(body, "regular") - -- Hover lift: scale the tile up symmetrically. Because the tile - -- is anchored to the holder's CENTER, SetScale grows it outward - -- around that midpoint rather than expanding from a corner. tile:SetScript("OnEnter", function(self) self:SetScale(1.04) if self.combinedBorder and self.combinedBorder.fill then @@ -317,9 +287,6 @@ end local function BuildPage2(parent) local p = MakePage(parent) - -- Page 2 has two states: a 4-tile grid and per-tile detail views. - -- Both states are full-page subframes; clicking a tile swaps grid - -- for that tile's detail; the in-detail back arrow returns to grid. local grid = CreateFrame("Frame", nil, p) grid:SetAllPoints(p) @@ -384,8 +351,7 @@ local function BuildPage2(parent) function() ShowDetail(d2) end) t2:SetPoint("TOPRIGHT", grid, "TOPRIGHT", -38, -96) - -- Anchor lower-row tiles directly to the grid so a tile's hover - -- scale doesn't ripple position changes onto its neighbor. + -- Anchor each lower tile to the grid so a hover scale doesn't move its sibling. local t3 = FeatureTile(grid, "UI-HUD-MicroMenu-SpellbookAbilities-Up", nil, nil, "Item/Ability Use", "Cast, use, equip, summon, swap, or open supported results directly from the list.", @@ -398,16 +364,11 @@ local function BuildPage2(parent) function() ShowDetail(d4) end) t4:SetPoint("TOPRIGHT", grid, "TOPRIGHT", -38, -222) - -- Reset to grid each time the page is entered so navigating away - -- and back doesn't leave a stale detail view open. p.OnEnter = ShowGrid return p end --- One capture-button widget per binding. Rounded gray fill matching --- the rest of the modern EasyFind controls. Right-click clears the --- binding, Esc cancels capture. local kbWidgets = {} local kbWaitingFor @@ -439,12 +400,9 @@ local function CreateKbWidget(parent, action, label) btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") w.btn = btn - -- Rounded silhouette (corner radius = btnHeight/2 = 20px = full pill). ns.CreateRoundedRectBorder(btn) ns.SetRoundedRectBarHeight(btn, 40) ns.SetRoundedRectBorderBgAlpha(btn, 1) - -- Hide the perimeter ring; the fill silhouette alone reads as a - -- clean rounded rectangle and avoids the aliased outline. if btn.combinedBorder and btn.combinedBorder.border then for _, t in pairs(btn.combinedBorder.border) do t:Hide() @@ -540,7 +498,6 @@ local function BuildPage3(parent) local mapKb = CreateKbWidget(p, MAP_ACTION, "Map Search Tab") kbWidgets = { uiKb, mapKb } - -- Stacked layout: search bar on top, map search below. uiKb.label:SetPoint("RIGHT", uiKb.btn, "LEFT", -14, 0) uiKb.btn:SetPoint("TOP", sub, "BOTTOM", 40, -38) @@ -570,8 +527,6 @@ local function CreateFrameOnce() local f = CreateFrame("Frame", "EasyFindWizard", UIParent) f:SetSize(WIZ_W, WIZ_H) - -- Shrink the wizard's overall footprint without resizing internal - -- layout (fonts, buttons, anchors all stay the same pixel sizes). f:SetScale(0.88) f:SetPoint("CENTER") f:SetFrameStrata("FULLSCREEN_DIALOG") @@ -584,16 +539,10 @@ local function CreateFrameOnce() f:SetScript("OnDragStop", f.StopMovingOrSizing) frame = f - -- Same rounded rect 9-slice the search bar / results panel use. - -- Pin a small bar height so the corner radius is fixed at ~10px - -- instead of half the frame height. Tint the fill dark gray - -- (not pure black) so it reads as a panel rather than a void. ns.CreateRoundedRectBorder(f) ns.SetRoundedRectBarHeight(f, 16) ns.SetRoundedRectBorderBgAlpha(f, PANEL_BG_ALPHA) - -- Hide the bright border ring; its corner cells render as visible - -- horizontal bands against the gradient fill, breaking the smooth - -- top-to-bottom gloss. The fill silhouette is enough. + -- Border ring hidden: its corner cells band against the gradient fill. if f.combinedBorder and f.combinedBorder.border then for _, t in pairs(f.combinedBorder.border) do t:Hide() @@ -605,13 +554,8 @@ local function CreateFrameOnce() end end - -- Glossy sheen: one continuous vertical gradient mapped across the - -- 9-slice. Each cell receives gradient stops sampled from its - -- vertical position in the frame (top corners get the top of the - -- ramp, middle row spans the bulk of the gradient, bottom corners - -- get the bottom). The result reads as one smooth top-to-bottom - -- gloss across the whole panel; the texture's alpha handles the - -- rounded silhouette so nothing pokes past the corners. + -- Single vertical gradient mapped across the 9-slice; each cell's + -- gradient stops are sampled from its vertical position in the frame. local function ApplyGloss(self) local fill = self.combinedBorder and self.combinedBorder.fill if not fill then return end @@ -619,13 +563,10 @@ local function CreateFrameOnce() if not H or H <= 0 then return end local corner = (self.cbBarHeight or 32) / 2 - -- Visible banding from 8-bit color comes from too few color - -- steps spread across too many pixels. To make bands - -- effectively invisible we PACK a wide brightness range into - -- the transition zone -- many 8-bit color values per pixel - -- means each band is only a couple pixels tall. Smoothstep - -- on top so the slope varies and bands can't space evenly. - local DARK_FRAC = 0.90 -- top 90% pure dark base; transition concentrated in bottom 10% + -- Pack the brightness ramp into the bottom 10% so 8-bit banding + -- collapses into a couple-pixel transition; smoothstep so the + -- slope varies and bands cannot space evenly. + local DARK_FRAC = 0.90 local function smoothstep(t) if t <= 0 then return 0 end if t >= 1 then return 1 end @@ -636,14 +577,13 @@ local function CreateFrameOnce() local t = y / H if t < DARK_FRAC then t = 0 else t = smoothstep((t - DARK_FRAC) / (1 - DARK_FRAC)) end - return lerp(0.022, 0.20, t), -- r - lerp(0.022, 0.20, t), -- g - lerp(0.030, 0.22, t) -- b + return lerp(0.022, 0.20, t), + lerp(0.022, 0.20, t), + lerp(0.030, 0.22, t) end - -- Relaxed pixel snapping lets the GPU sub-pixel-blend the - -- vertex colors instead of hard-aligning each color stop to - -- the nearest pixel row, which softens the transitions. + -- Relaxed pixel snapping lets the GPU sub-pixel-blend vertex + -- colors instead of snapping color stops to the nearest pixel row. for _, cell in pairs(fill) do if cell.SetSnapToPixelGrid then cell:SetSnapToPixelGrid(false) end if cell.SetTexelSnappingBias then cell:SetTexelSnappingBias(0) end @@ -653,7 +593,7 @@ local function CreateFrameOnce() if not cell then return end local r1, g1, b1 = colorAtY(yTop) local r2, g2, b2 = colorAtY(yBot) - -- VERTICAL gradient: first color = bottom, second = top. + -- VERTICAL: first color is bottom, second is top. cell:SetGradient("VERTICAL", CreateColor(r2, g2, b2, 1), CreateColor(r1, g1, b1, 1)) @@ -676,8 +616,7 @@ local function CreateFrameOnce() ApplyGloss(f) f:HookScript("OnSizeChanged", ApplyGloss) - -- Crisp X built from two rotated 1px lines so it stays sharp at - -- any UI scale (no font hinting, no texture filtering blur). + -- Two rotated 1px lines so the X stays sharp at any UI scale. local closeBtn = CreateFrame("Button", nil, f) closeBtn:SetSize(18, 18) closeBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -10, -10) @@ -701,8 +640,6 @@ local function CreateFrameOnce() local pageHost = CreateFrame("Frame", nil, f) pageHost:SetPoint("TOPLEFT", f, "TOPLEFT", 8, -8) - -- Leave room for the inset banner: BANNER_INSET (6) + BANNER_H (38) - -- + a small gap (4) so page content doesn't kiss the banner top. pageHost:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -8, 6 + BANNER_H + 4) pages = { @@ -711,11 +648,6 @@ local function CreateFrameOnce() BuildPage3(pageHost), } - -- Footer banner: a self-contained rounded panel floating inside - -- the main window with the same corner radius as the wizard - -- frame, inset a few pixels on every edge so it reads as its own - -- element. Slightly darker than the gradient base but not pitch - -- black -- enough contrast to anchor the back/continue cluster. local BANNER_INSET = 6 local footer = CreateFrame("Frame", nil, f) footer:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", BANNER_INSET, BANNER_INSET) @@ -735,13 +667,9 @@ local function CreateFrameOnce() for _, t in pairs(footer.combinedBorder.border) do t:Hide() end end - -- Page indicator dots: vertically centered in the banner, anchored - -- to the LEFT so they stay flush left. local DOT_GAP = 14 local DOT_SZ = 9 local DOT_ACTIVE = 11 - -- Dots and buttons live INSIDE the banner so they render above - -- the banner's rounded fill instead of being hidden behind it. for i = 1, #pages do local d = footer:CreateTexture(nil, "OVERLAY") d:SetSize(DOT_SZ, DOT_SZ) @@ -750,9 +678,6 @@ local function CreateFrameOnce() dots[i] = d end - -- Right cluster: [Back] [Continue]. Continue is the only action - -- button (rounded fill, white text); Back stays as a quiet ghost - -- link. To skip, the user clicks the X in the top-right. nextBtn = MakeButton(footer, "Continue", "rounded", 96) nextBtn:SetPoint("RIGHT", footer, "RIGHT", -10, 0) From 2b295d257642bae6d68f19f0251be493a46433c6 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 12 May 2026 03:04:00 -0400 Subject: [PATCH 096/103] Drop dead Utils fallbacks and forwarding wrappers Utils loads before MapTab and Wizard per .toc order, so the `Utils.SafeAfter or function(...)` fallbacks are dead code that would silently bypass xpcall safety if ever taken. Replace with direct references. Remove MapTab.GetTopAncestor (no callers) and MapSearch:GetMapPath (pure forwarding shim around the file-scope local; collapse its four self:GetMapPath sites to the direct call). --- MapSearch.lua | 12 ++++-------- MapTab.lua | 6 ++---- Wizard.lua | 2 +- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/MapSearch.lua b/MapSearch.lua index dd0097e..8d0e913 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -2070,7 +2070,7 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) end -- Ancestor target: highlight breadcrumb directly, else DCA overshoots. - local currentParentChain = self:GetMapPath(currentMapID) + local currentParentChain = GetMapPath(currentMapID) for i = 1, #currentParentChain - 1 do if currentParentChain[i].mapID == targetMapID then DebugPrint("[EasyFind] Target is ancestor of current map, highlighting breadcrumb") @@ -2175,8 +2175,8 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) end end - local targetParentPath = self:GetMapPath(targetParentMapID) - local currentPath = self:GetMapPath(currentMapID) + local targetParentPath = GetMapPath(targetParentMapID) + local currentPath = GetMapPath(currentMapID) DebugPrint("[EasyFind] Target parent path:") for i, p in ipairs(targetParentPath) do @@ -2254,10 +2254,6 @@ function MapSearch:HighlightZoneOnMap(targetMapID, zoneName) self:HighlightBreadcrumbForNavigation(dcaMapID, targetMapID, targetParentPath, dcaIndex) end -function MapSearch:GetMapPath(mapID) - return GetMapPath(mapID) -end - function MapSearch:HighlightBreadcrumbForNavigation(dcaMapID, finalTargetMapID, targetParentPath, dcaIndex) DebugPrint("[EasyFind] HighlightBreadcrumbForNavigation: DCA=", dcaMapID, "finalTarget=", finalTargetMapID) @@ -2300,7 +2296,7 @@ function MapSearch:HighlightBreadcrumbForNavigation(dcaMapID, finalTargetMapID, else DebugPrint("[EasyFind] No button found, walking current path for fallback") local currentMapID = WorldMapFrame:GetMapID() - local currentPath = self:GetMapPath(currentMapID) + local currentPath = GetMapPath(currentMapID) for i = 1, #currentPath - 1 do local breadcrumbBtn = self:FindBreadcrumbButton(navBar, currentPath[i].mapID) if breadcrumbBtn and breadcrumbBtn:IsShown() then diff --git a/MapTab.lua b/MapTab.lua index 470e92a..30369cd 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -5,8 +5,8 @@ ns.MapTab = MapTab local Utils = ns.Utils local MapUtils = ns.MapUtils -local SafeAfter = Utils and Utils.SafeAfter or function(delay, fn) C_Timer.After(delay, fn) end -local tinsert = Utils and Utils.tinsert or table.insert +local SafeAfter = Utils.SafeAfter +local tinsert = Utils.tinsert local tremove = table.remove local GOLD_COLOR = ns.GOLD_COLOR or {1.0, 0.82, 0.0} @@ -38,8 +38,6 @@ local HEADER_POOL_RETAIN = 40 local SECTION_POOL_RETAIN = 12 -MapTab.GetTopAncestor = function(_, mapID) return GetTopAncestor(mapID) end - local worldChildrenCache = {} local function GetWorldChildren(mapID) if not mapID or mapID == 0 then return nil end diff --git a/Wizard.lua b/Wizard.lua index 7ea5eb3..9dd0906 100644 --- a/Wizard.lua +++ b/Wizard.lua @@ -5,7 +5,7 @@ ns.Wizard = Wizard local Utils = ns.Utils local SafeCallMethod = Utils.SafeCallMethod -local SafeAfter = Utils.SafeAfter or function(d, fn) C_Timer.After(d, fn) end +local SafeAfter = Utils.SafeAfter local CreateFrame = CreateFrame local UIParent = UIParent From ba724e35dbdcd85dec859f0de359b8605e5ea8c4 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 12 May 2026 03:20:36 -0400 Subject: [PATCH 097/103] Route protected calls and timers through Utils safety helpers Replace bare EnableKeyboard / SetPropagateKeyboardInput calls with Utils.SafeCallMethod so they no-op under combat lockdown instead of tainting. Replace guarded C_Timer.After patterns with Utils.SafeAfter (xpcall-wrapped). Wrap user-interaction OnUpdate handlers (calculator copy watcher, pending search frames, dropdown dismissers, map tab pending, setup glow, resizer) with Utils.SafeOnUpdate so a crash self-cancels the ticker instead of spamming and freezing the UI. Hoist three inline tsort comparators in MapSearch.lua and four in MapTab.lua to module-scope locals to avoid per-call closure allocation in per-keystroke and per-pin hot paths. Branch luacheck and lua syntax remain clean. --- Database.lua | 8 +-- DatabaseDynamic.lua | 12 +--- MapSearch.lua | 31 +++++----- MapTab.lua | 25 ++++---- Options.lua | 6 +- UI.lua | 143 ++++++++++++++++++-------------------------- Wizard.lua | 6 +- 7 files changed, 92 insertions(+), 139 deletions(-) diff --git a/Database.lua b/Database.lua index 30e0531..6b32081 100644 --- a/Database.lua +++ b/Database.lua @@ -1568,13 +1568,9 @@ function Database:PopulateDynamicLoot(scanAllSpecs) lootSpecsScanned[sp.classID .. "-" .. sp.specID] = true end RebuildLootSearchData() - if C_Timer and C_Timer.After then - C_Timer.After(0, function() - collectgarbage("step", 200) - end) - else + Utils.SafeAfter(0, function() collectgarbage("step", 200) - end + end) end function Database:PopulateDynamicLootAsync(done, scanAllSpecs) diff --git a/DatabaseDynamic.lua b/DatabaseDynamic.lua index 05788b0..91a4c8c 100644 --- a/DatabaseDynamic.lua +++ b/DatabaseDynamic.lua @@ -174,11 +174,7 @@ function Database:LoadDeferredSyncProvidersStaggered() if not provider.asyncFn and not (provider.loaded and not provider.dirty) then RunDynamicProvider(self, provider, function() - if C_Timer and C_Timer.After then - C_Timer.After(0, step) - else - step() - end + Utils.SafeAfter(0, step) end) return end @@ -238,11 +234,7 @@ function Database:LoadHeavyDynamicSearchData(onDone) if provider.asyncFn then RunDynamicProvider(self, provider, function(changed) if changed then anyChanged = true end - if C_Timer and C_Timer.After then - C_Timer.After(0.05, step) - else - step() - end + Utils.SafeAfter(0.05, step) end) return end diff --git a/MapSearch.lua b/MapSearch.lua index 8d0e913..2ce4f06 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -11,10 +11,20 @@ local tinsert, tsort, tconcat, tremove = Utils.tinsert, Utils.tsort, Utils.tconc local sfind, slower, ssub = Utils.sfind, Utils.slower, Utils.ssub local mmin, mmax, mpi, mfloor = Utils.mmin, Utils.mmax, Utils.mpi, Utils.mfloor local pcall, tostring = Utils.pcall, Utils.tostring +local SafeAfter = Utils.SafeAfter local GetMapParentID = MapUtils.GetParentMapID local GetMapPath = MapUtils.GetMapPath local ZONE_ABBREVIATIONS = MapUtils.ZONE_ABBREVIATIONS +local function ZoneMatchLess(a, b) + if a.score ~= b.score then return a.score > b.score end + return a.name < b.name +end + +local function PinYAsc(a, b) + return (a.y or 0) < (b.y or 0) +end + local GOLD_COLOR = ns.GOLD_COLOR local YELLOW_HIGHLIGHT = ns.YELLOW_HIGHLIGHT local TOOLTIP_BORDER = ns.TOOLTIP_BORDER @@ -743,7 +753,7 @@ function MapSearch:CreateFilterDropdown(globalName, options, dbKey, toggleBtn, a end end) - dropdown:SetScript("OnUpdate", function(self) + Utils.SafeOnUpdate(dropdown, function(self) if self:IsShown() and IsMouseButtonDown("LeftButton") then if not self:IsMouseOver() and not toggleBtn:IsMouseOver() then self:Hide() @@ -1246,12 +1256,7 @@ function MapSearch:SearchZones(query) end end - tsort(matches, function(a, b) - if a.score ~= b.score then - return a.score > b.score - end - return a.name < b.name - end) + tsort(matches, ZoneMatchLess) if #matches > 0 then CachePut(cache, query, matches) @@ -2966,13 +2971,9 @@ do -- Reset helpers are below this block, so go through the namespace. if ns.MapSearch.ResetSearchZonesCache then ns.MapSearch.ResetSearchZonesCache() end if ns.MapSearch.ResetSearchPoisCache then ns.MapSearch.ResetSearchPoisCache() end - if C_Timer and C_Timer.After then - C_Timer.After(0.2, function() - if ns.MapSearch.BuildWorldZoneCache then ns.MapSearch:BuildWorldZoneCache() end - end) - elseif ns.MapSearch.BuildWorldZoneCache then - ns.MapSearch:BuildWorldZoneCache() - end + SafeAfter(0.2, function() + if ns.MapSearch.BuildWorldZoneCache then ns.MapSearch:BuildWorldZoneCache() end + end) end) end @@ -4513,7 +4514,7 @@ function MapSearch:ShowMultipleWaypoints(instances) end -- Sort north-to-south (ascending y) so southern pins render on top - tsort(instances, function(a, b) return (a.y or 0) < (b.y or 0) end) + tsort(instances, PinYAsc) -- Show each instance with pin, highlight box, and indicator for i, instance in ipairs(instances) do diff --git a/MapTab.lua b/MapTab.lua index 30369cd..d9c4492 100644 --- a/MapTab.lua +++ b/MapTab.lua @@ -16,6 +16,9 @@ local GameTooltip = GameTooltip local GameTooltip_Hide = GameTooltip_Hide local C_Timer = C_Timer +local function NameLess(a, b) return a.name < b.name end +local function ZoneMapIDDesc(a, b) return (a.zoneMapID or 0) > (b.zoneMapID or 0) end + local TAB_W, TAB_H = 42, 55 local TAB_ICON_SIZE = 20 local TAB_ICON_GOLD = {1.00, 0.82, 0.00} @@ -73,7 +76,7 @@ local function GetWorldChildren(mapID) end end end - table.sort(result, function(a, b) return a.name < b.name end) + table.sort(result, NameLess) worldChildrenCache[mapID] = result return result end @@ -129,7 +132,7 @@ local function SchedulePendingSearch(editBox, typed, grew) if not pendingSearchFrame then pendingSearchFrame = CreateFrame("Frame") pendingSearchFrame:Hide() - pendingSearchFrame:SetScript("OnUpdate", function(self) + Utils.SafeOnUpdate(pendingSearchFrame, function(self) self:Hide() local box = pendingSearchEditBox local text = pendingSearchText or "" @@ -1021,9 +1024,7 @@ local function RenderRows(scrollChild, pinned, localEntries, globalEntries, rece if #variants == 1 then placeRow(variants[1], 18, e.name) else - table.sort(variants, function(a, b) - return (a.zoneMapID or 0) > (b.zoneMapID or 0) - end) + table.sort(variants, ZoneMapIDDesc) local subKey = groupKey .. ":var:" .. n local stored = collapsedDb[subKey] local subCollapsed = stored ~= false @@ -1424,9 +1425,7 @@ function MapTab:RunSearch(text) local removed = {} for _, list in pairs(byName) do if #list >= 2 then - table.sort(list, function(a, b) - return (a.zoneMapID or 0) > (b.zoneMapID or 0) - end) + table.sort(list, ZoneMapIDDesc) for j = 1, #list do removed[list[j]] = true end versionGroups = versionGroups or {} versionGroups[#versionGroups + 1] = { @@ -1508,14 +1507,10 @@ local function EnsureNavFrame() if navFrame then return navFrame end if not panel then return nil end navFrame = CreateFrame("Frame", nil, panel) - navFrame:EnableKeyboard(false) - navFrame:SetPropagateKeyboardInput(true) + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + Utils.SafeCallMethod(navFrame, "SetPropagateKeyboardInput", true) navFrame:SetScript("OnKeyDown", function(self, key) - if HandleNavKey(key, false) then - self:SetPropagateKeyboardInput(false) - else - self:SetPropagateKeyboardInput(true) - end + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", not HandleNavKey(key, false)) end) navFrame:SetScript("OnKeyUp", function(_, key) if navKeyRepeat and navKeyRepeat.IsKey(key) then diff --git a/Options.lua b/Options.lua index 782c996..d4ef5f0 100644 --- a/Options.lua +++ b/Options.lua @@ -128,11 +128,7 @@ local function ResetOptionsPosition() end local function RunSoon(fn) - if C_Timer and C_Timer.After then - C_Timer.After(0, fn) - else - fn() - end + Utils.SafeAfter(0, fn) end local function ClearMapRuntime() diff --git a/UI.lua b/UI.lua index 698434b..902c6cf 100644 --- a/UI.lua +++ b/UI.lua @@ -1769,7 +1769,7 @@ function UI:EnsureCalculatorCopyBox() box:SetAlpha(0) box:SetFontObject(GameFontNormal) box:SetAutoFocus(false) - box:EnableKeyboard(false) + Utils.SafeCallMethod(box, "EnableKeyboard", false) box:SetMaxLetters(0) box:SetScript("OnKeyDown", function(self, key) if UI:HandleCalculatorCopyKey(key) then @@ -1836,15 +1836,13 @@ function UI:CopyCalculatorResult(result, source) UI._calculator.copyToken = (UI._calculator.copyToken or 0) + 1 local token = UI._calculator.copyToken - if C_Timer and C_Timer.After then - C_Timer.After(0, function() - local copyBox = UI._calculator.copyBox - if UI._calculator.copyToken ~= token or not copyBox then return end - if copyBox:GetText() ~= result then return end - copyBox:SetFocus() - copyBox:HighlightText(0, -1) - end) - end + Utils.SafeAfter(0, function() + local copyBox = UI._calculator.copyBox + if UI._calculator.copyToken ~= token or not copyBox then return end + if copyBox:GetText() ~= result then return end + copyBox:SetFocus() + copyBox:HighlightText(0, -1) + end) return true end @@ -1900,7 +1898,7 @@ function UI:EnsureCalculatorCopyWatcher() UI:ReleaseCalculatorCopyBox() end end) - frame:SetScript("OnUpdate", function(self) + Utils.SafeOnUpdate(frame, function(self) if not UI._calculator.activeData or not UI._calculator.activeResult then UI._calculator.ctrlWasDown = nil UI._calculator.copyKeyWasDown = nil @@ -1918,13 +1916,9 @@ function UI:EnsureCalculatorCopyWatcher() and ((IsKeyDown("C") or IsKeyDown("c")) and true or false) if copyDown and not UI._calculator.copyKeyWasDown then UI:RearmActiveCalculatorCopy("confirm") - if C_Timer and C_Timer.After then - C_Timer.After(0, function() - UI:ConfirmCalculatorCopied() - end) - else + Utils.SafeAfter(0, function() UI:ConfirmCalculatorCopied() - end + end) end UI._calculator.copyKeyWasDown = copyDown and true or nil end) @@ -2099,9 +2093,9 @@ function UI:PlayCalculatorCopyFlash(row, part) if tex.anim then if tex.anim:IsPlaying() then tex.anim:Stop() end tex.anim:Play() - elseif C_Timer and C_Timer.After then + else tex:SetAlpha(0.35) - C_Timer.After(0.35, function() + Utils.SafeAfter(0.35, function() if tex then tex:SetAlpha(0.0) tex:Hide() @@ -2142,23 +2136,14 @@ function UI:ConfirmCalculatorCopied() if popupCopy then self:UpdateCalculatorPopupCopyVisual(UI._calculator.popupData) end - if C_Timer and C_Timer.After then - C_Timer.After(0, function() - UI:ReleaseCalculatorCopyBox("confirm") - if popupCopy then - UI:RestoreCalculatorPopupFocus() - else - UI:RestoreSearchFocusAfterCalculatorCopy() - end - end) - else - self:ReleaseCalculatorCopyBox("confirm") + Utils.SafeAfter(0, function() + UI:ReleaseCalculatorCopyBox("confirm") if popupCopy then - self:RestoreCalculatorPopupFocus() + UI:RestoreCalculatorPopupFocus() else - self:RestoreSearchFocusAfterCalculatorCopy() + UI:RestoreSearchFocusAfterCalculatorCopy() end - end + end) return true end @@ -2231,18 +2216,16 @@ function UI:HandleCalculatorPasteIntoSearch(editBox, key) local expected = UI._calculator.copyCompleteValue if expected == "" then return false end - if C_Timer and C_Timer.After then - C_Timer.After(0, function() - if not searchFrame or searchFrame.editBox ~= editBox then return end - if not editBox:IsVisible() then return end - local current = editBox:GetText() or "" - if current ~= expected and strtrim(current) ~= strtrim(expected) then return end - if editBox.ResetPendingSearch then editBox:ResetPendingSearch() end - UI:HideResults() - editBox:SetFocus() - editBox:SetCursorPosition(#current) - end) - end + Utils.SafeAfter(0, function() + if not searchFrame or searchFrame.editBox ~= editBox then return end + if not editBox:IsVisible() then return end + local current = editBox:GetText() or "" + if current ~= expected and strtrim(current) ~= strtrim(expected) then return end + if editBox.ResetPendingSearch then editBox:ResetPendingSearch() end + UI:HideResults() + editBox:SetFocus() + editBox:SetCursorPosition(#current) + end) return false end @@ -3200,7 +3183,7 @@ function UI:CreateSearchFrame() local pendingUISearchDue = 0 local pendingUISearchFrame = CreateFrame("Frame") pendingUISearchFrame:Hide() - pendingUISearchFrame:SetScript("OnUpdate", function(self) + Utils.SafeOnUpdate(pendingUISearchFrame, function(self) if GetTime() < pendingUISearchDue then return end self:Hide() local typedNow = pendingUISearchText @@ -3944,13 +3927,9 @@ function UI:CreateSearchFrame() if UI:IsCalculatorCopyConfirmKey(key) then UI:RearmActiveCalculatorCopy("confirm") - if C_Timer and C_Timer.After then - C_Timer.After(0, function() - UI:ConfirmCalculatorCopied() - end) - else + Utils.SafeAfter(0, function() UI:ConfirmCalculatorCopied() - end + end) -- Ctrl+C must not leak to gameplay keybinds. The hidden editbox is -- already focused and selected; swallowing propagation still lets -- the client copy that selected text. @@ -4816,18 +4795,14 @@ function UI:HandleQuickFilterTextChanged(editBox) if self:UpdateQuickFilterSuggestions(editBox) then if editBox and editBox.ResetPendingSearch then editBox:ResetPendingSearch() end if editBox and editBox.UpdateAutocomplete then - if C_Timer and C_Timer.After then - local box = editBox - C_Timer.After(0, function() - if box and box:IsVisible() and box:HasFocus() - and UI._quickFilterSuggestionsActive - and box.UpdateAutocomplete then - box.UpdateAutocomplete() - end - end) - else - editBox.UpdateAutocomplete() - end + local box = editBox + Utils.SafeAfter(0, function() + if box and box:IsVisible() and box:HasFocus() + and UI._quickFilterSuggestionsActive + and box.UpdateAutocomplete then + box.UpdateAutocomplete() + end + end) end return true end @@ -6532,7 +6507,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end) - classFlyout:SetScript("OnUpdate", function(self) + Utils.SafeOnUpdate(classFlyout, function(self) if self:IsKeyboardEnabled() then return end if not self:IsMouseOver() and not specPopup:IsMouseOver() then if not self._leaveTimer then @@ -6916,7 +6891,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- check, right-clicking outside dismisses the search bar (whose handler -- listens for GLOBAL_MOUSE_DOWN regardless of button) but leaves the -- filter dropdown stuck open. - dropdown:SetScript("OnUpdate", function(self) + Utils.SafeOnUpdate(dropdown, function(self) if self:IsShown() and (IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton")) then if not self:IsMouseOver() and not toggleBtn:IsMouseOver() then @@ -12302,18 +12277,16 @@ function UI:HideResults() idleTrimSerial = idleTrimSerial + 1 local serial = idleTrimSerial - if C_Timer and C_Timer.After then - C_Timer.After(60, function() - if serial ~= idleTrimSerial then return end - if resultsFrame and resultsFrame:IsShown() then return end - if ns.Database and ns.Database.TrimSearchMemory then - ns.Database:TrimSearchMemory() - end - if ns.MapSearch and ns.MapSearch.TrimSearchMemory then - ns.MapSearch:TrimSearchMemory() - end - end) - end + Utils.SafeAfter(60, function() + if serial ~= idleTrimSerial then return end + if resultsFrame and resultsFrame:IsShown() then return end + if ns.Database and ns.Database.TrimSearchMemory then + ns.Database:TrimSearchMemory() + end + if ns.MapSearch and ns.MapSearch.TrimSearchMemory then + ns.MapSearch:TrimSearchMemory() + end + end) end function UI:ShowPinnedItems() @@ -13956,7 +13929,7 @@ local function RevealEJEncounter(step) end end reveal() - if C_Timer and C_Timer.After then C_Timer.After(0.05, reveal) end + Utils.SafeAfter(0.05, reveal) end -- Direct open mode - programmatically navigates to the target as far as possible. @@ -15753,7 +15726,7 @@ function UI:ShowFirstTimeSetup() -- Gentle pulse on the gold fill local pulseUp = true local pulseAlpha = 0.20 - glow:SetScript("OnUpdate", function(self, elapsed) + Utils.SafeOnUpdate(glow, function(self, elapsed) if pulseUp then pulseAlpha = pulseAlpha + elapsed * 0.12 if pulseAlpha >= 0.35 then pulseAlpha = 0.35; pulseUp = false end @@ -15863,7 +15836,7 @@ function UI:ShowFirstTimeSetup() stopResize(self) end) - resizer:SetScript("OnUpdate", function(self) + Utils.SafeOnUpdate(resizer, function(self) if not self.dragging then return end -- If the user released the button outside the hitbox, OnMouseUp -- won't fire - bail out when we detect the button is no longer down. @@ -16124,14 +16097,14 @@ function UI:ShowFirstTimeSetup() gotItBtn:SetScript("OnClick", FinishSetup) -- Escape closes the whole tutorial from the positioning panel. - panel:EnableKeyboard(true) - panel:SetPropagateKeyboardInput(true) + Utils.SafeCallMethod(panel, "EnableKeyboard", true) + Utils.SafeCallMethod(panel, "SetPropagateKeyboardInput", true) panel:SetScript("OnKeyDown", function(self, key) if key == "ESCAPE" then - self:SetPropagateKeyboardInput(false) + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) FinishSetup() else - self:SetPropagateKeyboardInput(true) + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) end end) diff --git a/Wizard.lua b/Wizard.lua index 9dd0906..6b09169 100644 --- a/Wizard.lua +++ b/Wizard.lua @@ -695,14 +695,14 @@ local function CreateFrameOnce() f:SetScript("OnKeyDown", function(self, key) if kbWaitingFor then - self:SetPropagateKeyboardInput(true) + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) return end if key == "ESCAPE" then - self:SetPropagateKeyboardInput(false) + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) FinishWizard(false) else - self:SetPropagateKeyboardInput(true) + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) end end) From 39595480b2c432e68ebadd38b659cbb8b7a95b7b Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 12 May 2026 04:50:36 -0400 Subject: [PATCH 098/103] Centralize rounded-rect fill and border painters in Utils Add ns.SetRoundedRectFill (color + alpha + optional pixel-snap-off), ns.SetRoundedRectBorderColor, and ns.SetRoundedRectBorderEdgeShown to Utils.lua. Replace per-file iteration over frame.combinedBorder in UI, Options, Wizard, and Rescaler (SetCalculatorRoundedFill, HideCalculatorRoundedBorder, PaintRoundedFill, TintRoundedFill, SetModernButtonFill, SetModernButtonAlpha, HideRoundedFrameBorder, HideRoundedBorder, MakeButton/FeatureTile/footer/page-frame inline loops) with the shared helpers. All combinedBorder pairs() iteration now lives in Utils.lua. Branch luacheck and lua syntax remain clean. --- Options.lua | 42 +++++---------------------- Rescaler.lua | 10 ++----- UI.lua | 42 +++++---------------------- Utils.lua | 32 ++++++++++++++++++++ Wizard.lua | 82 +++++++++------------------------------------------- 5 files changed, 62 insertions(+), 146 deletions(-) diff --git a/Options.lua b/Options.lua index d4ef5f0..37e4a93 100644 --- a/Options.lua +++ b/Options.lua @@ -205,18 +205,11 @@ local function SyncOptionControls() if optionsFrame.clearBtn then optionsFrame.clearBtn:SetText(GetBindingKey("EASYFIND_CLEAR") or "Not Bound") end end -local function PaintRoundedFill(frame, r, g, b, a) - if not (frame and frame.combinedBorder and frame.combinedBorder.fill) then return end - for _, tex in pairs(frame.combinedBorder.fill) do - tex:SetVertexColor(r, g, b, a or 1) - tex:SetAlpha(a or 1) - end -end - -local function HideRoundedFrameBorder(frame) - if not (frame and frame.combinedBorder and frame.combinedBorder.border) then return end - for _, tex in pairs(frame.combinedBorder.border) do tex:Hide() end +local PaintRoundedFill = ns.SetRoundedRectFill +local function HideRoundedBorder(frame) + ns.SetRoundedRectBorderEdgeShown(frame, false) end +local HideRoundedFrameBorder = HideRoundedBorder local function StyleSelectorButton(btnFrame, height) btnFrame:SetBackdrop(nil) @@ -435,17 +428,7 @@ local NAV_HOVER = { 0.11, 0.11, 0.13, 0.85 } local NAV_CLEAR = { 0, 0, 0, 0 } local function TintRoundedFill(frame, r, g, b) - if not (frame and frame.combinedBorder and frame.combinedBorder.fill) then return end - for _, tex in pairs(frame.combinedBorder.fill) do - tex:SetVertexColor(r, g, b, 1) - if tex.SetSnapToPixelGrid then tex:SetSnapToPixelGrid(false) end - if tex.SetTexelSnappingBias then tex:SetTexelSnappingBias(0) end - end -end - -local function HideRoundedBorder(frame) - if not (frame and frame.combinedBorder and frame.combinedBorder.border) then return end - for _, tex in pairs(frame.combinedBorder.border) do tex:Hide() end + ns.SetRoundedRectFill(frame, r, g, b, 1, true) end local function ApplyWizardPanelGloss(frame) @@ -511,20 +494,11 @@ local function CreateModernCloseButton(parent) return closeBtn end -local function SetModernButtonFill(btn, r, g, b) - if not (btn and btn.combinedBorder and btn.combinedBorder.fill) then return end - for _, tex in pairs(btn.combinedBorder.fill) do - tex:SetVertexColor(r, g, b, 1) - end -end - -local function SetModernButtonAlpha(btn, alpha) - if not (btn and btn.combinedBorder and btn.combinedBorder.fill) then return end - for _, tex in pairs(btn.combinedBorder.fill) do tex:SetAlpha(alpha) end -end +local SetModernButtonFill = ns.SetRoundedRectBorderFillColor +local SetModernButtonAlpha = ns.SetRoundedRectBorderBgAlpha local function SetNavButtonBg(btn, color) - SetModernButtonFill(btn, color[1], color[2], color[3]) + SetModernButtonFill(btn, color[1], color[2], color[3], 1) SetModernButtonAlpha(btn, color[4] or 1) end diff --git a/Rescaler.lua b/Rescaler.lua index 1ae5f04..b245aab 100644 --- a/Rescaler.lua +++ b/Rescaler.lua @@ -263,16 +263,10 @@ local function CreateGlowOverlay(name, parent, target) end local function HideRoundedFrameBorder(frame) - if not (frame and frame.combinedBorder and frame.combinedBorder.border) then return end - for _, tex in pairs(frame.combinedBorder.border) do tex:Hide() end + ns.SetRoundedRectBorderEdgeShown(frame, false) end -local function SetModernButtonFill(btn, r, g, b) - if not (btn and btn.combinedBorder and btn.combinedBorder.fill) then return end - for _, tex in pairs(btn.combinedBorder.fill) do - tex:SetVertexColor(r, g, b, 1) - end -end +local SetModernButtonFill = ns.SetRoundedRectBorderFillColor local function CreateModernButton(parent, text, width, height) local btn = CreateFrame("Button", nil, parent) diff --git a/UI.lua b/UI.lua index 902c6cf..83e9846 100644 --- a/UI.lua +++ b/UI.lua @@ -1128,29 +1128,12 @@ function UI:GetSearchBarCommandSuggestionEntries(text) end function UI:SetCalculatorRoundedFill(frame, r, g, b, a, br, bg, bb, ba) - if not (frame and frame.combinedBorder) then return end - if frame.combinedBorder.fill then - for _, tex in pairs(frame.combinedBorder.fill) do - tex:SetVertexColor(r, g, b, 1) - tex:SetAlpha(a or 1) - if tex.SetSnapToPixelGrid then tex:SetSnapToPixelGrid(false) end - if tex.SetTexelSnappingBias then tex:SetTexelSnappingBias(0) end - end - end - if frame.combinedBorder.border then - for _, tex in pairs(frame.combinedBorder.border) do - tex:SetVertexColor(br or 0.30, bg or 0.30, bb or 0.32, ba or 0.85) - if tex.SetSnapToPixelGrid then tex:SetSnapToPixelGrid(false) end - if tex.SetTexelSnappingBias then tex:SetTexelSnappingBias(0) end - end - end + ns.SetRoundedRectFill(frame, r, g, b, a, true) + ns.SetRoundedRectBorderColor(frame, br or 0.30, bg or 0.30, bb or 0.32, ba or 0.85, true) end function UI:HideCalculatorRoundedBorder(frame) - if not (frame and frame.combinedBorder and frame.combinedBorder.border) then return end - for _, tex in pairs(frame.combinedBorder.border) do - tex:Hide() - end + ns.SetRoundedRectBorderEdgeShown(frame, false) end function UI:StyleCalculatorButton(btn, height) @@ -4538,18 +4521,11 @@ function UI:QuickFilterAllowsData(data, quickFilter) end function UI:SetQuickFilterPillFill(frame, r, g, b, a) - if not (frame and frame.combinedBorder and frame.combinedBorder.fill) then return end - for _, tex in pairs(frame.combinedBorder.fill) do - tex:SetVertexColor(r, g, b, 1) - tex:SetAlpha(a or 1) - if tex.SetSnapToPixelGrid then tex:SetSnapToPixelGrid(false) end - if tex.SetTexelSnappingBias then tex:SetTexelSnappingBias(0) end - end + ns.SetRoundedRectFill(frame, r, g, b, a, true) end function UI:HideQuickFilterPillBorder(frame) - if not (frame and frame.combinedBorder and frame.combinedBorder.border) then return end - for _, tex in pairs(frame.combinedBorder.border) do tex:Hide() end + ns.SetRoundedRectBorderEdgeShown(frame, false) end function UI:CreateQuickFilterPill(frame, editBox, iconHolder, filterBtn) @@ -7933,12 +7909,8 @@ function UI:CreateResultButton(index) local calcCard = CreateFrame("Frame", nil, resultRow) ns.CreateRoundedRectBorder(calcCard) ns.SetRoundedRectBarHeight(calcCard, 12) - for _, t in pairs(calcCard.combinedBorder.fill) do - t:SetVertexColor(0.095, 0.095, 0.108, 1) - end - for _, t in pairs(calcCard.combinedBorder.border) do - t:SetVertexColor(0.34, 0.34, 0.36, 0.92) - end + ns.SetRoundedRectBorderFillColor(calcCard, 0.095, 0.095, 0.108, 1) + ns.SetRoundedRectBorderColor(calcCard, 0.34, 0.34, 0.36, 0.92) calcCard:Hide() resultRow.calcCard = calcCard diff --git a/Utils.lua b/Utils.lua index 9c956e2..d6e8706 100644 --- a/Utils.lua +++ b/Utils.lua @@ -609,6 +609,38 @@ function ns.SetRoundedRectBorderFillColor(frame, r, g, b, a) end end +-- Paint fill color AND alpha, plus optionally disable pixel snapping so +-- the texture renders at sub-pixel positions cleanly (used by buttons +-- and cards that get color-animated, where snap can cause shimmering). +function ns.SetRoundedRectFill(frame, r, g, b, a, snapOff) + if not (frame and frame.combinedBorder and frame.combinedBorder.fill) then return end + a = a or 1 + for _, t in pairs(frame.combinedBorder.fill) do + t:SetVertexColor(r, g, b, a) + t:SetAlpha(a) + if snapOff then + if t.SetSnapToPixelGrid then t:SetSnapToPixelGrid(false) end + if t.SetTexelSnappingBias then t:SetTexelSnappingBias(0) end + end + end +end + +function ns.SetRoundedRectBorderColor(frame, r, g, b, a, snapOff) + if not (frame and frame.combinedBorder and frame.combinedBorder.border) then return end + for _, t in pairs(frame.combinedBorder.border) do + t:SetVertexColor(r, g, b, a or 1) + if snapOff then + if t.SetSnapToPixelGrid then t:SetSnapToPixelGrid(false) end + if t.SetTexelSnappingBias then t:SetTexelSnappingBias(0) end + end + end +end + +function ns.SetRoundedRectBorderEdgeShown(frame, shown) + if not (frame and frame.combinedBorder and frame.combinedBorder.border) then return end + for _, t in pairs(frame.combinedBorder.border) do t:SetShown(shown) end +end + function ns.CreateRoundedRectDivider(frame) if frame.combinedDivider then return frame.combinedDivider end local d = frame:CreateTexture(nil, "ARTWORK") diff --git a/Wizard.lua b/Wizard.lua index 6b09169..0b82f68 100644 --- a/Wizard.lua +++ b/Wizard.lua @@ -55,22 +55,9 @@ local function MakeButton(parent, text, variant, w) ns.SetRoundedRectBarHeight(b, 10) ns.SetRoundedRectBorderBgAlpha(b, 1) -- Border ring hidden: 256px texture aliases badly at ~5px corners. - if b.combinedBorder and b.combinedBorder.border then - for _, t in pairs(b.combinedBorder.border) do - t:Hide() - end - end - if b.combinedBorder and b.combinedBorder.fill then - for _, t in pairs(b.combinedBorder.fill) do - if t.SetSnapToPixelGrid then t:SetSnapToPixelGrid(false) end - if t.SetTexelSnappingBias then t:SetTexelSnappingBias(0) end - end - end + ns.SetRoundedRectBorderEdgeShown(b, false) local function tintFill(rr, gg, bb) - if not (b.combinedBorder and b.combinedBorder.fill) then return end - for _, t in pairs(b.combinedBorder.fill) do - t:SetVertexColor(rr, gg, bb, 1) - end + ns.SetRoundedRectFill(b, rr, gg, bb, 1, true) end tintFill(0.18, 0.18, 0.20) fs:SetTextColor(1, 1, 1, 1) @@ -223,16 +210,8 @@ local function FeatureTile(parent, atlas, file, coords, title, desc, onClick) ns.CreateRoundedRectBorder(tile) ns.SetRoundedRectBarHeight(tile, 10) ns.SetRoundedRectBorderBgAlpha(tile, 0.95) - if tile.combinedBorder and tile.combinedBorder.fill then - for _, t in pairs(tile.combinedBorder.fill) do - t:SetVertexColor(0.05, 0.05, 0.06, 1) - if t.SetSnapToPixelGrid then t:SetSnapToPixelGrid(false) end - if t.SetTexelSnappingBias then t:SetTexelSnappingBias(0) end - end - end - if tile.combinedBorder and tile.combinedBorder.border then - for _, t in pairs(tile.combinedBorder.border) do t:Hide() end - end + ns.SetRoundedRectFill(tile, 0.05, 0.05, 0.06, 1, true) + ns.SetRoundedRectBorderEdgeShown(tile, false) local icon = tile:CreateTexture(nil, "ARTWORK") icon:SetSize(36, 36) @@ -262,19 +241,11 @@ local function FeatureTile(parent, atlas, file, coords, title, desc, onClick) tile:SetScript("OnEnter", function(self) self:SetScale(1.04) - if self.combinedBorder and self.combinedBorder.fill then - for _, t in pairs(self.combinedBorder.fill) do - t:SetVertexColor(0.09, 0.09, 0.11, 1) - end - end + ns.SetRoundedRectBorderFillColor(self, 0.09, 0.09, 0.11, 1) end) tile:SetScript("OnLeave", function(self) self:SetScale(1.00) - if self.combinedBorder and self.combinedBorder.fill then - for _, t in pairs(self.combinedBorder.fill) do - t:SetVertexColor(0.05, 0.05, 0.06, 1) - end - end + ns.SetRoundedRectBorderFillColor(self, 0.05, 0.05, 0.06, 1) end) if onClick then tile:SetScript("OnClick", onClick) @@ -403,16 +374,8 @@ local function CreateKbWidget(parent, action, label) ns.CreateRoundedRectBorder(btn) ns.SetRoundedRectBarHeight(btn, 40) ns.SetRoundedRectBorderBgAlpha(btn, 1) - if btn.combinedBorder and btn.combinedBorder.border then - for _, t in pairs(btn.combinedBorder.border) do - t:Hide() - end - end - if btn.combinedBorder and btn.combinedBorder.fill then - for _, t in pairs(btn.combinedBorder.fill) do - t:SetVertexColor(0.18, 0.18, 0.20, 1) - end - end + ns.SetRoundedRectBorderEdgeShown(btn, false) + ns.SetRoundedRectBorderFillColor(btn, 0.18, 0.18, 0.20, 1) local fs = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") fs:SetPoint("CENTER") @@ -420,10 +383,7 @@ local function CreateKbWidget(parent, action, label) btn._label = fs local function tintFill(rr, gg, bb) - if not (btn.combinedBorder and btn.combinedBorder.fill) then return end - for _, t in pairs(btn.combinedBorder.fill) do - t:SetVertexColor(rr, gg, bb, 1) - end + ns.SetRoundedRectBorderFillColor(btn, rr, gg, bb, 1) end local function setHover(hover) @@ -543,16 +503,8 @@ local function CreateFrameOnce() ns.SetRoundedRectBarHeight(f, 16) ns.SetRoundedRectBorderBgAlpha(f, PANEL_BG_ALPHA) -- Border ring hidden: its corner cells band against the gradient fill. - if f.combinedBorder and f.combinedBorder.border then - for _, t in pairs(f.combinedBorder.border) do - t:Hide() - end - end - if f.combinedBorder and f.combinedBorder.fill then - for _, t in pairs(f.combinedBorder.fill) do - t:SetVertexColor(0.04, 0.04, 0.05, 1) - end - end + ns.SetRoundedRectBorderEdgeShown(f, false) + ns.SetRoundedRectBorderFillColor(f, 0.04, 0.04, 0.05, 1) -- Single vertical gradient mapped across the 9-slice; each cell's -- gradient stops are sampled from its vertical position in the frame. @@ -656,16 +608,8 @@ local function CreateFrameOnce() ns.CreateRoundedRectBorder(footer) ns.SetRoundedRectBarHeight(footer, 16) ns.SetRoundedRectBorderBgAlpha(footer, 1) - if footer.combinedBorder and footer.combinedBorder.fill then - for _, t in pairs(footer.combinedBorder.fill) do - t:SetVertexColor(0.075, 0.075, 0.085, 1) - if t.SetSnapToPixelGrid then t:SetSnapToPixelGrid(false) end - if t.SetTexelSnappingBias then t:SetTexelSnappingBias(0) end - end - end - if footer.combinedBorder and footer.combinedBorder.border then - for _, t in pairs(footer.combinedBorder.border) do t:Hide() end - end + ns.SetRoundedRectFill(footer, 0.075, 0.075, 0.085, 1, true) + ns.SetRoundedRectBorderEdgeShown(footer, false) local DOT_GAP = 14 local DOT_SZ = 9 From f1dcbfdb2cc7317ee1689d42934d6b6f203a1d57 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 12 May 2026 05:20:16 -0400 Subject: [PATCH 099/103] Tighten new rounded-rect helpers Drop two dead defensive `if ns.SetRoundedRectBorderFillColor then` guards (Utils loads before UI/Rescaler per .toc, the check can't fire). Extract a local DisablePixelSnap helper from the two new ns.SetRoundedRectFill / ns.SetRoundedRectBorderColor bodies that called the same two methods inline. --- Rescaler.lua | 6 ++---- UI.lua | 2 +- Utils.lua | 15 +++++++-------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/Rescaler.lua b/Rescaler.lua index b245aab..457325f 100644 --- a/Rescaler.lua +++ b/Rescaler.lua @@ -487,10 +487,8 @@ local function CreateMockWindowFrame(parent, searchBar, preview, anchorAbove) ns.CreateRoundedRectBorder(frame) ns.CreateRoundedRectDivider(frame) ns.SetRoundedRectBarHeight(frame, searchBar:GetHeight()) - if ns.SetRoundedRectBorderFillColor then - local c = ns.SEARCH_WINDOW_FILL_COLOR or {0.052, 0.052, 0.060} - ns.SetRoundedRectBorderFillColor(frame, c[1], c[2], c[3], 1) - end + local c = ns.SEARCH_WINDOW_FILL_COLOR or {0.052, 0.052, 0.060} + ns.SetRoundedRectBorderFillColor(frame, c[1], c[2], c[3], 1) ns.SetRoundedRectBorderBgAlpha(frame, ns.SEARCH_WINDOW_ALPHA or 0.95) frame.UpdateLayout = function(self) diff --git a/UI.lua b/UI.lua index 83e9846..db8b32a 100644 --- a/UI.lua +++ b/UI.lua @@ -4,7 +4,7 @@ local UI = {} ns.UI = UI function UI:ApplySearchWindowFill(frame) - if not (frame and ns.SetRoundedRectBorderFillColor) then return end + if not frame then return end local c = ns.SEARCH_WINDOW_FILL_COLOR or {0.052, 0.052, 0.060} ns.SetRoundedRectBorderFillColor(frame, c[1], c[2], c[3], 1) end diff --git a/Utils.lua b/Utils.lua index d6e8706..dc720d1 100644 --- a/Utils.lua +++ b/Utils.lua @@ -609,6 +609,11 @@ function ns.SetRoundedRectBorderFillColor(frame, r, g, b, a) end end +local function DisablePixelSnap(t) + if t.SetSnapToPixelGrid then t:SetSnapToPixelGrid(false) end + if t.SetTexelSnappingBias then t:SetTexelSnappingBias(0) end +end + -- Paint fill color AND alpha, plus optionally disable pixel snapping so -- the texture renders at sub-pixel positions cleanly (used by buttons -- and cards that get color-animated, where snap can cause shimmering). @@ -618,10 +623,7 @@ function ns.SetRoundedRectFill(frame, r, g, b, a, snapOff) for _, t in pairs(frame.combinedBorder.fill) do t:SetVertexColor(r, g, b, a) t:SetAlpha(a) - if snapOff then - if t.SetSnapToPixelGrid then t:SetSnapToPixelGrid(false) end - if t.SetTexelSnappingBias then t:SetTexelSnappingBias(0) end - end + if snapOff then DisablePixelSnap(t) end end end @@ -629,10 +631,7 @@ function ns.SetRoundedRectBorderColor(frame, r, g, b, a, snapOff) if not (frame and frame.combinedBorder and frame.combinedBorder.border) then return end for _, t in pairs(frame.combinedBorder.border) do t:SetVertexColor(r, g, b, a or 1) - if snapOff then - if t.SetSnapToPixelGrid then t:SetSnapToPixelGrid(false) end - if t.SetTexelSnappingBias then t:SetTexelSnappingBias(0) end - end + if snapOff then DisablePixelSnap(t) end end end From 5cbcabececb7484c49c7c391dbc343006ad80efd Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Wed, 13 May 2026 02:20:05 -0400 Subject: [PATCH 100/103] Wizard: tutorial illustration carousels and image assets --- .gitignore | 4 + Wizard.lua | 238 +++++++++++++++++++++++++++++---- tutorial-calculator-visual.tga | Bin 0 -> 392957 bytes tutorial-map-search.tga | Bin 0 -> 324021 bytes tutorial-search-01.tga | Bin 0 -> 200194 bytes tutorial-search-02.tga | Bin 0 -> 261914 bytes tutorial-search-03.tga | Bin 0 -> 827409 bytes tutorial-search-04.tga | Bin 0 -> 243201 bytes tutorial-search-05.tga | Bin 0 -> 426739 bytes tutorial-search-06.tga | Bin 0 -> 240247 bytes tutorial-search-07.tga | Bin 0 -> 287001 bytes tutorial-search-08.tga | Bin 0 -> 714480 bytes tutorial-search-09.tga | Bin 0 -> 166577 bytes tutorial-use-01.tga | Bin 0 -> 203349 bytes tutorial-use-02.tga | Bin 0 -> 287001 bytes tutorial-use-03.tga | Bin 0 -> 442263 bytes 16 files changed, 214 insertions(+), 28 deletions(-) create mode 100644 tutorial-calculator-visual.tga create mode 100644 tutorial-map-search.tga create mode 100644 tutorial-search-01.tga create mode 100644 tutorial-search-02.tga create mode 100644 tutorial-search-03.tga create mode 100644 tutorial-search-04.tga create mode 100644 tutorial-search-05.tga create mode 100644 tutorial-search-06.tga create mode 100644 tutorial-search-07.tga create mode 100644 tutorial-search-08.tga create mode 100644 tutorial-search-09.tga create mode 100644 tutorial-use-01.tga create mode 100644 tutorial-use-02.tga create mode 100644 tutorial-use-03.tga diff --git a/.gitignore b/.gitignore index 487c46c..4540280 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ archive/ *.swo *~ +# Dev workspace artifacts +target/ +.claude/ + # OS files Thumbs.db .DS_Store diff --git a/Wizard.lua b/Wizard.lua index 0b82f68..e58e174 100644 --- a/Wizard.lua +++ b/Wizard.lua @@ -24,8 +24,76 @@ local PANEL_BG_ALPHA = 0.97 local TEXT_PRIM = { 1.00, 0.97, 0.86 } local TEXT_BODY = { 0.78, 0.78, 0.80 } local TEXT_DIM = { 0.55, 0.55, 0.58 } +local TUTORIAL_IMAGE_TINT = 0.82 local DOT_FILLED = "Interface\\COMMON\\Indicator-Yellow" +local MAP_SEARCH_TUTORIAL_IMAGE = "Interface\\AddOns\\EasyFind\\tutorial-map-search" +local CALCULATOR_ICON_TEX = "Interface\\AddOns\\EasyFind\\textures\\calculator-icon" +local SEARCH_TUTORIAL_SLIDES = { + { + image = "Interface\\AddOns\\EasyFind\\tutorial-search-01", + w = 651, h = 202, + text = "Search across gear, loot, panels, settings, collections, currencies, achievements, map places, and more.", + }, + { + image = "Interface\\AddOns\\EasyFind\\tutorial-search-02", + w = 655, h = 267, + text = "Results show icons, categories, and Alt+number. Press the matching Alt+number to instantly activate that row.", + }, + { + image = "Interface\\AddOns\\EasyFind\\tutorial-search-09", + w = 486, h = 218, + text = "Pin important results so they stay available before you type.", + }, + { + image = "Interface\\AddOns\\EasyFind\\tutorial-search-03", + w = 662, h = 611, + text = "Use the filter menu to choose which result groups appear in your general search.", + }, + { + image = "Interface\\AddOns\\EasyFind\\tutorial-search-05", + w = 657, h = 593, + text = "Type @ to see available quick filters, then Tab or Space to select one.", + }, + { + image = "Interface\\AddOns\\EasyFind\\tutorial-search-04", + w = 672, h = 220, + text = "Quick filters let you search inside one category without opening the filter menu.", + }, + { + image = "Interface\\AddOns\\EasyFind\\tutorial-search-06", + w = 660, h = 267, + text = "Quick filters let you search inside one category without opening the filter menu.", + }, + { + image = "Interface\\AddOns\\EasyFind\\tutorial-search-07", + w = 658, h = 276, + text = "Supported results can run directly from search, including macros and other actionable rows.", + }, + { + image = "Interface\\AddOns\\EasyFind\\tutorial-search-08", + w = 638, h = 498, + text = "Search settings and adjust matching controls inline without leaving the results.", + }, +} +local USE_TUTORIAL_SLIDES = { + { + image = "Interface\\AddOns\\EasyFind\\tutorial-use-01", + w = 658, h = 152, + text = "Equip gear sets directly from search.", + }, + { + image = "Interface\\AddOns\\EasyFind\\tutorial-use-02", + w = 658, h = 276, + text = "Run macros from results, or Ctrl-click to edit them.", + }, + { + image = "Interface\\AddOns\\EasyFind\\tutorial-use-03", + w = 663, h = 388, + text = "Use toys and other supported items directly from the list.", + }, +} +local CALCULATOR_TUTORIAL_IMAGE = "Interface\\AddOns\\EasyFind\\tutorial-calculator-visual" local function ApplyInter(fs, weight, sizeOverride, flags) if ns.RegisterAddonFont then @@ -41,18 +109,18 @@ local backBtn, nextBtn local function MakeButton(parent, text, variant, w) local b = CreateFrame("Button", nil, parent) - local h = (variant == "rounded") and 26 or 24 + local h = (variant == "rounded") and 20 or 18 b:SetSize(w or 96, h) local fs = b:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") fs:SetPoint("CENTER") fs:SetText(text) - ApplyInter(fs, "semibold", 12) + ApplyInter(fs, "semibold", 10) b._label = fs if variant == "rounded" then ns.CreateRoundedRectBorder(b) - ns.SetRoundedRectBarHeight(b, 10) + ns.SetRoundedRectBarHeight(b, 8) ns.SetRoundedRectBorderBgAlpha(b, 1) -- Border ring hidden: 256px texture aliases badly at ~5px corners. ns.SetRoundedRectBorderEdgeShown(b, false) @@ -173,6 +241,14 @@ local function BodyText(parent, text) return fs end +local function FitSize(srcW, srcH, maxW, maxH) + if not srcW or not srcH or srcW <= 0 or srcH <= 0 then + return maxW, maxH + end + local scale = math.min(maxW / srcW, maxH / srcH) + return srcW * scale, srcH * scale +end + local function BuildPage1(parent) local p = MakePage(parent) @@ -280,7 +356,8 @@ local function BuildPage2(parent) d:Show() end - local function CreateDetailView(headerText, detailText) + local function CreateDetailView(headerText, detailText, opts) + opts = opts or {} local d = CreateFrame("Frame", nil, p) d:SetAllPoints(p) d:Hide() @@ -292,27 +369,132 @@ local function BuildPage2(parent) local h = HeaderText(d, headerText, "GameFontNormalLarge") h:SetPoint("TOP", d, "TOP", 0, -28) - local body = BodyText(d, detailText or "") - body:SetPoint("TOP", h, "BOTTOM", 0, -16) - body:SetWidth(WIZ_W - 120) + if opts.image then + local image = d:CreateTexture(nil, "ARTWORK") + image:SetTexture(opts.image) + image:SetSize(opts.imageW or 410, opts.imageH or 233) + image:SetPoint("TOP", h, "BOTTOM", 0, -12) + image:SetTexCoord(0, 1, 0, 1) + image:SetVertexColor(TUTORIAL_IMAGE_TINT, TUTORIAL_IMAGE_TINT, TUTORIAL_IMAGE_TINT, 1) + + local body = BodyText(d, detailText or "") + body:SetPoint("TOP", image, "BOTTOM", 0, -10) + body:SetWidth(opts.textW or (WIZ_W - 96)) + body:SetJustifyH("LEFT") + body:SetSpacing(2) + else + local body = BodyText(d, detailText or "") + body:SetPoint("TOP", h, "BOTTOM", 0, -16) + body:SetWidth(WIZ_W - 120) + end + + detailViews[#detailViews + 1] = d + return d + end + + local function CreateCarouselDetailView(headerText, slides) + local d = CreateFrame("Frame", nil, p) + d:SetAllPoints(p) + d:Hide() + + local back = MakeButton(d, "< Back", "ghost", 52) + back:SetPoint("TOPLEFT", d, "TOPLEFT", 12, -10) + back:SetScript("OnClick", ShowGrid) + + local h = HeaderText(d, headerText, "GameFontNormalLarge") + h:SetPoint("TOP", d, "TOP", 0, -28) + + local image = d:CreateTexture(nil, "ARTWORK") + image:SetPoint("TOP", h, "BOTTOM", 0, -10) + image:SetTexCoord(0, 1, 0, 1) + + local slideText = BodyText(d, "") + slideText:SetPoint("TOP", image, "BOTTOM", 0, -8) + slideText:SetWidth(WIZ_W - 104) + slideText:SetJustifyH("LEFT") + slideText:SetSpacing(2) + + local controls = CreateFrame("Frame", nil, d) + controls:SetSize(148, 18) + controls:SetPoint("BOTTOM", d, "BOTTOM", 0, 6) + + local prev = MakeButton(controls, "<", "ghost", 26) + prev:SetPoint("LEFT", controls, "LEFT", 0, 0) + + local next = MakeButton(controls, ">", "ghost", 26) + next:SetPoint("RIGHT", controls, "RIGHT", 0, 0) + + local counter = controls:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + counter:SetPoint("CENTER", controls, "CENTER", 0, 0) + counter:SetTextColor(TEXT_DIM[1], TEXT_DIM[2], TEXT_DIM[3], 1) + ApplyInter(counter, "regular", 10) + + local idx = 1 + local function ShowSlide(newIdx) + local count = #slides + if count == 0 then return end + if newIdx < 1 then newIdx = count end + if newIdx > count then newIdx = 1 end + idx = newIdx + + local slide = slides[idx] + image:SetTexture(slide.image) + image:SetVertexColor(TUTORIAL_IMAGE_TINT, TUTORIAL_IMAGE_TINT, TUTORIAL_IMAGE_TINT, 1) + local w, hgt = FitSize(slide.w, slide.h, 486, 218) + image:SetSize(w, hgt) + slideText:SetText(slide.text or "") + counter:SetText(idx .. " / " .. count) + end + + prev:SetScript("OnClick", function() ShowSlide(idx - 1) end) + next:SetScript("OnClick", function() ShowSlide(idx + 1) end) + d.OnEnter = function() ShowSlide(idx) end + ShowSlide(1) + + detailViews[#detailViews + 1] = d + return d + end + + local function CreateCalculatorDetailView() + local d = CreateFrame("Frame", nil, p) + d:SetAllPoints(p) + d:Hide() + + local back = MakeButton(d, "< Back", "ghost", 52) + back:SetPoint("TOPLEFT", d, "TOPLEFT", 12, -10) + back:SetScript("OnClick", ShowGrid) + + local h = HeaderText(d, "Calculator", "GameFontNormalLarge") + h:SetPoint("TOP", d, "TOP", 0, -28) + + local visual = d:CreateTexture(nil, "ARTWORK") + visual:SetTexture(CALCULATOR_TUTORIAL_IMAGE) + visual:SetSize(486, 224) + visual:SetPoint("TOP", h, "BOTTOM", 0, -10) + visual:SetTexCoord(0, 1, 0, 1) + visual:SetVertexColor(TUTORIAL_IMAGE_TINT, TUTORIAL_IMAGE_TINT, TUTORIAL_IMAGE_TINT, 1) + + local body = BodyText(d, + "Type math into search for instant results, or search calculator and press Alt+C to open the full calculator.") + body:SetPoint("TOP", visual, "BOTTOM", 0, -10) + body:SetWidth(WIZ_W - 104) + body:SetJustifyH("LEFT") + body:SetSpacing(2) detailViews[#detailViews + 1] = d return d end - local d1 = CreateDetailView("Search", - "Search panels, settings, achievements, statistics, abilities, talents, titles, reputations, currencies, mounts, toys, pets, outfits, transmogs, heirlooms, gear sets, bag items, bosses, loot, macros, map places, and addon options.\n\n" .. - "Right-click a result for contextual actions like Guide, Pin, Add Alias, favorite toggles, tracking, pet actions, and other row-specific tools. Pins keep important results available before typing; aliases let your own words find the same result.") + local d1 = CreateCarouselDetailView("Search", SEARCH_TUTORIAL_SLIDES) local d2 = CreateDetailView("Map Search Tab", - "Use the Map Search tab for location-first browsing: banks, flight masters, dungeons, raids, zones, and useful points of interest.") - local d3 = CreateDetailView("Item/Ability Use", - "Use supported results directly from the list: cast abilities, summon mounts and pets, use toys, swap outfits, view or try on transmogs, add heirlooms to bags, consume usable bag items, equip gear from bags, and open ordinary bag items in their bag slot.") - local d4 = CreateDetailView("Calculator", - "Type math straight into search, including 1+23, 1*7, sin(30), tan(45), or 5!. Select the expression or result and press Ctrl+C, or press Alt+C to open the calculator.") + "Search the map you're viewing. \"This Zone\" shows matches for the current map, while \"Across the World\" groups broader results by continent and zone. Built for quickly moving between zones and previewing POIs on hover; the same results are available from the main search bar, just without the nested map layout.", + { image = MAP_SEARCH_TUTORIAL_IMAGE, imageW = 486, imageH = 224, textW = WIZ_W - 92 }) + local d3 = CreateCarouselDetailView("Item/Ability Use", USE_TUTORIAL_SLIDES) + local d4 = CreateCalculatorDetailView() local t1 = FeatureTile(grid, nil, "Interface\\AddOns\\EasyFind\\Textures\\Spyglass", nil, "Search", - "Search any panel, tab, setting, mount, toy, currency, achievement, or vendor item.", + "Search any panel, tab, setting, mount, toy, currency, achievement, and more.", function() ShowDetail(d1) end) t1:SetPoint("TOPLEFT", grid, "TOPLEFT", 38, -96) @@ -329,7 +511,7 @@ local function BuildPage2(parent) function() ShowDetail(d3) end) t3:SetPoint("TOPLEFT", grid, "TOPLEFT", 38, -222) - local t4 = FeatureTile(grid, nil, "Interface\\Icons\\INV_Misc_Note_05", nil, + local t4 = FeatureTile(grid, nil, CALCULATOR_ICON_TEX, nil, "Calculator", "Type expressions, copy the result, or open the full calculator with Alt+C.", function() ShowDetail(d4) end) @@ -480,7 +662,7 @@ local function BuildPage3(parent) return p end -local BANNER_H = 38 +local BANNER_H = 29 local function CreateFrameOnce() if frame then return frame end @@ -600,33 +782,33 @@ local function CreateFrameOnce() BuildPage3(pageHost), } - local BANNER_INSET = 6 + local BANNER_INSET = 5 local footer = CreateFrame("Frame", nil, f) footer:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", BANNER_INSET, BANNER_INSET) footer:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -BANNER_INSET, BANNER_INSET) footer:SetHeight(BANNER_H) ns.CreateRoundedRectBorder(footer) - ns.SetRoundedRectBarHeight(footer, 16) + ns.SetRoundedRectBarHeight(footer, 12) ns.SetRoundedRectBorderBgAlpha(footer, 1) ns.SetRoundedRectFill(footer, 0.075, 0.075, 0.085, 1, true) ns.SetRoundedRectBorderEdgeShown(footer, false) - local DOT_GAP = 14 - local DOT_SZ = 9 - local DOT_ACTIVE = 11 + local DOT_GAP = 11 + local DOT_SZ = 7 + local DOT_ACTIVE = 9 for i = 1, #pages do local d = footer:CreateTexture(nil, "OVERLAY") d:SetSize(DOT_SZ, DOT_SZ) d:SetTexture(DOT_FILLED) - d:SetPoint("LEFT", footer, "LEFT", 14 + DOT_ACTIVE / 2 + (i - 1) * (DOT_SZ + DOT_GAP), 0) + d:SetPoint("LEFT", footer, "LEFT", 12 + DOT_ACTIVE / 2 + (i - 1) * (DOT_SZ + DOT_GAP), 0) dots[i] = d end - nextBtn = MakeButton(footer, "Continue", "rounded", 96) - nextBtn:SetPoint("RIGHT", footer, "RIGHT", -10, 0) + nextBtn = MakeButton(footer, "Continue", "rounded", 78) + nextBtn:SetPoint("RIGHT", footer, "RIGHT", -8, 0) - backBtn = MakeButton(footer, "Back", "ghost", 50) - backBtn:SetPoint("RIGHT", nextBtn, "LEFT", -6, 0) + backBtn = MakeButton(footer, "Back", "ghost", 42) + backBtn:SetPoint("RIGHT", nextBtn, "LEFT", -5, 0) backBtn:SetScript("OnClick", function() ShowPage(pageIdx - 1) end) nextBtn:SetScript("OnClick", function() diff --git a/tutorial-calculator-visual.tga b/tutorial-calculator-visual.tga new file mode 100644 index 0000000000000000000000000000000000000000..d6cf655dd1a042dd198af59234cfc84006a3e418 GIT binary patch literal 392957 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

%uMb`C|3T>b!k@w{*~W9E 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%0ACLQpGB1fHnX8gZplw~LCgj2* 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 literal 0 HcmV?d00001 diff --git a/tutorial-map-search.tga b/tutorial-map-search.tga new file mode 100644 index 0000000000000000000000000000000000000000..e678746e0fa42686a2556ca1e66b50bdbaef179d GIT binary patch literal 324021 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 literal 0 HcmV?d00001 diff --git a/tutorial-search-01.tga b/tutorial-search-01.tga new file mode 100644 index 0000000000000000000000000000000000000000..b867cbd4698664ee9eb87097ce3750d1e9a51926 GIT binary patch literal 200194 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 LasKk9zpDKYu6upa literal 0 HcmV?d00001 diff --git a/tutorial-search-03.tga b/tutorial-search-03.tga new file mode 100644 index 0000000000000000000000000000000000000000..815a60af261901ea8c5cce05574fbfbb8134876d GIT binary patch literal 827409 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

Q564kW*EUW!4^I)Y2AGoL?9 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 literal 0 HcmV?d00001 diff --git a/tutorial-search-04.tga b/tutorial-search-04.tga new file mode 100644 index 0000000000000000000000000000000000000000..9bb6fa15da6968d8e136f15338caa4b93fe204cd GIT binary patch literal 243201 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