diff --git a/.gitignore b/.gitignore index 3cae8ca..4540280 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,18 @@ # Release artifacts *.zip +# Archived / retired assets (kept on disk for reference, not shipped) +archive/ + # Editor files *.swp *.swo *~ +# Dev workspace artifacts +target/ +.claude/ + # OS files Thumbs.db .DS_Store diff --git a/.luacheckrc b/.luacheckrc index 9e45d5e..0f4cd57 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -15,33 +15,40 @@ 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", + "SettingsPanel", + "TokenFrame", } -- WoW API globals (read-only) read_globals = { -- WoW Lua extensions - "debugstack", "strsplit", "strtrim", "wipe", "hooksecurefunc", + "bit", + "debugstack", "debugprofilestart", "debugprofilestop", "strsplit", "strtrim", "wipe", "hooksecurefunc", "format", "time", "date", + -- Menus + "MenuUtil", + -- 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", "ToggleWorldMap", "ToggleDropDownMenu", + "SetPortraitTexture", "ShowUIPanel", "HideUIPanel", "ToggleWorldMap", "ToggleDropDownMenu", "InterfaceOptions_AddCategory", "InCombatLockdown", "IsShiftKeyDown", "IsMouseButtonDown", "IsAltKeyDown", "IsControlKeyDown", "IsInGroup", "IsInInstance", "IsIndoors", "UnitIsGroupLeader", "UnitFactionGroup", "UnitPosition", "StaticPopup_Show", "GameTooltip_Hide", "PlaySound", "ReloadUI", "GetSpellInfo", "GetItemInfo", "UseToyByItemID", + "GetMacroSpell", "GetMacroItem", "PanelTemplates_GetSelectedTab", -- Frames and UI objects @@ -50,8 +57,9 @@ read_globals = { "CharacterStatsPane", "PaperDollTitlesPane", "PaperDollEquipmentManagerPane", "CurrencyFrame", "SpellBookFrame", "PlayerSpellsFrame", "CollectionsJournal", - "PVEFrame", "ReputationFrame", "TokenFrame", + "PVEFrame", "ReputationFrame", "TokenFramePopup", "GroupFinderFrame", "LFGListFrame", "HelpFrame", "ClassTalentFrame", + "GameMenuFrame", "MacroFrame", "GuildMicroButton", "StoreMicroButton", "PlayerFrame", "StoreFrame", "LFDParentFrame", "RaidFinderFrame", "LFGListPVEStub", "LFGListPVPStub", @@ -59,24 +67,38 @@ read_globals = { "PVPQueueFrame", "AchievementFrameCategories_ExpandToCategory", "AchievementFrameCategories_UpdateDataProvider", + "AchievementFrame_LoadUI", "AchievementFrame_SelectAchievement", + "OpenAchievementFrameToAchievement", + "AchievementFrame", "GetCategoryList", "GetCategoryNumAchievements", + "GetStatisticsCategoryList", "GetStatistic", + "GetAchievementInfo", + "SetAchievementSearchString", "GetNumFilteredAchievements", + "GetFilteredAchievementID", + "AddTrackedAchievement", "RemoveTrackedAchievement", + "IsTrackedAchievement", "AchievementFrame_ToggleTracking", + "C_ContentTracking", "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_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_TransmogOutfitInfo", "C_VignetteInfo", + "C_ToyBox", "C_Traits", "C_TransmogCollection", "C_TransmogOutfitInfo", + "C_TransmogSets", "C_VignetteInfo", -- 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", - "UnitClass", "GetLootSpecialization", "DressUpItemLink", + "GetNumTitles", "GetTitleName", "IsTitleKnown", "GetCurrentTitle", "SetCurrentTitle", + "UnitClass", "GetLootSpecialization", "DressUpItemLink", "DressUpTransmogSet", "GetNumClasses", "GetClassInfo", "GetNumSpecializationsForClassID", "GetSpecializationInfoForClassID", "RAID_CLASS_COLORS", "EJ_GetCurrentTier", "EJ_SelectTier", "EJ_GetInstanceByIndex", @@ -84,6 +106,18 @@ read_globals = { "EJ_SetDifficulty", "EJ_SetLootFilter", "EJ_SetSlotFilter", "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", + "GetFlyoutInfo", "GetFlyoutSlotInfo", + "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 @@ -93,7 +127,7 @@ read_globals = { "EasyFindDevDB", -- Blizzard settings frames - "SettingsPanel", "InterfaceOptionsFrame", + "InterfaceOptionsFrame", -- Constants, Enums, Mixins "Enum", "Settings", "BackdropTemplateMixin", @@ -103,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/Aliases.lua b/Aliases.lua new file mode 100644 index 0000000..eefe3cf --- /dev/null +++ b/Aliases.lua @@ -0,0 +1,148 @@ +local _, ns = ... +local Aliases = {} +ns.Aliases = Aliases + +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 + +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 + if IsMapAliasTarget(data) then + return MapAliasKey(data) + end + 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 + +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 + 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 + +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 + local snapshot + 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 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, + 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 + +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 + +function Aliases:ClearAll() + if not EasyFind or not EasyFind.db then return end + EasyFind.db.aliases = {} +end + +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 + +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/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/BlizzOptionsSearch.lua b/BlizzOptionsSearch.lua new file mode 100644 index 0000000..37f5ef2 --- /dev/null +++ b/BlizzOptionsSearch.lua @@ -0,0 +1,1871 @@ +local _, ns = ... +local BlizzOptionsSearch = {} +ns.BlizzOptionsSearch = BlizzOptionsSearch + +local Utils = ns.Utils +local tinsert = Utils.tinsert +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 + +-- 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) + 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 + +-- { display name, CVar/variable, category name, type code, [min, max, step] } +-- type: c=checkbox, d=dropdown, s=slider +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" } + +-- 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" }, + { 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" }, + }, +} + +local categoryIDByName = {} +local categoryIDByVariable = {} + +local settingTooltips = {} + +-- 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_]", "") + 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 + +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 + + 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 + + local fromGlobal = ResolveTooltipGlobal(displayName, variable) + if fromGlobal then + settingTooltips[variable] = fromGlobal + return fromGlobal + end + + settingTooltips[variable] = false + return nil +end +BlizzOptionsSearch.GetTooltipForVariable = GetTooltipForVariable + +-- 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 + 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" 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] + if cached ~= nil then + return cached ~= false and cached or nil + end + -- 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 + return hardcoded + 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 + found = GetInitializerOptions(init, setting) + 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 + if found then optionsByVariable[variable] = found end + return found +end +BlizzOptionsSearch.GetOptionsForVariable = GetOptionsForVariable + +-- 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 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 + 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 + +-- 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 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 + 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 + +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 + pendingApplySettings[settObj] = nil + FirePendingChanged() + end +end + +-- 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] + if p and p:IsShown() and p.which == popupName then return p end + end + 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) +end + +function BlizzOptionsSearch:ApplyPendingChanges() + if not next(pendingApplySettings) then return end + -- 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 + local ok, v = pcall(setting.GetVariable, setting) + if ok and v then vars[#vars + 1] = v end + end + end + for i = 1, #vars do self:ApplyVariable(vars[i]) 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 + +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 + +-- 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 + 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, +} + +-- 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" }, +} + +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 + +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) + 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 + + -- 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 + 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) + LiftPopupAndRefresh(FindStaticPopupSlot("GAME_SETTINGS_TIMED_CONFIRMATION")) + 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 + CommitStagedDependents(variable) + pendingApplySettings[settObj] = nil + FirePendingChanged() +end + +-- 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 + "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.Hide then ns.UI:Hide() end + end, + OnButton2 = function() + if BlizzOptionsSearch.ApplyPendingChanges then + BlizzOptionsSearch:ApplyPendingChanges() + end + if ns.UI and ns.UI.Hide then ns.UI:Hide() end + end, + OnButton3 = function() end, + OnHide = function() + 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) + 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 + 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 + +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 + + 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 + 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 + -- data.tooltip / data.options can be string or + -- function; 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 + + 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 + +-- Idempotent: re-crawl after late addon registrations fills in new entries. +local function ResolveCategoryIDs() + 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 + 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 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 + if SettingsPanel.Open then + SecureCall(SettingsPanel.Open, SettingsPanel) + elseif ShowUIPanel then + SecureCall(ShowUIPanel, SettingsPanel) + end +end + +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 + +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 + +local function OpenSettingsByName(name) + local id = GetCategoryID(name) + if not id then return false end + ShowSettings() + if Settings and Settings.OpenToCategory then + pcall(Settings.OpenToCategory, id) + return true + end + return false +end +BlizzOptionsSearch.OpenSettingsByName = OpenSettingsByName + +local function GetSettingsScrollBox() + if not SettingsPanel then return nil end + return SettingsPanel.Container + and SettingsPanel.Container.SettingsList + and SettingsPanel.Container.SettingsList.ScrollBox +end + +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, 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 + +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 + +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 + +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 + +-- 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 + +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 bindingIdx = GetBindingIndexForAction(action) + + -- 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) + 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 + end + return false + 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 not section then return false end + + local sectionData = section.data or section + local alignBegin = ScrollBoxConstants and ScrollBoxConstants.AlignBegin + scrollBox:ScrollToElementData(section, alignBegin) + + -- 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 + 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, nil, function(target) + return target and target:IsVisible() + end) + end + end) + end + SafeAfter(0.05, expandThenHighlight) + + return true +end +BlizzOptionsSearch.ScrollToBindingAction = ScrollToBindingAction + +local function CollectEntries() + local entries = {} + + ResolveCategoryIDs() + + -- 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 + + 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 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 }, + settingsCategory = catName, + settingCategoryID = catID, + settingVariable = var, + settingType = resolved, + settingMin = sMin, + settingMax = sMax, + settingStep = sStep, + steps = { + { + settingsCategory = catName, + settingCategoryID = catID, + settingVariable = var, + }, + }, + }) + end + end + + local list = GetSettingsCategoryList() + 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", + 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 + 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 + +-- 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 + 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 + 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", + bindingAction = action, + bindingHeader = currentHeader, + }, + }, + }) + end + end + return entries +end +BlizzOptionsSearch.CollectKeybindings = CollectKeybindings + +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 + + -- 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 + 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 d then setting = d.setting end + if not setting and init.GetData then + 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 + -- 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 + 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) + if vok and variable and nok and settingName and settingName ~= "" then + 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 + 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 + resolvedType = "slider" + sMin = opts.minValue + sMax = opts.maxValue + -- 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 + sStep = (sMax - sMin) / opts.steps + else + sStep = 1 + end + -- 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 + 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 + resolvedType = "checkbox" + end + end + if not resolvedType then resolvedType = "dropdown" end + + local settingOptions + if resolvedType == "dropdown" then + settingOptions = GetInitializerOptions(init, setting) + 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, + settingCategoryID = catID, + settingVariable = variable, + settingType = resolvedType, + settingMin = sMin, + settingMax = sMax, + settingStep = sStep, + settingFormatter = sFmt, + settingOptions = settingOptions, + steps = { + { + settingsCategory = catName, + settingCategoryID = catID, + settingVariable = variable, + }, + }, + } + tinsert(out, entry) + end + end + end + end + return out +end + +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 + return set == "AddOns" or set == 2 +end + +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) + 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", + 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 + + 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 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 + emit(sub, parentName) + end + end + end + end + end + end + + return entries +end +BlizzOptionsSearch.CollectAddonCategories = CollectAddonCategories + +-- 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 +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 + +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 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 + 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 emittedVars[e.settingVariable] then + e.category = "Game Settings" + tinsert(entries, e) + emittedVars[e.settingVariable] = true + 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 + -- 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 + 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 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 + 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) } + -- 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 + 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 + +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 + 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 + local gameEntries = CollectGameSettings() + for i = 1, #gameEntries do + tinsert(data, gameEntries[i]) + end + if ns.Database.ResetSearchCache then ns.Database:ResetSearchCache() end +end + +function BlizzOptionsSearch:HandleStep(step) + if not step then return false end + + -- 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) + end + if not catID and step.settingsCategory then + catID = GetCategoryID(step.settingsCategory) + end + + 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 + + -- 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) + 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 +end + +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 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 + seen[e.settingsCategory] = true + end + end + ResolveCategoryIDs() + local fresh = CollectEntries() + for _, e in ipairs(fresh) do + -- 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 + end + if ns.Database.ResetSearchCache then ns.Database:ResetSearchCache() end + end) +end) diff --git a/CHANGELOG.md b/CHANGELOG.md index 399df0e..2030e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to EasyFind will be documented in this file. --- +## [2.0.0] - 2026-05-13 + +EasyFind 2.0 is a full rewrite of the search and map experience. Launch the in-game tutorial to see what's new. + +--- + ## [1.5.0] - 2026-04-07 ### Added @@ -19,7 +25,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 +33,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 +42,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 +62,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 +73,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 +91,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 +141,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 +169,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 +187,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 +354,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/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/Core.lua b/Core.lua index f27c51f..4b5ea7d 100644 --- a/Core.lua +++ b/Core.lua @@ -10,114 +10,211 @@ local ErrorHandler = Utils.ErrorHandler EasyFind = {} ns.EasyFind = EasyFind -EasyFind._ns = ns -- Expose namespace for dev tools (EasyFindDev) +EasyFind._ns = ns -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 Tab" local eventFrame = CreateFrame("Frame") 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. -local DB_VERSION = 3 +-- 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, - enableUISearch = true, enableMapSearch = true, iconScale = 0.8, + nativePinScale = 1.5, uiSearchScale = 1.0, - mapSearchScale = 1.0, - mapSearchWidth = 0.88, - uiSearchWidth = 0.88, + uiSearchWidth = 1.54, 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 = false, -- Hide search bars when map is full screen - directOpen = false, -- Open panels directly instead of step-by-step - 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 - mapSmartShow = false, -- Hide map search bars until mouse hovers nearby - resultsTheme = "Retail", -- "Classic" or "Retail" - 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 - 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 = true, -- 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 - 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 - 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 - globalSearchFilters = { -- Global search category filters (all enabled by default) + uiSearchBarHeight = ns.SEARCHBAR_HEIGHT or 30, + fontSize = 0.9, + uiSearchPosition = nil, + localMapDirectOpen = true, + globalMapDirectOpen = true, + 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, rares = true, }, - alwaysShowRares = false, -- Persistent rare tracking: show active rares on map without searching - uiSearchFilters = { -- UI search category filters - ui = true, - mounts = false, - toys = false, - pets = false, - outfits = false, - loot = false, - map = false, + mapTabFilters = { + zones = true, + instances = true, + flightpath = false, + travel = true, + services = true, + rares = true, + }, + mapTabRecentSearches = {}, + mapTabShowRecent = true, + mapTabRecentCount = 3, + mapTabAutoExpand = true, + alwaysShowRares = false, + uiSearchFilters = { + 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, + bags = true, + macros = true, + 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 - 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", - uiMapSearchLocal = true, -- Map search in UI bar: true = local zone only, false = global + hideTooltips = { + collections = false, + loot = false, + }, + currencyFilterMode = "all", + reputationFilterMode = "all", + showLegacyReputations = false, + abilityHidePassives = false, + appearanceSetClass = nil, + appearanceSetCollected = true, + appearanceSetNotCollected = true, + appearanceSetPvE = true, + appearanceSetPvP = true, + uiMapSearchLocal = true, + aliases = {}, + uiSearchHistory = {}, + uiSearchHistoryLimit = 500, +} + +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 + +-- Keys preserved across the 2.0 settings reset; all others restored to defaults. +local PRESERVED_KEYS = { + dbVersion = true, + tutorialDone = true, + mapTabRecentSearches = true, + aliases = true, + uiSearchHistory = true, + uiSearchHistoryLimit = true, } +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) + 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 + + RequireRevampedTutorial(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 @@ -126,24 +223,74 @@ 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 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, + [4] = function(db) + local w = db.uiSearchWidth + if w == nil or w <= 0.88 then + db.uiSearchWidth = 1.54 + end + end, + [5] = function(db) + if db.localMapDirectOpen == false then db.localMapDirectOpen = true end + if db.globalMapDirectOpen == false then db.globalMapDirectOpen = true end + end, + [6] = function(db) + if db.mapTabFilters and db.mapTabFilters.flightpath == nil then + db.mapTabFilters.flightpath = false + end + end, + [8] = function(db) + if db.resultsTheme == "Retail" or db.resultsTheme == "Classic" then + db.resultsTheme = "Modern" + end + end, + [9] = function(db) + db.panelOpacity = nil + end, + [10] = function(db) + if not db.uiSearchBarHeight then db.uiSearchBarHeight = ns.SEARCHBAR_HEIGHT or 30 end + end, + [11] = function(db) + RequireRevampedTutorial(db) + end, + [12] = function(db) + db.searchBarOpacity = nil + end, + [13] = function(db) + RequireRevampedTutorial(db) + end, + [14] = function(db) + db.staticOpacity = nil + db.searchBarOpacity = nil + db.suggestedKeybindsApplied = nil + if db.mapTabFilters then + db.mapTabFilters.flightpath = false + end + end, + [15] = function(db) + db.showLoginMessage = false + end, + [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] = function(db) + ApplyFreshSettingsFor2(db) + end, } --- Fields that are runtime-only and must not persist in SavedVariables local RUNTIME_FIELDS = { "firstInstall", } @@ -215,6 +362,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 @@ -227,8 +375,6 @@ local function OnInitialize() end 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) @@ -236,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 @@ -246,21 +391,19 @@ 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 "" 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() elseif msg:find("^test ") then - -- /ef test Interface\\Path\\To\\Texture local texture = msg:match("^test%s+(.+)") if texture then EasyFind:TestIndicatorTexture(texture) @@ -284,10 +427,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"] @@ -325,91 +482,166 @@ 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") 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 local SafeAfter = Utils.SafeAfter -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 +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 - if EasyFind.db.enableUISearch ~= false then - SafeInit(ns.UI, "UI") - SafeInit(ns.Highlight, "Highlight") + 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 + +-- 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 + ns.Database:LoadDeferredSyncProvidersStaggered() + end + if not self._dynamicLoadTriggered then + self._dynamicLoadTriggered = true + if ns.Database.LoadHeavyDynamicSearchDataSync then + SafeAfter(0.5, function() + ns.Database:LoadHeavyDynamicSearchDataSync() + end) end - if EasyFind.db.enableMapSearch ~= false then - SafeInit(ns.MapSearch, "MapSearch") + -- Wardrobe / Heirlooms APIs sometimes aren't ready first pass. + SafeAfter(3.0, function() + if ns.Database.LoadDeferredSyncProvidersStaggered then + ns.Database:LoadDeferredSyncProvidersStaggered() + end + end) + end +end + +local function OnPlayerLogin() + 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 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) + 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 - 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) + end + if EasyFind.db.enableMapSearch ~= false then + SafeInit(ns.MapSearch, "MapSearch") + 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 + if ns.Options and ns.Options.RegisterWithBlizzardOptions then + local ok, err = xpcall(ns.Options.RegisterWithBlizzardOptions, ErrorHandler, ns.Options) if not ok then - EasyFind:Print("|cffff4444Loot scan failed: " .. tostring(err) .. "|r") + EasyFind:Print("|cffff4444Options registration failed: " .. tostring(err) .. "|r") end end + InstallTransmogClassFilterHook() - -- 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() - end) - end) - end) - end) + if ns.Database then + if ns.Database.WarmSearchHotPath then + xpcall(ns.Database.WarmSearchHotPath, ErrorHandler, ns.Database) + end + EasyFind:EnsureDynamicLoaded() + end + + -- 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 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 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 - -- Skip for brand-new installs (they get the first-time setup instead) - if 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) @@ -425,6 +657,14 @@ eventFrame:RegisterEvent("PLAYER_LOGIN") 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") +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() @@ -434,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 @@ -445,15 +683,38 @@ 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 + if ns.Database and ns.Database.SyncTransmogSetFiltersFromUI then + ns.Database:SyncTransmogSetFiltersFromUI() + MarkDynamicCategoryDirty("transmogSets") + InstallTransmogClassFilterHook() + end + elseif event == "UPDATE_MACROS" then + MarkDynamicCategoryDirty("macros") + elseif event == "SPELLS_CHANGED" then + if spellRefreshTimer then spellRefreshTimer:Cancel() end + spellRefreshTimer = C_Timer.NewTimer(1.0, function() + spellRefreshTimer = nil + MarkDynamicCategoryDirty("abilities") + end) + elseif event == "BAG_UPDATE_DELAYED" then + if bagRefreshTimer then bagRefreshTimer:Cancel() end + bagRefreshTimer = C_Timer.NewTimer(0.5, function() + bagRefreshTimer = nil + MarkDynamicCategoryDirty("bags") + end) + elseif event == "EQUIPMENT_SETS_CHANGED" then + if gearSetRefreshTimer then gearSetRefreshTimer:Cancel() end + gearSetRefreshTimer = C_Timer.NewTimer(0.3, function() + gearSetRefreshTimer = nil + 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 @@ -463,21 +724,30 @@ 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() - if WorldMapFrame and WorldMapFrame:IsShown() and ns.MapSearch then - ns.MapSearch:FocusLocalSearch() + self:EnsureDynamicLoaded() + if EasyFind.db.enableMapSearch ~= false and WorldMapFrame and WorldMapFrame:IsShown() and ns.MapTab then + ns.MapTab:Focus() elseif ns.UI then ns.UI:ToggleFocus() end end +function EasyFind:FocusMapSearch() + if EasyFind.db.enableMapSearch == false then return end + self:EnsureDynamicLoaded() + if ns.MapTab then ns.MapTab:Focus() end +end + function EasyFind:OpenOptions() if ns.Options then ns.Options:Toggle() end end @@ -504,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") @@ -533,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) @@ -545,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}, @@ -568,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 @@ -581,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)) @@ -614,13 +878,26 @@ 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) mmBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + -- 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 + 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() @@ -672,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 677b6bd..6b32081 100644 --- a/Database.lua +++ b/Database.lua @@ -5,14 +5,18 @@ ns.Database = Database local Utils = ns.Utils local ipairs = Utils.ipairs -local tsort, tconcat, tremove = Utils.tsort, Utils.tconcat, Utils.tremove +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 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; oldest prefixes evict when the ring buffer wraps. local wordCache = {} +local wordCacheKeys = {} +local wordCacheHead = 1 +local WORD_CACHE_MAX = 256 local function GetWords(str) local cached = wordCache[str] @@ -21,33 +25,94 @@ 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 --- Reusable row tables for DamerauLevenshtein (avoids 3 table allocs per call) local dlPrev2, dlPrev, dlCurr = {}, {}, {} --- Reusable sort comparator (avoids closure creation per SearchUI call) +local scoreNameUsedWords = {} + 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) + 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 --- Called after PLAYER_LOGIN when C_CurrencyInfo is available --- Scans the WoW currency list and injects any currencies not already in the static database +-- 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 = { + 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 + 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) - -- 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() @@ -64,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 @@ -103,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 @@ -112,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 @@ -120,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 @@ -128,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 @@ -143,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 = {} @@ -155,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 @@ -174,6 +217,7 @@ function Database:PopulateDynamicCurrencies() end words[#words + 1] = slower(immediateHeaderName) .. " " .. currNameLower + local isAccountTransferable = Database:IsCurrencyAccountTransferable(info.currencyID) local entry = { name = currName, keywords = words, @@ -183,6 +227,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 = {} @@ -200,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 @@ -212,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 @@ -228,14 +272,59 @@ function Database:PopulateDynamicCurrencies() end if not didCollapse then break end end + + -- 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 + 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 --- 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") - -- Expand all collapsed headers so we can see every faction local headersWeExpanded = {} for pass = 1, 50 do local numFactions = C_Reputation.GetNumFactions() @@ -243,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 @@ -262,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 @@ -280,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 @@ -292,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 @@ -302,6 +385,9 @@ function Database:PopulateDynamicReputations() return path end + 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) @@ -321,6 +407,16 @@ function Database:PopulateDynamicReputations() end keywords[#keywords + 1] = factionNameLower + 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, @@ -329,6 +425,7 @@ function Database:PopulateDynamicReputations() path = path, steps = steps, factionID = factionData.factionID, + factionSide = factionSide, hasRepBar = not factionData.isHeader or factionData.isHeaderWithRep, } @@ -350,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 @@ -368,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 @@ -393,12 +486,9 @@ function Database:PopulateDynamicReputations() end if not didCollapse then break end end + 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"}, @@ -435,6 +525,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 = {}, @@ -443,7 +542,32 @@ local LOOT_PROTO = { } local LOOT_MT = { __index = LOOT_PROTO } --- Map equip location strings to user-friendly search keywords +local TRANSMOG_SET_PROTO = { + category = "Appearance Set", + path = {}, + steps = {}, +} +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 } + + local SLOT_KEYWORDS = { INVTYPE_HEAD = {"helm", "helmet", "head"}, INVTYPE_NECK = {"neck", "necklace", "amulet"}, @@ -468,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"}, @@ -479,7 +602,73 @@ local STAT_KEYWORD_MAP = { ITEM_MOD_STRENGTH_SHORT = {"str", "strength"}, } --- Slot display names for result text +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 + local SLOT_DISPLAY = { INVTYPE_HEAD = "Head", INVTYPE_NECK = "Neck", INVTYPE_SHOULDER = "Shoulder", INVTYPE_CHEST = "Chest", INVTYPE_ROBE = "Chest", INVTYPE_WAIST = "Waist", @@ -491,26 +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 lootItemCache = {} -- itemID -> entry (persists across spec/diff toggles) -local lootSpecsScanned = {} -- ["classID-specID"] = true +local lootEntries = {} +local lootScanGeneration = 0 +local bossScanGeneration = 0 +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 }, @@ -518,13 +709,12 @@ 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] 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,8 +723,41 @@ function Database:SetEJDifficulty(diffID) if setDiff then setDiff(diffID) end end --- Sync the EJ's internal loot filter to match EasyFind's lootFilter setting. --- Called when the user changes the filter and before loot navigation. +-- 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") + local setDiff = EJ("SetDifficulty") + local getInstInfo = EJ("GetInstanceInfo") + if not selectInst or not getInst or not setDiff then return end + + -- journalInstanceID is the 12th return of EJ_GetInstanceInfo. + 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 + + if savedInstID and savedInstID > 0 then + selectInst(savedInstID) + end +end + function Database:SyncEJLootFilter() local setFilter = EJ("SetLootFilter") if not setFilter then return end @@ -555,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 @@ -571,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 @@ -595,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 @@ -619,19 +837,30 @@ 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. +-- 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() + 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 + -- 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 + end 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 @@ -641,10 +870,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 @@ -658,17 +889,18 @@ 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 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, _, _, _, _, @@ -683,19 +915,19 @@ 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 local savedCollected = hasFilterAPI and C_ToyBox.GetCollectedShown() local savedUncollected = C_ToyBox.GetUncollectedShown and C_ToyBox.GetUncollectedShown() @@ -714,34 +946,43 @@ function Database:PopulateDynamicToys() if itemID and itemID > 0 then local _, toyName, toyIcon = GetToyInfo(itemID) if toyName and toyName ~= "" then - uiSearchData[#uiSearchData + 1] = setmetatable({ + -- 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) + if ok and usable == false then isUsable = false end + 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 - -- 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 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") - -- 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) @@ -751,7 +992,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 @@ -769,29 +1017,114 @@ 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 end if C_PetJournal.SetSearchFilter then C_PetJournal.SetSearchFilter(savedString) end + return true +end + +function Database:PopulateDynamicHeirlooms() + 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 false end + + local hasHeirloom = C_Heirloom.PlayerHasHeirloom + local getInfo = C_Heirloom.GetHeirloomInfo + if not getInfo then return false 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 + return true +end + +-- 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 + local isKnown = IsTitleKnown + 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 false 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 + 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 - -- Remove previous outfit entries (handles mid-session outfit changes) - for i = #uiSearchData, 1, -1 do - if uiSearchData[i].category == "Outfit" then - tremove(uiSearchData, i) +function Database:PopulateDynamicGearSets() + 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 false end + + local getInfo = C_EquipmentSet.GetEquipmentSetInfo + if not getInfo then return false 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 + return true +end + +function Database:PopulateDynamicOutfits() + if not C_TransmogOutfitInfo or not C_TransmogOutfitInfo.GetOutfitsInfo then return false end + + 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 @@ -805,13 +1138,172 @@ function Database:PopulateDynamicOutfits() outfitEntries[#outfitEntries + 1] = entry end end + return true +end + +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 + +function Database:PopulateDynamicTransmogSets() + if not C_TransmogSets or not C_TransmogSets.GetAllSets then return false end + + RemoveEntriesWithField("transmogSetID") + -- 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() + if not allSets then return false end + + local db = EasyFind and EasyFind.db + 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 + local showPvE = not db or db.appearanceSetPvE ~= false + local showPvP = not db or db.appearanceSetPvP ~= false + + -- Guard against the Core.lua hooksecurefunc re-entering Populate. + 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 + + -- 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 + + 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 + if wcf and wcf.ClassDropdown and wcf.ClassDropdown.Update then + pcall(wcf.ClassDropdown.Update, wcf.ClassDropdown) + end + + 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 + 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] + -- 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 + local cm = setInfo.classMask or 0 + local classOk = not wantMask or cm == 0 or cm < 0 or band(cm, wantMask) ~= 0 + + 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) + + 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 + + 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 + return true 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 @@ -820,14 +1312,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 @@ -842,7 +1327,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") @@ -852,7 +1336,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 @@ -875,8 +1358,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 @@ -884,19 +1369,116 @@ 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(includeBosses) + lootScanGeneration = lootScanGeneration + 1 + if includeBosses then + bossScanGeneration = bossScanGeneration + 1 + end +end + +-- 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") @@ -913,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 @@ -927,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 @@ -943,23 +1524,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 @@ -985,80 +1553,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 @@ -1067,58 +1562,1362 @@ 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 lootSpecsScanned[sp.classID .. "-" .. sp.specID] = true end RebuildLootSearchData() - collectgarbage("collect") + Utils.SafeAfter(0, function() + collectgarbage("step", 200) + 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. - -function Database:FlattenTree(tree, parentPath, parentSteps, parentButtonFrame, parentCategory) - parentPath = parentPath or {} - parentSteps = parentSteps or {} +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 - for _, node in ipairs(tree) do - local myButtonFrame = node.buttonFrame or parentButtonFrame - local myCategory = node.category or parentCategory + local specPairs = BuildLootSpecPairs(scanAllSpecs) + local needScan = GetLootSpecsToScan(specPairs) + if #needScan == 0 then + RebuildLootSearchData() + done(true) + return + end - -- Accumulate steps: reuse parent array when node adds nothing - local mySteps - if node.steps then - mySteps = {} - for _, s in ipairs(parentSteps) do mySteps[#mySteps + 1] = s end - for _, s in ipairs(node.steps) do mySteps[#mySteps + 1] = s end - else - mySteps = parentSteps - end + if not EncounterJournal then + EncounterJournal_LoadUI() + end - -- Build the flat entry (path = parent names leading here, NOT including self) - local entry = { - name = node.name, - keywords = node.keywords or {}, + 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 + +function Database:PopulateDynamicMacros() + if not GetNumMacros or not GetMacroInfo then return false end + + RemoveEntriesByCategory("Macro") + 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 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 + 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 + return true +end + +function Database:PopulateDynamicAbilities() + RemoveEntriesByCategory("Ability") + 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 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) + 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 + 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) + 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 + -- 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 + 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 + end + end + end + return true +end + +-- 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"}, + ["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 + +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 + +function Database:PopulateDynamicBosses() + 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 + return + end + + -- Suppress EJ UI events so opening the journal mid-scan can't fight us. + 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 + + AddBossEntry(tier, isRaid, instID, instName, encName, encID, getCreatureInfo) + encIdx = encIdx + 1 + end + + idx = idx + 1 + end + end + end + + if savedTier and selectTier then selectTier(savedTier) end + 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 + +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, nodeInfo) + 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 + + -- Row renderer desaturates the 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] = { + 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, + talentIsAllocated = isAllocated and true 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, nodeInfo) + end + end + end + end + end + return true +end + +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 + 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 + + 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 + -- Classic-era multi-return 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 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") + 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, + equipLoc = equipLoc, + isEquippable = isEquippable, + bagID = first.bag, + bagSlot = first.slot, + bagItemLink = info.link, + bagCount = info.totalCount, + bagLocations = info.locations, + buttonFrame = bagBtn, + steps = { + { buttonFrame = bagBtn }, + { containerBag = first.bag, containerSlot = first.slot, allLocations = info.locations }, + }, + } + end + return true +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 + +-- 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"}, + ["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 RefreshStatisticsCategoryIDs +local IsInStatisticsCategoryTree +local MarkID +local HasID + +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 + +function Database:PopulateDynamicAchievements() + if not GetCategoryList or not GetCategoryInfo then return false end + -- 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") + + local categories = GetCategoryList() + if not categories then return false end + + local statsCatSet = RefreshStatisticsCategoryIDs() + + local catMap = {} + for i = 1, #categories do + local catID = categories[i] + if not HasID(statsCatSet, catID) and not HasID(Database.statisticIDs, catID) then + local name, parentID, flags = GetCategoryInfo(catID) + 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, + 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 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 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, + achievementCategoryID = cat.id, + } + + uiSearchData[#uiSearchData + 1] = buildCategoryEntry({ + displayName = prefix .. cat.name, + 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 + +-- 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 {} +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) + 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 + +-- Bumping during a scan causes the in-flight time-sliced loop to bail. +local statsScanGeneration = 0 + +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 + + -- 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) + 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 + + -- Keywords skipped; the unique stat name is enough for the scorer. + 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 + + 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 + -- 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 + 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 + + -- Sync fallback path for environments without C_Timer. + local seenStatisticIDs = {} + local function emit(cat, parentChain) + local baseSteps = { + { buttonFrame = "AchievementMicroButton" }, + { waitForFrame = "AchievementFrame", tabIndex = 3 }, + } + local pathBase = { "Achievements", "Statistics" } + for i = 1, #parentChain do + 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, + statisticsCategoryID = cat.id, + } + pathBase[#pathBase + 1] = cat.name + + if GetCategoryNumAchievements and GetAchievementInfo then + 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 + 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 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, + 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 + + 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 + Database.statisticsComplete = true + Database.statisticsVersion = Database.statisticsVersion + 1 + return true +end + +-- Children inherit buttonFrame/category and accumulate path + steps. +function Database:FlattenTree(tree, parentPath, parentSteps, parentButtonFrame, parentCategory) + parentPath = parentPath or {} + parentSteps = parentSteps or {} + + for _, node in ipairs(tree) do + local myButtonFrame = node.buttonFrame or parentButtonFrame + local myCategory = node.category or parentCategory + + -- Reuse parent array when node adds nothing. + local mySteps + if node.steps then + mySteps = {} + for _, s in ipairs(parentSteps) do mySteps[#mySteps + 1] = s end + for _, s in ipairs(node.steps) do mySteps[#mySteps + 1] = s end + else + mySteps = parentSteps + end + + -- path = parent names leading here, NOT including self. + local entry = { + name = node.name, + keywords = node.keywords or {}, category = myCategory, buttonFrame = myButtonFrame, path = {}, 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 if node.canQueue then entry.canQueue = true end - if node.children then entry.isContainer = true end + if node.slashCommand then entry.slashCommand = node.slashCommand end 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 @@ -1129,15 +2928,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"}, @@ -1192,7 +2987,6 @@ function Database:BuildUIDatabase() }, }, - -- PROFESSIONS { name = "Professions", keywords = {"professions", "profession", "crafting", "trade skills", "skills"}, @@ -1201,7 +2995,6 @@ function Database:BuildUIDatabase() steps = {{ buttonFrame = "ProfessionMicroButton" }}, }, - -- TALENTS & SPELLBOOK { name = "Talents & Spellbook", keywords = {"talents and spellbook", "class abilities"}, @@ -1222,562 +3015,49 @@ function Database:BuildUIDatabase() steps = {{ waitForFrame = "PlayerSpellsFrame", tabIndex = 2 }}, children = { { - name = "PvP Talents", - keywords = {"pvp talents", "pvp abilities", "battleground talents", "pvp"}, - steps = {{ waitForFrame = "PlayerSpellsFrame", regionFrames = { "FIND_PVP_TALENTS" }, text = "PvP Talents are at the bottom right of the Talents pane" }}, - }, - { - name = "War Mode", - keywords = {"war mode", "warmode", "pvp toggle", "world pvp", "pvp on", "pvp off", "pvp"}, - steps = {{ waitForFrame = "PlayerSpellsFrame", regionFrames = { "PlayerSpellsFrame.TalentsFrame.WarmodeButton" } }}, - }, - }, - }, - { - name = "Spellbook", - keywords = {"spellbook", "spells", "abilities", "skills", "spell book"}, - category = "Talents", - steps = {{ waitForFrame = "PlayerSpellsFrame", tabIndex = 3 }}, - }, - }, - }, - - -- ACHIEVEMENTS - { - name = "Achievements", - keywords = {"achievement", "achievements", "achieve", "points"}, - category = "Menu Bar", - buttonFrame = "AchievementMicroButton", - steps = {{ buttonFrame = "AchievementMicroButton" }}, - children = { - { - name = "Achievements Tab", - 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 = "PvP Talents", + keywords = {"pvp talents", "pvp abilities", "battleground talents", "pvp"}, + steps = {{ waitForFrame = "PlayerSpellsFrame", regionFrames = { "FIND_PVP_TALENTS" }, text = "PvP Talents are at the bottom right of the Talents pane" }}, }, { - 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" }} }, - }, + name = "War Mode", + keywords = {"war mode", "warmode", "pvp toggle", "world pvp", "pvp on", "pvp off", "pvp"}, + steps = {{ waitForFrame = "PlayerSpellsFrame", regionFrames = { "PlayerSpellsFrame.TalentsFrame.WarmodeButton" } }}, }, }, }, + { + name = "Spellbook", + keywords = {"spellbook", "spells", "abilities", "skills", "spell book"}, + category = "Talents", + steps = {{ waitForFrame = "PlayerSpellsFrame", tabIndex = 3 }}, + }, + }, + }, - -- GUILD ACHIEVEMENTS (Tab 2) + { + name = "Achievements", + keywords = {"achievement", "achievements", "achieve", "points"}, + category = "Menu Bar", + buttonFrame = "AchievementMicroButton", + steps = {{ buttonFrame = "AchievementMicroButton" }}, + children = { { - name = "Guild Achievements", - keywords = {"guild achievements", "guild tab", "guild points"}, + name = "Achievements Tab", + keywords = {"achievements", "achievement tab", "personal achievements"}, 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" }} }, - }, + steps = {{ waitForFrame = "AchievementFrame", tabIndex = 1 }}, }, - -- STATISTICS (Tab 3) { name = "Statistics", keywords = {"statistics", "stats tab", "player statistics"}, category = "Achievements", steps = {{ waitForFrame = "AchievementFrame", tabIndex = 3 }}, - 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) - { - 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" }, - }, - }, - }, }, }, }, - -- QUEST LOG { name = "Quest Log", keywords = {"quest", "quests", "objectives", "log", "journal"}, @@ -1786,7 +3066,6 @@ function Database:BuildUIDatabase() steps = {{ buttonFrame = "QuestLogMicroButton" }}, }, - -- HOUSING { name = "Housing Dashboard", keywords = {"housing", "house", "home", "dashboard", "player housing"}, @@ -1795,7 +3074,6 @@ function Database:BuildUIDatabase() steps = {{ buttonFrame = "HousingMicroButton" }}, }, - -- GUILD & COMMUNITIES { name = "Guild & Communities", keywords = {"guild", "communities", "social", "clan"}, @@ -1804,7 +3082,6 @@ function Database:BuildUIDatabase() steps = {{ buttonFrame = "GuildMicroButton" }}, }, - -- GROUP FINDER { name = "Group Finder", keywords = {"lfg", "lfd", "lfr", "finder", "queue", "group finder"}, @@ -1812,7 +3089,6 @@ function Database:BuildUIDatabase() buttonFrame = "LFDMicroButton", steps = {{ buttonFrame = "LFDMicroButton" }}, children = { - -- PVE SECTION { name = "Dungeons & Raids", keywords = {"dungeons", "raids", "dungeons and raids"}, @@ -1837,7 +3113,6 @@ function Database:BuildUIDatabase() }, }, - -- PVP SECTION { name = "Player vs. Player", keywords = {"pvp", "player vs player", "battleground", "arena", "bg"}, @@ -1890,7 +3165,6 @@ function Database:BuildUIDatabase() }, }, - -- MYTHIC+ SECTION { name = "Mythic+ Dungeons", keywords = {"mythic", "mythic+", "m+", "keystone", "mythic plus", "keys"}, @@ -1900,7 +3174,6 @@ function Database:BuildUIDatabase() }, }, - -- WARBAND COLLECTIONS { name = "Warband Collections", keywords = {"collections", "warband"}, @@ -1917,7 +3190,6 @@ function Database:BuildUIDatabase() }, }, - -- TRANSMOGRIFICATION { name = "Transmogrification", keywords = {"transmogrification", "transmog", "tmog", "mog", "wardrobe", "outfit", "outfits", "appearance", "keymog"}, @@ -1926,7 +3198,6 @@ function Database:BuildUIDatabase() steps = {{ loadTransmog = true }}, }, - -- ADVENTURE GUIDE { name = "Adventure Guide", keywords = {"adventure", "guide", "dungeon journal", "encounters", "loot", "boss", "journal"}, @@ -1946,13 +3217,15 @@ function Database:BuildUIDatabase() }, }, - -- GAME MENU / HELP / SHOP + -- /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"}, category = "Menu Bar", buttonFrame = "MainMenuMicroButton", - steps = {{ buttonFrame = "MainMenuMicroButton" }}, + slashCommand = "/click MainMenuMicroButton", }, { name = "Help", @@ -1972,7 +3245,6 @@ function Database:BuildUIDatabase() }, }, - -- PORTRAIT MENU OPTIONS (Auto-generated by Harvester) { name = "Portrait Menu", keywords = {"portrait", "portrait menu", "right click portrait", "player frame menu"}, @@ -2003,12 +3275,11 @@ function Database:BuildUIDatabase() }, }, - -- OTHER UI ELEMENTS (no tree hierarchy, standalone) { name = "Bags / Inventory", keywords = {"bags", "bag", "inventory", "backpack", "items", "storage"}, category = "Inventory", - icon = 130716, + iconAtlas = "bag-main", steps = {{ buttonFrame = "MainMenuBarBackpackButton" }}, }, { @@ -2019,46 +3290,74 @@ 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 ToggleWorldMap()", + }, + { + 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", + }, + + -- /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", + "battle pet", "critter", "minion"}, + category = "Action", + 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", }, } - -- 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 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 @@ -2070,7 +3369,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 @@ -2098,49 +3396,49 @@ 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" 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 }, +} + +-- 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, + ["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. --- 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 + local found = sfind(text, query, 1, true) + if not found then return false end + if found == 1 then return true end + -- 32=space 45=- 40=( 58=: 47=/ 46=. + while found do + local prev = sbyte(text, found - 1) + 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`. --- "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 @@ -2151,34 +3449,31 @@ 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 - if ssub(query, i, i) ~= ssub(words[i], 1, 1) then + if sbyte(query, i) ~= sbyte(words[i], 1) then allMatch = false break 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 - -- "raba" → "ra(ndom) ba(ttleground)" - greedily consume query chars across words - local qi = 1 -- position in query + -- 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 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 + if sbyte(query, qi + matchLen) == sbyte(w, matchLen + 1) then matchLen = matchLen + 1 else break @@ -2187,38 +3482,43 @@ 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 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 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 + end + + local queryFirst = sbyte(query, 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 must match: typos rarely hit the first char. + if sbyte(word, 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 @@ -2227,17 +3527,17 @@ function Database:ScoreFuzzy(text, query, queryLen) return bestScore end --- Requires first character match to avoid spurious hits like "ahn'" in "magtheridon's". +-- First-char constraint avoids spurious hits like "ahn'" in "magtheridon's". 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 @@ -2247,16 +3547,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 @@ -2264,67 +3562,82 @@ 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 - prev[j - 1] + cost -- substitution + prev[j] + 1, + curr[j - 1] + 1, + prev[j - 1] + cost ) - -- 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 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 --- 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. +-- 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 + 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 + return false + end + end + return true +end + +-- 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 + if not Database:CouldMatch(nameLower, query) then return 0 end + local score = 0 - -- Whole-string matching (works for single and multi-word queries) 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 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 @@ -2344,10 +3657,9 @@ 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. - if score < 50 and sfind(query, " ", 1, true) then + -- 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 queryWords = {} @@ -2359,7 +3671,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 @@ -2371,21 +3684,21 @@ 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 then + elseif qwLen >= 4 and sbyte(nw, 1) == sbyte(qw, 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 - -- 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 @@ -2419,31 +3732,18 @@ 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 --- 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 = {} @@ -2452,9 +3752,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 @@ -2462,14 +3762,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 ssub(kw, 1, queryLen) == query then + 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 @@ -2477,12 +3776,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 @@ -2506,27 +3803,26 @@ 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 must match at least one keyword; a single unmatched word - -- zeroes the total to prevent common words like "of" from producing false positives. + -- 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] 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 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 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 @@ -2534,7 +3830,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 @@ -2544,64 +3839,344 @@ function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) bestScore = kwScore end end + if bestScore == 0 then + return 0 + end + total = total + bestScore + end + end + + return total +end + +-- 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 - if bestScore == 0 then - return 0 +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 + + 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 nameBest = ScoreFieldWords(nameWords, qw, qwLen) + local kwBest = 0 + 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 > 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 end - total = total + bestScore end - return total + if matched < 2 then return 0 end + -- 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 + return total + matched * 5 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 prevQuery = "" local prevSkipKey = "" local prevCandidates = {} +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 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 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 + 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 + +function Database:ResetSearchCache() + if self._dynamicBatchLoading then + self._dynamicBatchChanged = true + return + end + prevQuery = "" + prevSkipKey = "" + wipe(prevCandidates) + local hadPrefixIndex = prefixIndexReady + ClearPrefixBuckets() + if hadPrefixIndex then self:BuildSearchPrefixIndex() end +end + +local resultsBuf = {} +local resultsQueryWords = {} +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 = "" - return {} + wipe(prevCandidates) + wipe(resultsBuf) + return resultsBuf + end + + -- 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 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 - if queryLen == 0 then prevQuery = ""; return {} end + if queryLen == 0 then prevQuery = ""; wipe(prevCandidates); 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 - -- Determine candidate set: incremental (previous matches) or full database + -- 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 + 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 + end + end + 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 "" + -- 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 + 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 searchSet = prevCandidates else - searchSet = uiSearchData + 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 or #searchSet == 0 then + wipe(resultsBuf) + wipe(prevCandidates) + prevQuery = query + prevSkipKey = skipKey + return resultsBuf + end end - local results = {} + wipe(resultsBuf) + local results = resultsBuf + local resultsN = 0 local candidateIdx = 0 local searchCount = #searchSet @@ -2612,9 +4187,11 @@ function Database:SearchUI(query, skipCategories) local nameLower = data.nameLower local score if data.lootEntry then - -- 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. + if lootStatQueryWord and not data._statsEnriched then + Database:EnrichLootStats(data) + end + -- 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) @@ -2623,48 +4200,50 @@ 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) 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 - 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 - -- Score against slot keywords if data.lootSlotKw then for ki = 1, #data.lootSlotKw do local kw = data.lootSlotKw[ki] if kw == qw then bestWord = mmax(bestWord, qwLen <= 3 and 140 or 80) - elseif ssub(kw, 1, qwLen) == qw then + elseif sfind(kw, qw, 1, true) == 1 then bestWord = mmax(bestWord, 70) end end end - -- Score against stat keywords if data.lootStatKw then for ki = 1, #data.lootStatKw do local kw = data.lootStatKw[ki] if kw == qw then bestWord = mmax(bestWord, qwLen <= 3 and 140 or 80) - elseif ssub(kw, 1, qwLen) == qw then + elseif sfind(kw, qw, 1, true) == 1 then bestWord = mmax(bestWord, 70) end end end - -- Score against source keywords (boss/dungeon name) + -- 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] if kw == qw then - bestWord = mmax(bestWord, 80) - elseif ssub(kw, 1, qwLen) == qw then - bestWord = mmax(bestWord, 70) + bestWord = mmax(bestWord, 110) + elseif sfind(kw, qw, 1, true) == 1 then + bestWord = mmax(bestWord, 100) end end end @@ -2677,19 +4256,53 @@ function Database:SearchUI(query, skipCategories) end score = totalScore - else + elseif data.category == "Boss" and not bossQueryWord then 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 + -- Strong name matches only (skip kw score) so short kw + -- aliases don't drag every achievement category in. + if nameLower == query then + score = 200 + elseif sfind(nameLower, query, 1, true) == 1 then + score = 150 + elseif Database:FindAtWordBoundary(nameLower, query) then + score = 120 + else + score = 0 + end + else + -- 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) + ) + if #queryWords >= 2 then + score = mmax(score, Database:ScoreEntryFields(data, queryWords)) + end + 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 end end - -- Trim stale entries from previous search for i = candidateIdx + 1, #prevCandidates do prevCandidates[i] = nil end @@ -2697,272 +4310,29 @@ function Database:SearchUI(query, skipCategories) prevSkipKey = skipKey tsort(results, scoreDescending) - 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 - - -- 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 - local itemData = item.data - local path = itemData.path or {} - local parentNode = getOrCreateNode(path) - local itemScore = item.score or 0 - - 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 not existing.data then existing.data = itemData end - existing.isMatch = true -- direct search match - if itemScore > existing.bestScore then - existing.bestScore = itemScore - end - else - parentNode.children[itemData.name] = { - name = itemData.name, - children = {}, - childOrder = {}, - data = itemData, - bestScore = itemScore, - isMatch = true, -- direct search match - } - 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] - 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) - - 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 + local SEARCH_RESULT_CAP = 250 + if resultsN > SEARCH_RESULT_CAP then + for i = SEARCH_RESULT_CAP + 1, resultsN do results[i] = nil end + for i = SEARCH_RESULT_CAP + 1, #resultEntryPool do + resultEntryPool[i] = nil end + resultsN = SEARCH_RESULT_CAP end - - -- Sort alphabetically - tsort(children, function(a, b) return (a.name or "") < (b.name or "") end) - return children -end - -function Database:FindItemByName(name) - for _, data in ipairs(uiSearchData) do - if data.name == name then - return data + -- 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 + r.data = nil + r.score = nil + r.isAlias = nil end end - return nil + 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 new file mode 100644 index 0000000..91a4c8c --- /dev/null +++ b/DatabaseDynamic.lua @@ -0,0 +1,266 @@ +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 = "achievements", category = "Achievement Category", fn = "PopulateDynamicAchievements" }, + { 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" }, + { 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 = "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" }, + { 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 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, finishWaiters) + end) + if not ok then + FinishDynamicProvider(database, provider, false, err, false, finishWaiters) + end + return + end + + local fn = database[provider.fn] + if not fn then onDone(false); return end + + 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() + 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 + +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: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 + provider.dirty = true + local changed = false + RunDynamicProvider(self, provider, function(providerChanged) + changed = providerChanged + end) + return changed +end + +function Database:LoadCoreDynamicSearchData() + return false +end + +function Database:LoadDeferredSyncProvidersStaggered() + -- 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 + 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() + Utils.SafeAfter(0, step) + end) + return + end + end + local changed = self._dynamicBatchChanged + self._dynamicBatchLoading = false + self._dynamicBatchChanged = false + if changed and self.ResetSearchCache then self:ResetSearchCache() end + if self.WarmSearchHotPath then self:WarmSearchHotPath() end + end + step() +end + +-- 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 + local provider = dynamicProviders[i] + 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; spec + -- toggles trigger a lazy scan for that spec. + 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 + if self.ResetSearchCache then self:ResetSearchCache() end + if self.WarmSearchHotPath then self:WarmSearchHotPath() end +end + +function Database:LoadHeavyDynamicSearchData(onDone) + if self._loadingHeavyDynamic then return false end + 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(changed) + if changed then anyChanged = true end + Utils.SafeAfter(0.05, step) + end) + return + end + end + self._loadingHeavyDynamic = false + if onDone then onDone(anyChanged) end + end + + step() + return true +end + +function Database:UnloadDynamicSearchData(includeCore) + if self.CancelDynamicScans then self:CancelDynamicScans(true) end + 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/EasyFind.toc b/EasyFind.toc index d618ab8..ec80646 100644 --- a/EasyFind.toc +++ b/EasyFind.toc @@ -2,19 +2,29 @@ ## Title: EasyFind ## Notes: Search and locate UI elements and map locations with ease ## Author: justawower -## Version: 1.5.0 -## IconTexture: 136460 +## Version: 2.0.0 +## IconTexture: Interface\AddOns\EasyFind\Textures\SpyglassMinimap ## Category: UI ## AddonCompartmentFunc: EasyFind_OnAddonCompartmentClick ## SavedVariables: EasyFindDB ## DefaultState: enabled Utils.lua +MapUtils.lua Core.lua Database.lua +DatabaseDynamic.lua StaticLocations.lua +TutorialBox.lua +ChevronConfig.lua +Aliases.lua +UIPins.lua UI.lua +Wizard.lua +Perf.lua Highlight.lua MapSearch.lua +MapTab.lua Rescaler.lua +BlizzOptionsSearch.lua Options.lua diff --git a/Fonts/Inter-Bold.ttf b/Fonts/Inter-Bold.ttf new file mode 100644 index 0000000..9fb9b75 Binary files /dev/null and b/Fonts/Inter-Bold.ttf differ diff --git a/Fonts/Inter-LICENSE.txt b/Fonts/Inter-LICENSE.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/Fonts/Inter-LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/Fonts/Inter-Regular.ttf b/Fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/Fonts/Inter-Regular.ttf differ diff --git a/Fonts/Inter-SemiBold.ttf b/Fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/Fonts/Inter-SemiBold.ttf differ diff --git a/Highlight.lua b/Highlight.lua index 0d54322..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,12 +33,166 @@ 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() + 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 +local function isTerminalHighlight() + if not currentGuide or not currentGuide.steps then return true end + return currentStepIndex and currentStepIndex >= #currentGuide.steps +end + +local function clearTerminalHighlight() + -- Cancel (not HideHighlight) so a guide ticker can't recreate the + -- final highlight after hover / scroll / panel close. + Highlight:Cancel() +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 + + -- 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() @@ -55,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 @@ -81,6 +234,41 @@ function Highlight:CreateHighlightFrame() alpha:SetToAlpha(0.3) alpha:SetDuration(0.5) highlightFrame.animGroup = animGroup + + -- 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 + if watchAccum < 0.1 then return end + watchAccum = 0 + local hoverFrame = self._hoverDismissFrame + if hoverFrame and canHoverDismiss() + and hoverFrame.IsMouseOver and hoverFrame:IsMouseOver() then + 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 + if terminal then + clearTerminalHighlight() + else + Highlight:HideHighlight() + end + return + end + end) end function Highlight:CreateIndicatorFrame() @@ -89,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") @@ -137,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) @@ -228,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() @@ -257,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 @@ -284,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) @@ -293,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) @@ -339,28 +511,55 @@ function Highlight:UpdateGuide() return end - -- Portrait menu step 2: find and highlight the correct menu option + -- 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 + -- 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 + 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 + 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 - -- 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) @@ -369,49 +568,83 @@ function Highlight:UpdateGuide() return end - -- Step 2+ type: Wait for frame, then highlight tab or region + -- 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 + if step.waitForFrame then 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 - -- 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 - -- Side tab (like Dungeon Finder / Raid Finder / Premade Groups in Group Finder) + -- 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) + 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 + + 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 + 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] @@ -424,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 @@ -436,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() @@ -468,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] @@ -484,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 @@ -497,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() @@ -529,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 @@ -544,13 +753,13 @@ 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 - if not self:IsCategoryExpandedOrSelected(prevStep.statisticsCategory) then + if not self:IsCategoryExpandedOrSelected(prevStep.statisticsCategory, prevStep.statisticsCategoryID) then currentStepIndex = i self:HideHighlight() return @@ -558,34 +767,31 @@ function Highlight:UpdateGuide() end 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) + -- 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) + local elementData = self:FindCategoryElementData(step.statisticsCategory, step.statisticsCategoryID) 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:GetStatisticsCategoryButton(step.statisticsCategory) + local categoryBtn = self:GetStatisticsCategoryButton(step.statisticsCategory, step.statisticsCategoryID) if categoryBtn then self:HighlightFrame(categoryBtn) else @@ -594,9 +800,69 @@ function Highlight:UpdateGuide() return end - -- Achievement category navigation (tree-based category selection in Achievements/Guild tabs) + 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 + + 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 + if step.achievementCategory then - -- First check: are we still on the correct tab? + if not (AchievementFrame and AchievementFrame:IsShown()) then + self:Cancel() + return + end local requiredTabIndex = nil for i = currentStepIndex - 1, 1, -1 do local prevStep = currentGuide.steps[i] @@ -609,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 @@ -621,13 +886,10 @@ 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 - if not self:IsCategoryExpandedOrSelected(prevStep.achievementCategory) then + if not self:IsCategoryExpandedOrSelected(prevStep.achievementCategory, prevStep.achievementCategoryID) then currentStepIndex = i self:HideHighlight() return @@ -635,34 +897,19 @@ function Highlight:UpdateGuide() end 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 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 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) + local categoryBtn = self:GetAchievementCategoryButton(step.achievementCategory, step.achievementCategoryID) if categoryBtn then self:HighlightFrame(categoryBtn) else @@ -671,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] @@ -697,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() @@ -707,17 +951,11 @@ function Highlight:UpdateGuide() return end - -- Highlight the sidebar tab button 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"} @@ -727,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] @@ -753,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 @@ -767,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 @@ -778,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] @@ -841,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 @@ -873,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] @@ -899,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 @@ -913,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 @@ -924,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 @@ -944,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] @@ -988,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 @@ -1019,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 @@ -1041,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 @@ -1050,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 @@ -1059,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 @@ -1068,7 +1261,16 @@ function Highlight:UpdateGuide() end end - -- Validate EJ boss (encounterID changed = user clicked different boss) + 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 + if prev.ejEncounterID then local infoFrame = _G["EncounterJournalEncounterFrameInfo"] local ej = _G["EncounterJournal"] @@ -1086,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 @@ -1099,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() @@ -1122,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) @@ -1136,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 @@ -1170,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 @@ -1178,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 @@ -1199,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 @@ -1222,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 @@ -1238,18 +1427,149 @@ function Highlight:UpdateGuide() return end - -- Text-only final step (when we've navigated but can't highlight specific element) + 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 + + if step.transmogSetID then + local wcf = _G["WardrobeCollectionFrame"] + 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. + -- Use SetScrollPercentage against the element's index instead. + 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 + end + + 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 + + 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 + + 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 + + -- 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 + local talentsFrame = PlayerSpellsFrame and PlayerSpellsFrame.TalentsFrame + if not talentsFrame then return 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 + + -- 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 + 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 match + 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 + 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 + end + else + self:ShowInstruction(step.text or "Look for this talent in the talents tree") + end + return + end + 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 @@ -1258,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 @@ -1307,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 @@ -1333,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 @@ -1357,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 @@ -1366,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 @@ -1397,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 @@ -1406,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 @@ -1470,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 @@ -1482,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 @@ -1492,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 @@ -1511,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 @@ -1524,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 @@ -1549,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 @@ -1561,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] @@ -1580,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 @@ -1594,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 @@ -1615,7 +1893,6 @@ function Highlight:GetPvPSideTabButton(frameName, sideTabIndex) return btn end - -- Fallback: Try to find generic category buttons local fallbackNames = { "PVPQueueFrameCategoryButton1", "PVPQueueFrameCategoryButton2", @@ -1628,14 +1905,26 @@ 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) + if not catID then return false end + + local numericCategoryID = tonumber(categoryID) + if numericCategoryID then return catID == numericCategoryID end --- Shared helper: find element data in the ScrollBox data provider by category name. --- Returns (elementData, scrollBox) or (nil, nil). -function Highlight:FindCategoryElementData(categoryName) + if categoryName and GetCategoryInfo then + local title = GetCategoryInfo(catID) + if title and slower(title) == slower(categoryName) then return true end + end + return false +end + +function Highlight:FindCategoryElementData(categoryName, categoryID) local categoriesFrame = _G["AchievementFrameCategories"] if not categoriesFrame or not categoriesFrame.ScrollBox then return nil, nil end @@ -1643,59 +1932,40 @@ 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 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. -function Highlight:FindVisibleCategoryButton(categoryName) +-- 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 - 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 - -- Fallback: EnumerateFrames with GetElementData if scrollBox.EnumerateFrames then 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 @@ -1704,17 +1974,13 @@ function Highlight:FindVisibleCategoryButton(categoryName) 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) - -- Primary: check the data provider for elementData.selected - local elementData = self:FindCategoryElementData(categoryName) +function Highlight:IsCategorySelectedByData(categoryName, categoryID) + 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 @@ -1723,19 +1989,13 @@ function Highlight:IsCategorySelectedByData(categoryName) 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. -function Highlight:IsCategoryExpandedOrSelected(categoryName) - local elementData = self:FindCategoryElementData(categoryName) +-- 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 @@ -1743,39 +2003,31 @@ function Highlight:IsCategoryExpandedOrSelected(categoryName) 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) - 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 - -- 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 @@ -1783,16 +2035,14 @@ function Highlight:IsStatisticsCategorySelected(categoryName) return false end - -- Primary: check elementData.selected via data provider - if self:IsCategorySelectedByData(categoryName) then + 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 - local nextBtn = self:FindVisibleCategoryButton(nextStep.statisticsCategory) + local nextBtn = self:FindVisibleCategoryButton(nextStep.statisticsCategory, nextStep.statisticsCategoryID) if nextBtn then return true end @@ -1802,7 +2052,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 @@ -1810,31 +2060,25 @@ function Highlight:GetStatisticsCategoryButton(categoryName) 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 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 - -- 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 - local nextBtn = self:FindVisibleCategoryButton(nextStep.achievementCategory) + local nextBtn = self:FindVisibleCategoryButton(nextStep.achievementCategory, nextStep.achievementCategoryID) if nextBtn then return true end @@ -1844,45 +2088,35 @@ 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) 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 @@ -1895,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 @@ -1917,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 @@ -1944,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 @@ -1955,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 @@ -1997,46 +2219,69 @@ 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 - -- EncounterJournal tabs (Adventure Guide) + if frameName == "WardrobeCollectionFrame" then + local tabBtn = _G["WardrobeCollectionFrameTab" .. tabIndex] + if tabBtn then return tabBtn end + local frame = _G["WardrobeCollectionFrame"] + if frame then + if frame.Tabs and frame.Tabs[tabIndex] then return frame.Tabs[tabIndex] end + 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 + -- 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 + for i = 1, select("#", frame:GetChildren()) do + local child = select(i, frame:GetChildren()) + 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 + end + end + 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 @@ -2047,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 @@ -2062,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"}, @@ -2077,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 @@ -2117,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 @@ -2130,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", @@ -2154,19 +2387,23 @@ 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 + highlightFrame._targetFrame = frame + highlightFrame._targetValidator = validator + highlightFrame._hoverDismissFrame = frame + highlightFrame._clearWhenTargetHidden = true + local bs = highlightFrame.borderSize local pad = 4 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) @@ -2177,7 +2414,6 @@ function Highlight:HighlightFrame(frame, instructionText) 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) @@ -2213,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() @@ -2227,11 +2461,120 @@ function Highlight:ShowInstruction(text) instructionFrame:Show() end +-- 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 + 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 + highlightFrame._hoverDismissFrame = button + highlightFrame._clearWhenTargetHidden = true + -- 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 + 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 + +function Highlight:RegisterTalentSearchIcon(button, targetNameLower, nameOf) + self:RegisterSearchIconWatch(button, function(f) + return nameOf and nameOf(f) == targetNameLower + end) +end + +-- 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() + return + end + self:HideHighlight() + + -- 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 + 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._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 + 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 + function Highlight:HideHighlight() highlightShownAt = nil if highlightFrame then + if highlightFrame._talentSearchBtn + 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._hoverDismissFrame = nil + highlightFrame._clearWhenTargetHidden = nil highlightFrame:Hide() if highlightFrame.animGroup then highlightFrame.animGroup:Stop() end + 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() @@ -2263,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 @@ -2273,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 @@ -2285,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] @@ -2305,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 @@ -2315,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 @@ -2326,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] @@ -2341,6 +2674,28 @@ function Highlight:GetPortraitMenuFrame() return nil end +-- 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) + 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) @@ -2374,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 @@ -2385,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 @@ -2394,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 @@ -2406,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] @@ -2422,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 @@ -2437,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 @@ -2474,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 @@ -2500,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 @@ -2523,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 @@ -2535,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 @@ -2582,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 @@ -2608,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 1879aa3..2ce4f06 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -4,33 +4,40 @@ 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 = Utils.sfind, Utils.slower -local mmin, mmax, mpi = Utils.mmin, Utils.mmax, Utils.mpi -local pcall, xpcall, tostring = Utils.pcall, Utils.xpcall, Utils.tostring -local ErrorHandler = Utils.ErrorHandler +local 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 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 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 --- 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 @@ -42,21 +49,17 @@ 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 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, "%-", " ") @@ -64,10 +67,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 @@ -77,14 +122,12 @@ 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) 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 @@ -92,17 +135,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", @@ -114,11 +156,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, @@ -126,8 +168,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}, @@ -144,7 +184,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"] @@ -155,35 +194,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. +-- 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 --- 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 - --- 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 @@ -195,15 +223,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 @@ -211,7 +232,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) @@ -229,7 +249,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) @@ -240,15 +259,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() @@ -262,7 +277,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) @@ -278,8 +292,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) @@ -289,59 +302,49 @@ 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 -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 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 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 -local efTrackedVignetteGUID = nil -- GUID we explicitly set via SetSuperTrackedVignette (rares only) - --- Reusable tables for OnSearchTextChanged (wiped each call to avoid per-keystroke allocations) +local zoneHighlightFrame +local isGlobalSearch = false +-- Set in Guide mode so the breadcrumb's final waypoint dismisses on hover. +local pinHoverClearsOverride = nil +local activePinState = nil +local mapIsMaximized = false +local cachedWorldZones +local worldZonePrefixIndex = {} +local worldZonePrefixSeen = {} +local worldZonePrefixReady = false +local emptyWorldZones = {} +local rareTrackCache = {} +local rareTrackMapID = nil +local rareDeadGUIDs = {} +MapSearch._rareTrackCache = rareTrackCache +MapSearch._rareDeadGUIDs = rareDeadGUIDs +local efTrackedVignetteGUID = nil + local reuseAllPOIs = {} local reuseZoneNames = {} local reuseExistingNames = {} @@ -352,629 +355,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 @@ -986,62 +402,25 @@ 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) --- PIN HELPERS - local function GetMapPinKey(data) if data.isZone and data.zoneMapID then return "zone:" .. data.zoneMapID @@ -1057,7 +436,6 @@ local function CleanForStorage(data) clean[k] = v end end - -- score and pin (frame ref) intentionally excluded return clean end @@ -1073,6 +451,11 @@ local function PinMapItem(data) if IsMapItemPinned(data) then return end local clean = CleanForStorage(data) clean.isPinned = true + -- 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 tinsert(EasyFind.db.pinnedMapItems, clean) end @@ -1087,53 +470,22 @@ local function UnpinMapItem(data) end 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 +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", - -- 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 } }, + -- 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 } }, bank = 136453, guildbank = 136453, personalbank = 136453, @@ -1183,7 +535,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 +543,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", @@ -1203,24 +556,10 @@ local function GetCategoryIcon(category) return CATEGORY_ICONS[category] or CATEGORY_ICONS.unknown end --- 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 +ns.MapSearch = ns.MapSearch or MapSearch +ns.MapSearch.GetCategoryIcon = GetCategoryIcon --- 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"} }, @@ -1260,54 +599,50 @@ 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. --- Everything else (services, travel, etc.) is excluded to keep global results clean. +local function GetFilterBucket(data) + if not data then return "other" end + -- 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 + 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 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" +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 + 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() - self:CreateSearchFrame() - self:CreateResultsFrame() 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) + self:BuildWorldZoneCache() 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, onChanged) local ROW_HEIGHT = 20 local DROPDOWN_WIDTH = 207 local PADDING_TOP = 8 @@ -1329,7 +664,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:") @@ -1346,7 +680,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() @@ -1357,32 +690,26 @@ 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) local filters = EasyFind.db[dbKey] filters[opt.key] = self:GetChecked() - if searchEditBox:GetText() ~= "" then - MapSearch:OnSearchTextChanged(searchEditBox:GetText()) - end + if onChanged then onChanged(opt.key, self:GetChecked()) end end) checkRows[opt.key] = row @@ -1402,1469 +729,60 @@ function MapSearch:CreateFilterDropdown(globalName, options, dbKey, toggleBtn, a 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 - - 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: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 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 searchIcon = localModeBtn:CreateTexture(nil, "OVERLAY") - searchIcon:SetSize(iconSz, iconSz) - searchIcon:SetPoint("CENTER") - 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 Standard mode.", 1, 1, 1, true) - else - GameTooltip:SetText("Standard 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() - - local editBox = CreateFrame("EditBox", "EasyFindMapSearchBox", searchFrame) - editBox:SetHeight(contentSz) - editBox:SetPoint("LEFT", localModeBtn, "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") - 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 (Standard)") - 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) - GameTooltip:Show() - end) - editBox:HookScript("OnLeave", function() - GameTooltip:Hide() - end) - editBox:HookScript("OnEditFocusGained", function() - GameTooltip:Hide() - end) - - editBox:HookScript("OnTextChanged", function(self) - clearBtn:SetShown(self:GetText() ~= "") - 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) - 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: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 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 globalSearchIcon = globalModeBtn:CreateTexture(nil, "OVERLAY") - globalSearchIcon:SetSize(iconSz, iconSz) - globalSearchIcon:SetPoint("CENTER") - 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 Standard mode.", 1, 1, 1, true) - else - GameTooltip:SetText("Standard 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() - - local globalEditBox = CreateFrame("EditBox", "EasyFindMapGlobalSearchBox", globalSearchFrame) - globalEditBox:SetHeight(contentSz) - globalEditBox:SetPoint("LEFT", globalModeBtn, "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") - 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 (Standard)") - 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) - GameTooltip:Show() - end) - globalEditBox:HookScript("OnLeave", function() - GameTooltip:Hide() - end) - globalEditBox:HookScript("OnEditFocusGained", function() - GameTooltip:Hide() - end) - - globalEditBox:HookScript("OnTextChanged", function(self) - globalClearBtn:SetShown(self:GetText() ~= "") - 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 - - -- Set initial active frame - activeSearchFrame = searchFrame -end - -function MapSearch:CreateResultsFrame() - resultsFrame = CreateFrame("Frame", "EasyFindMapResultsFrame", WorldMapFrame, "BackdropTemplate") - resultsFrame:SetWidth(300) - resultsFrame:SetFrameStrata("TOOLTIP") - resultsFrame:SetFrameLevel(1001) - - 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: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 - - local scrollChild = CreateFrame("Frame", nil, scrollFrame) - scrollFrame:SetScrollChild(scrollChild) - resultsFrame.scrollChild = scrollChild - - -- Minimal retail-style scrollbar (overlays right edge, no content squish) - resultsFrame.scrollBar = ns.Utils.CreateMinimalScrollBar(scrollFrame, resultsFrame) - - for i = 1, MAX_RESULTS_POOL do - local resultRow = self:CreateResultButton(i) - resultButtons[i] = resultRow - 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 - else - MapSearch:ShowWaypointAt(coords.x, coords.y, coords.icon, coords.category) - end - activePinState = MapSearch._savedPinState - end - 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 - GameTooltip:Show() - 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 - end + self:SetSelectedRow(self.keyboardOpen and 1 or 0) + self.keyboardOpen = nil 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) + + dropdown:SetScript("OnHide", function(self) + self:SetSelectedRow(0) + if self.restoreToolbar then + self.restoreToolbar() + self.restoreToolbar = nil + end 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) + + Utils.SafeOnUpdate(dropdown, 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) + 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 not MapSearch._previewing 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 --- 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) @@ -2875,7 +793,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) @@ -2899,6 +816,7 @@ function MapSearch:CreateHighlightFrame() highlightFrame:SetSize(64, 64) highlightFrame:SetFrameStrata("TOOLTIP") highlightFrame:SetFrameLevel(2000) + highlightFrame:EnableMouse(false) highlightFrame:Hide() local top = highlightFrame:CreateTexture(nil, "OVERLAY") @@ -2917,10 +835,12 @@ 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) + -- Anchored explicitly each show; reparenting caused stale Hidden state. + 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) local animGroup = highlightFrame:CreateAnimationGroup() @@ -2931,7 +851,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") @@ -2943,15 +862,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 @@ -2975,7 +891,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 @@ -2985,7 +900,7 @@ function MapSearch:CreateHighlightFrame() SetUserWaypoint(UiMapPoint.CreateFromCoordinates(viewingMapID, x, y)) C_SuperTrack.SetSuperTrackedUserWaypoint(true) efPlacedWaypoint = true - ShowSuperTrackGlow() + MapSearch:RefreshAllClearButtons() end end if button == "RightButton" then @@ -2997,7 +912,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") @@ -3006,7 +920,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") @@ -3018,26 +931,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) + -- 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") @@ -3046,11 +958,11 @@ 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") zoneInd:SetFrameLevel(500) + zoneInd:EnableMouse(false) ns.CreateIndicatorTextures(zoneInd) local zoneIndAnimGroup = zoneInd:CreateAnimationGroup() @@ -3069,20 +981,17 @@ 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:GetMapID() + mapID = mapID or (WorldMapFrame and WorldMapFrame.GetMapID and WorldMapFrame:GetMapID()) if not mapID then return {} end 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 @@ -3100,11 +1009,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) @@ -3124,7 +1032,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 {} @@ -3188,215 +1095,270 @@ 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 = { +local ZONE_PARENT_OVERRIDES = MapUtils.PARENT_OVERRIDES or { + [2346] = 2274, -- Undermine → Khaz Algar (API incorrectly says The Ringing Deeps 2214) } --- 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", +-- 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 = "" }, + 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 + +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 + + 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 + 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 + -- 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) + zone.nameLower = nameLower + 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 + -- 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 tinsert(matches, zone) end end - -- Sort by score, then by name - 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) + else + cache.lastQuery = query + end 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 -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) - end +-- Groups only when multiple results share the EXACT SAME parent path. +local zoneGroupPool = {} +local zoneGroupPoolN = 0 +local zoneGroupByKey = {} +local zoneGroupResults = {} +local zonePathParts = {} - 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 "" - end +local function ZoneNameLess(a, b) + return a.name < b.name +end - -- First pass: build path display and parent mapIDs - local pathDisplay = {} - local pathParentMapID = {} +local function ZoneGroupLess(a, b) + return (a.parentPath or "") < (b.parentPath or "") +end - 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 - 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 - -- 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 +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 - -- 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 +function MapSearch:GroupZonesByParent(zones) + zoneGroupPoolN = 0 + wipe(zoneGroupByKey) + wipe(zoneGroupResults) + + 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 + + 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 + + tsort(zoneGroupResults, ZoneGroupLess) + 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 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 --- 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) @@ -3412,10 +1374,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) @@ -3446,32 +1406,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 @@ -3496,11 +1451,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 @@ -3531,7 +1484,130 @@ local function FindSurroundingZone(parentMapID, mapID, left, right, top, bottom, if bestID then return zones[bestID] end end --- Highlight a zone on the continent map using the actual zone shape texture +-- 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 + 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 + + -- Direct-child only: ancestors/siblings/descendants produce glitchy rects. + 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 + + 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 + + -- 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 + + 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 + -- 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() + local centerX = (left + right) / 2 + local centerY = (top + bottom) / 2 + local zoneCenterPxX = centerX * canvasWidth + local zoneCenterPxY = centerY * canvasHeight + + -- Hide pooled textures so prior-hover residue doesn't bleed through. + 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 + + -- 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 = + 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 + -- Fall back to a translucent rect (same path bugged zones use). + 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 + function MapSearch:HighlightZone(mapID) DebugPrint("[EasyFind] HighlightZone called for mapID:", mapID) @@ -3540,14 +1616,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) @@ -3567,7 +1641,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 @@ -3583,17 +1656,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 @@ -3628,7 +1698,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 = @@ -3642,8 +1711,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 @@ -3651,11 +1719,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 @@ -3665,7 +1731,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 @@ -3730,9 +1795,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 @@ -3755,14 +1819,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 @@ -3792,7 +1852,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") @@ -3856,10 +1915,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 @@ -3869,14 +1926,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" @@ -3896,7 +1952,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) @@ -3923,7 +1978,9 @@ function MapSearch:HighlightZone(mapID) return true end -function MapSearch:ClearZoneHighlight() +-- 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 for _, highlight in ipairs(zoneHighlightFrame.highlights) do @@ -3962,7 +2019,8 @@ function MapSearch:ClearZoneHighlight() zoneHighlightFrame:Hide() - -- Also clear breadcrumb highlight + if preserveBreadcrumb then return end + if self.breadcrumbHighlight then if self.breadcrumbHighlight.indicatorFrame then self.breadcrumbHighlight.indicatorFrame:Hide() @@ -3973,15 +2031,13 @@ function MapSearch:ClearZoneHighlight() 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) @@ -3993,7 +2049,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) @@ -4018,10 +2074,8 @@ 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. - local currentParentChain = self:GetMapPath(currentMapID) + -- Ancestor target: highlight breadcrumb directly, else DCA overshoots. + 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") @@ -4041,26 +2095,21 @@ 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 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! + -- 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) @@ -4073,9 +2122,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 @@ -4086,8 +2133,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) @@ -4106,8 +2152,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) @@ -4115,9 +2160,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 @@ -4136,9 +2180,8 @@ 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) + local targetParentPath = GetMapPath(targetParentMapID) + local currentPath = GetMapPath(currentMapID) DebugPrint("[EasyFind] Target parent path:") for i, p in ipairs(targetParentPath) do @@ -4149,7 +2192,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 @@ -4195,10 +2237,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 @@ -4214,32 +2254,11 @@ 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) - 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 -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) @@ -4248,7 +2267,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) @@ -4257,10 +2276,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 @@ -4282,12 +2299,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 + local currentPath = GetMapPath(currentMapID) + for i = 1, #currentPath - 1 do local breadcrumbBtn = self:FindBreadcrumbButton(navBar, currentPath[i].mapID) if breadcrumbBtn and breadcrumbBtn:IsShown() then buttonToHighlight = breadcrumbBtn @@ -4300,7 +2315,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) @@ -4308,38 +2323,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 @@ -4350,10 +2357,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") @@ -4364,7 +2369,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 @@ -4376,7 +2380,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] @@ -4386,7 +2389,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 @@ -4405,7 +2407,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) @@ -4424,7 +2425,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 @@ -4436,8 +2437,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 @@ -4451,8 +2450,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) @@ -4480,7 +2478,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() @@ -4498,7 +2495,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 @@ -4533,11 +2529,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 @@ -4563,7 +2556,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 @@ -4571,110 +2563,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 --- 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() - 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. + -- Only restore pins when the player is still in the pinned zone. if activePinState then local currentMapID = WorldMapFrame:GetMapID() local playerMapID = GetBestMapForUnit("player") @@ -4691,7 +2585,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() @@ -4700,63 +2593,20 @@ 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. - -- activePinState is preserved so they restore on reopen. + -- activePinState is preserved so visuals restore on reopen. self:ClearHighlight() self:ClearZoneHighlight() self.pendingWaypoint = nil end) - -- Reposition search bars when map toggles between maximized and windowed 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() @@ -4765,32 +2615,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: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 - -- 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() @@ -4799,8 +2635,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) @@ -4824,15 +2659,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 @@ -4843,52 +2675,39 @@ 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 search results when rares spawn or despawn nearby local vignetteFrame = CreateFrame("Frame") vignetteFrame:RegisterEvent("VIGNETTES_UPDATED") vignetteFrame:SetScript("OnEvent", function() - 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 + -- Don't wipe a hover preview. + if MapSearch._previewing then return end if EasyFind.db.alwaysShowRares then MapSearch:UpdateRareTracking() end @@ -4923,14 +2742,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) @@ -4940,11 +2756,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 @@ -4962,9 +2776,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 @@ -4980,20 +2793,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 @@ -5037,13 +2836,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 @@ -5054,14 +2852,23 @@ 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) if entIsRaid then cat = "raid" end end - tinsert(results, { + 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 + local entry = { name = entrance.name, category = cat, icon = nil, -- use category icon @@ -5071,8 +2878,9 @@ function MapSearch:ScanDungeonEntrances(mapID) x = ex, y = ey, pathPrefix = parentLabel, - keywords = {cat, "instance", "entrance"}, - }) + keywords = kw, + } + tinsert(results, PreparePOI(entry)) end -- Pass 1: scan the zone directly; exclude entrances owned by a different zone @@ -5101,7 +2909,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 @@ -5117,9 +2925,103 @@ 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 + +-- 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 = {} +-- Promoted zone-style POIs with breadcrumb paths, so BuildResults doesn't +-- allocate ~300 tables per keystroke. +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") + invalidator:RegisterEvent("ZONE_CHANGED_NEW_AREA") + 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) + -- 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 + SafeAfter(0.2, function() + if ns.MapSearch.BuildWorldZoneCache then ns.MapSearch:BuildWorldZoneCache() end + end) + end) +end + +-- 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 + wipe(staticLocationCache) + 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 + +-- 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() + local cache = localScanCache + 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(mapID) + local dungeonEntrances = self:ScanDungeonEntrances(mapID) + local flightMasters = includeFlightMasters and self:ScanFlightMasters(mapID) or emptyFlightMasters + local vignetteRares = self:ScanVignettes(mapID) + localScanCache = { + mapID = mapID, + includeFlightMasters = includeFlightMasters, + dynamicPOIs = dynamicPOIs, dungeonEntrances = dungeonEntrances, + flightMasters = flightMasters, vignetteRares = vignetteRares, + } + return dynamicPOIs, dungeonEntrances, flightMasters, vignetteRares +end function MapSearch:GetGlobalInstanceCache() if globalInstanceCache then return globalInstanceCache end @@ -5128,17 +3030,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 @@ -5152,7 +3050,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 @@ -5167,7 +3064,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, @@ -5178,7 +3075,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 @@ -5186,7 +3084,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) @@ -5199,7 +3096,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, @@ -5210,7 +3107,8 @@ function MapSearch:GetGlobalInstanceCache() y = loc.y, pathPrefix = mapName, keywords = loc.keywords, - }) + } + tinsert(globalInstanceCache, PreparePOI(entry)) end end end @@ -5225,8 +3123,61 @@ function MapSearch:GetGlobalInstanceCache() return globalInstanceCache end --- Scan flight masters for the given map using the TaxiMap API --- Returns POI-style entries with name, position, and flightmaster category +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 + function MapSearch:ScanFlightMasters(mapID) mapID = mapID or WorldMapFrame:GetMapID() if not mapID then return {} end @@ -5239,14 +3190,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 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") @@ -5257,21 +3210,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 + local entry = { + name = node.name .. " (Flight Master)", + category = "flightmaster", + icon = "atlas:TaxiNode_Neutral", + isStatic = true, + x = x, + y = y, + parentMapID = nodeParentMapID, + -- 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"}, + } + tinsert(results, PreparePOI(entry)) end end end @@ -5280,10 +3246,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 -local cachedAllFlightMasters = nil - function MapSearch:ScanAllFlightMasters() if cachedAllFlightMasters then return cachedAllFlightMasters end if not C_TaxiMap or not C_TaxiMap.GetTaxiNodesForMap then return {} end @@ -5299,13 +3261,14 @@ 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: 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) @@ -5331,65 +3294,72 @@ 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() - local mapID = WorldMapFrame:GetMapID() - if not mapID then return {} end +function MapSearch:GetStaticLocations(mapID) + 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 = {} - -- Get built-in static locations 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 + icon = loc.icon, isStatic = true, + mapID = mapID, + coordMapID = 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 + -- Dev POIs (recorder) skipped when their name already exists in static. + 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 + icon = nil, isStatic = true, + mapID = mapID, + coordMapID = 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 -function MapSearch:ScanVignettes() +function MapSearch:ScanVignettes(mapID) local rares = {} - if not GetVignettes then return rares end + if not GetVignettes or not GetVignettePosition then return rares end - local mapID = WorldMapFrame:GetMapID() + 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 @@ -5398,13 +3368,11 @@ function MapSearch:ScanVignettes() 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) + -- 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 not pos and playerMapID and playerMapID ~= mapID then - pos = GetVignettePosition(guid, playerMapID) - end if pos then - rares[#rares + 1] = { + local entry = { name = info.name, category = "rare", icon = CATEGORY_ICONS.rare, @@ -5413,25 +3381,12 @@ function MapSearch:ScanVignettes() vignetteGUID = guid, keywords = {"rare", "rares"}, } + rares[#rares + 1] = PreparePOI(entry) end end 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 - rares[#rares + 1] = { - name = "Rares", - category = "rare", - icon = CATEGORY_ICONS.rare, - keywords = {"rare", "rares"}, - isAggregate = true, - allInstances = instances, - } - return rares end @@ -5441,28 +3396,23 @@ 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 - -- 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 @@ -5473,8 +3423,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 @@ -5484,7 +3433,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 @@ -5499,33 +3447,33 @@ 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 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 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" @@ -5553,31 +3501,30 @@ function MapSearch:ScanMapPOIs() category = "chromie" end - -- 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 + 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, - }) + } + tinsert(pois, PreparePOI(entry)) end end end end - -- Delve entrances via dedicated API (not returned by GetAreaPOIForMap) if GetDelvesForMap then local delveIDs = GetDelvesForMap(mapID) if delveIDs then 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, @@ -5586,13 +3533,13 @@ function MapSearch:ScanMapPOIs() x = dInfo.position.x, y = dInfo.position.y, keywords = {"delve", "delves", "instance"}, - }) + } + tinsert(pois, PreparePOI(entry)) end end 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 @@ -5603,14 +3550,83 @@ function MapSearch:ScanMapPOIs() end end - return pois + -- 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 + 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 PreparePOIList(deduped) +end + +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 +3684,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 +3729,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 +3756,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 @@ -5794,35 +3804,18 @@ function MapSearch:GetPinInfo(pin) } end -function MapSearch:OnSearchTextChanged(text) - 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() +-- 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 @@ -5838,9 +3831,11 @@ function MapSearch:OnSearchTextChanged(text) 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, @@ -5848,8 +3843,9 @@ function MapSearch:OnSearchTextChanged(text) zoneMapType = zone.mapType, zoneParentMapID = zone.parentMapID, pathPrefix = parentPath, - score = zone.score + 200, - }) + score = zone.score, + } + allPOIs[#allPOIs + 1] = entry end end @@ -5859,31 +3855,16 @@ 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 - -- (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 @@ -5894,10 +3875,10 @@ function MapSearch:OnSearchTextChanged(text) 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 @@ -5910,52 +3891,50 @@ function MapSearch:OnSearchTextChanged(text) 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 + zoneNames[GetNameLower(entrance)] = true end 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 - local fullPath = pathForMap[poi.entranceMapID] - tinsert(allPOIs, { - 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, - }) + 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 + -- 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, #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 + allPOIs[#allPOIs + 1] = p + end + end + + 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 -- 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 @@ -5965,14 +3944,15 @@ function MapSearch:OnSearchTextChanged(text) 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 @@ -5980,17 +3960,19 @@ function MapSearch:OnSearchTextChanged(text) 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) @@ -5999,25 +3981,36 @@ function MapSearch:OnSearchTextChanged(text) -- 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 + + -- 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. end 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 @@ -6062,8 +4055,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) @@ -6097,10 +4091,75 @@ function MapSearch:OnSearchTextChanged(text) results = pinned end - self:ShowResults(results) + isGlobalSearch = savedGlobalFlag + 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 }, +} +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 + +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) +function MapSearch:SearchPOIs(pois, query, noCache) query = slower(query) wipe(reuseSearchResults) wipe(reuseSearchSeen) @@ -6110,11 +4169,49 @@ 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 cache + local candidates = pois + local candidatesAreCached = false + 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 + -- 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 + local nameLower = GetNameLower(poi) local key = poi.name .. (poi.category or "") .. (poi.isZone and poi.pathPrefix or "") @@ -6126,19 +4223,10 @@ 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 - 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 + if not poi.kwLower then PreparePOI(poi) end + -- 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 @@ -6176,10 +4264,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 @@ -6210,441 +4300,26 @@ 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) - return results -end - -function MapSearch:ShowResults(results) - 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() - 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) - 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 + tsort(results, POIResultLess) -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 + if noCache then return results 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() + 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.lastQuery = query + cache.lastCategory = matchedCategory + + return results end -- Shared logic for navigating to an instance entrance. @@ -6672,22 +4347,15 @@ function MapSearch:NavigateToEntrance(name, x, y, icon, category, targetMapID, d end end -function MapSearch:SelectResult(data) - -- Clear preview state so OnLeave doesn't undo the real selection +-- 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) self._previewing = nil self._savedPinState = nil self._suppressTextChanged = true - searchFrame.editBox:SetText("") - searchFrame.editBox:ClearFocus() - searchFrame.editBox.placeholder:Show() - if globalSearchFrame then - self._suppressTextChanged = true - globalSearchFrame.editBox:SetText("") - globalSearchFrame.editBox:ClearFocus() - globalSearchFrame.editBox.placeholder:Show() - end - self:HideResults() - + pinHoverClearsOverride = true if data then DebugPrint("[EasyFind] SelectResult: name=", data.name, "isZone=", data.isZone, "zoneMapID=", data.zoneMapID, @@ -6705,8 +4373,14 @@ function MapSearch:SelectResult(data) return end - local directMode = isGlobalSearch and (EasyFind.db.globalMapDirectOpen or false) - or (EasyFind.db.localMapDirectOpen or false) + local directMode + if directOverride ~= nil then + directMode = directOverride + elseif 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 @@ -6717,13 +4391,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) @@ -6751,6 +4430,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,20 +4444,37 @@ 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 - -- Clear search text after pins are placed. Flag persists until - -- OnSearchTextChanged consumes it (OnTextChanged may fire next frame). - self._suppressTextChanged = true - if isGlobalSearch and globalSearchFrame then - globalSearchFrame.editBox:SetText("") - globalSearchFrame.editBox.placeholder:Show() - else - searchFrame.editBox:SetText("") - searchFrame.editBox.placeholder:Show() + -- 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() + 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 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 + SetUserWaypoint(UiMapPoint.CreateFromCoordinates(autoMapID, autoX, autoY)) + C_SuperTrack.SetSuperTrackedUserWaypoint(true) + efPlacedWaypoint = true + MapSearch:RefreshAllClearButtons() + end + end end end @@ -6813,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 @@ -6875,7 +4576,6 @@ function MapSearch:ShowMultipleWaypoints(instances) SetUserWaypoint(UiMapPoint.CreateFromCoordinates(viewingMapID, x, y)) C_SuperTrack.SetSuperTrackedUserWaypoint(true) efPlacedWaypoint = true - ShowSuperTrackGlow() end end if button == "RightButton" then @@ -6959,13 +4659,17 @@ 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 iconTexture = instance.icon end - SetIconTexture(pin.icon, iconTexture) + Utils.SetIconTexture(pin.icon, iconTexture) if pin.glow then pin.glow:SetSize(glowSize, glowSize) @@ -7016,14 +4720,25 @@ function MapSearch:ShowMultipleWaypoints(instances) end end - -- Auto-track on minimap if requested by navigate button + -- 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 if requested by navigate button if self.autoTrackNextPin then self.autoTrackNextPin = nil self:TrackActivePin() 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() @@ -7033,6 +4748,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 @@ -7048,6 +4764,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) @@ -7057,13 +4798,20 @@ function MapSearch:ShowWaypointAt(x, y, icon, category) 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 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) + end -- Resize and position highlight highlightFrame:SetSize(highlightSize, highlightSize) @@ -7073,35 +4821,89 @@ 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 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() end + + -- Refresh the UI search bar's clear button so it appears while a + -- pin is visible (active map navigation). + self:RefreshAllClearButtons() +end + +-- 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) +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 @@ -7129,18 +4931,139 @@ function MapSearch:HighlightPin(pin) 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 + 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 + 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 + + -- 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 + -- 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() + 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 + + -- Restore the native pin's original scale before tearing down highlight visuals. + RestoreNativePinScale() + highlightFrame:Hide() highlightFrame.top:Show() highlightFrame.bottom:Show() 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 + -- 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). + self:RefreshAllClearButtons() waypointPin.waypointX = nil waypointPin.waypointY = nil waypointPin.isLocalSearch = nil @@ -7158,6 +5081,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 @@ -7183,12 +5107,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 @@ -7202,11 +5141,16 @@ function MapSearch:GetPreviewCoords(data) px, py = data.x, data.y pIcon = data.icon pCat = data.category - pMapID = data.entranceMapID - end - -- Coords on the current map: use directly + -- 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 + -- 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" @@ -7216,23 +5160,77 @@ 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 --- 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, +-- 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 + +function MapSearch:RefreshAllClearButtons() + local frames = { + _G["EasyFindSearchFrame"], + } + for _, f in ipairs(frames) do + if f and f.UpdateClearButtonVisibility then + f.UpdateClearButtonVisibility() + end + end +end + function MapSearch:ClearAll() activePinState = nil self:ClearHighlight() -- Only clear Blizzard waypoint if EasyFind placed it if efPlacedWaypoint then efPlacedWaypoint = false - HideSuperTrackGlow() C_SuperTrack.SetSuperTrackedUserWaypoint(false) if HasUserWaypoint() then ClearUserWaypoint() end end + -- Notify the UI search bar to refresh its clear-button state. + self:RefreshAllClearButtons() end function MapSearch:GetNearestInstance(instances, mapID) @@ -7254,8 +5252,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 @@ -7276,82 +5272,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() @@ -7393,172 +5313,7 @@ function MapSearch:UpdatePinHighlight() end 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 - 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 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 - 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 - end -end - -function MapSearch:ResetPosition() - 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() - -- 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 @@ -7669,142 +5424,157 @@ 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 + -- the WorldMapFrame happens to be viewing. + 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 + -- how the MapTab surfaces both "This Zone" and "Across the World" + -- content without asking the user to pick. + wipe(reuseUISearchPOIs) + wipe(reuseUISearchExistingNames) + local pois = reuseUISearchPOIs + local existingNames = reuseUISearchExistingNames - local isLocal = EasyFind.db.uiMapSearchLocal ~= false - local searchMapID = 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() + do + local dynamicPOIs, dungeonEntrances, flightMasters, vignetteRares = GetLocalScans(self, searchMapID) + local staticLocations = self:GetStaticLocations(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 - 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) 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 + -- 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, + } + end end end - local instancePOIs = self:GetGlobalInstanceCache() + -- 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() - -- 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 - -- 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 - local fullPath = pathForMap[poi.entranceMapID] - pois[#pois + 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, - } + 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 @@ -7812,27 +5582,22 @@ 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 global search filters (zones / dungeons / raids / delves) - -- so UI results mirror what the real global search bar shows - if not isLocal then - local gFilters = EasyFind.db.globalSearchFilters - if gFilters then - local filtered = {} + -- 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 mtFilters = EasyFind.db.mapTabFilters + if mtFilters then + wipe(reuseUISearchFiltered) + local filtered = reuseUISearchFiltered 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 @@ -7841,23 +5606,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, - x = r.x or r.entranceX, - y = r.y or r.entranceY, - keywords = r.keywords, - } - -- 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 @@ -7873,7 +5651,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 @@ -7881,58 +5668,69 @@ 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). -- 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 - if EasyFind.db.directOpen then - if isGlobalResult then - -- Fast mode (global): open map directly at the target + -- 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 (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 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) + elseif data.zoneMapID then + WorldMapFrame:SetMapID(data.zoneMapID) + end + 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 + -- 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 + activePinState = { + mapID = data.mapID, + x = x, y = y, + icon = data.icon, category = data.category, + isLocal = true, + } + MapSearch:RefreshAllClearButtons() 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, - } + 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 @@ -7946,6 +5744,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 diff --git a/MapTab.lua b/MapTab.lua new file mode 100644 index 0000000..c2ced57 --- /dev/null +++ b/MapTab.lua @@ -0,0 +1,2491 @@ +local _, ns = ... + +local MapTab = {} +ns.MapTab = MapTab + +local Utils = ns.Utils +local MapUtils = ns.MapUtils +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} + +local CreateFrame = CreateFrame +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} +local TAB_ICON_DIM = {0.55, 0.45, 0.10} +local TAB_STACK_GAP = -3 + +local FormatPathPrefix = MapUtils.FormatPathPrefix +local GetTopAncestor = MapUtils.GetTopAncestor +local GetZoneUnderAncestor = MapUtils.GetZoneUnderAncestor +local GetAncestorNames = MapUtils.GetAncestorNames +local ExpandZoneAbbrev = MapUtils.ExpandZoneAbbrev + +local ROW_HEIGHT = 22 +local ROW_ICON_SIZE = 17 +local SECTION_HEADER_H = 22 +local MAX_ROW_POOL = 300 +local ROW_POOL_RETAIN = 80 +local HEADER_POOL_RETAIN = 40 +local SECTION_POOL_RETAIN = 12 + + +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 + -- 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 + 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 + table.sort(result, NameLess) + 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 declared: closures below capture this before RunSearch defines it. +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 +function MapTab:IsEnabled() + return IsMapSearchEnabled() +end +-- 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 +-- 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 +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() + Utils.SafeOnUpdate(pendingSearchFrame, 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 +-- 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 = "" +local lastRenderedQuery +local navRowIndex = 0 +local visibleNavRows = {} +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 + +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" + and region:GetAtlas() == atlas then + return region + end + end + return nil +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 + if selectedIsOurs then + setGlow(qmf.QuestsTab, false) + setGlow(qmf.MapLegendTab, false) + setGlow(qmf.EventsTab, false) + setGlow(tabFrame, true) + 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 ShowOurPanel() + if not IsMapSearchEnabled() then return end + local qmf = _G["QuestMapFrame"] + if not qmf or not panel then return end + selectedIsOurs = true + lastSelectedWasOurs = true + -- Must measure before hiding QuestsFrame so GetLeft/GetRight are live. + if panel.MeasureBlizzardSearch then panel.MeasureBlizzardSearch() end + -- 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 + 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 + + -- 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 + 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 + +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 + -- 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 + if restore then + restoringBlizzardDisplayMode = true + pcall(qmf.SetDisplayMode, qmf, restore) + restoringBlizzardDisplayMode = false + end + end + prevBlizzardDisplayMode = nil + 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 + if ReleaseMapTabMemory then ReleaseMapTabMemory(true) end + RefreshSelectGlows() +end + +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, + }) +end + +local function CreateResultRow(parent) + local row = CreateFrame("Button", nil, parent) + row:SetHeight(ROW_HEIGHT) + -- 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() + 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 + + 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) + 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", deleteBtn, "LEFT", -4, 0) + text:SetJustifyH("LEFT") + text:SetWordWrap(false) + row.text = text + + -- 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 + hl:SetBlendMode("ADD") + hl:SetAllPoints(row) + end + + return row +end + +local function SetRowIcon(row, data) + -- 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 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 + +-- 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 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 + if MapSearch and MapSearch.RunHoverPreview then + MapSearch:RunHoverPreview(data) + end +end + +local function ClearHoverPreview() + local MapSearch = ns.MapSearch + if MapSearch and MapSearch.EndHoverPreview then + MapSearch:EndHoverPreview() + end +end + +RefreshCurrentSearch = function() + if not panel or not panel.searchBox then return end + local sb = panel.searchBox + MapTab:RunSearch(sb.GetTypedText and sb:GetTypedText() or sb:GetText() or "") +end + +-- 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 + -- 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) + local data = row.data + if not data then return end + local MapSearch = ns.MapSearch + + if data.isRecentSearch then + if data.query and panel and panel.searchBox then + panel.searchBox:SetText(data.query) + panel.searchBox:ClearFocus() + end + return + end + + if button == "RightButton" then + local isPinned = MapSearch and MapSearch:IsMapItemPinned(data) + ShowPopup(isPinned, function() + if isPinned then MapSearch:UnpinMapItem(data) else MapSearch:PinMapItem(data) end + RefreshCurrentSearch() + end, function() TriggerResultSelect(data, false) end, function() PromptAlias(data) end) + return + end + + MapTab:PushRecentSearch(currentQuery) + TriggerResultSelect(data) +end + +local function RowOnEnter(row) + if row.data then + if row.data.isRecentSearch and row.deleteBtn then + row.deleteBtn:Show() + else + HoverPreview(row.data) + end + end +end +local function RowOnLeave(row) + if row and row.deleteBtn and not row.deleteBtn:IsMouseOver() then + row.deleteBtn:Hide() + end + ClearHoverPreview() + GameTooltip:Hide() +end + +local function AcquireRow(parent) + 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 + 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 + +local function CreateSectionLabel(parent) + local hdr = CreateFrame("Frame", nil, parent) + hdr:SetHeight(SECTION_HEADER_H) + local fs = hdr:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + fs:SetPoint("CENTER", hdr, "CENTER", 0, 0) + fs:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) + hdr.label = fs + 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 + +local sectionLabelPool = {} +local sectionLabelPoolCursor = 1 + +local function AcquireSectionLabel(parent, labelText) + 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 + +local GROUP_HEADER_H = 28 + +local function CreateGroupHeader(parent) + local hdr = CreateFrame("Button", nil, parent) + hdr:SetHeight(GROUP_HEADER_H) + -- 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 + 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) + 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() + ClearHoverPreview() + 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) + 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 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 + 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") + 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 + 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) + 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() + 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 CancelPendingSearch() 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 + +-- 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 + return pins +end + +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: 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 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 = { + name = e.name, + category = "zone", + isZone = true, + zoneMapID = e.ancestorMapID, + synthesized = true, + } + end + end + -- 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 + -- 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 = {}, {}, {} + 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 + 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, localEntries, globalEntries, recentList) + ReleaseAllRows() + ReleaseAllHeaders() + -- 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 + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + end + if navKeyRepeat then navKeyRepeat.Stop() end + local y = 4 + local collapsedDb = sessionCollapsed + + 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", 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 "" + 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 + do + local pathText = FormatPathPrefix(data.pathPrefix) or "" + 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 + if pathText ~= "" then + local last = pathText:match("[^>]+$") + if last then pathText = last:gsub("^%s+", ""):gsub("%s+$", "") end + end + if pathText ~= "" then + row.text:SetText(name .. " |cff808080" .. pathText .. "|r") + else + row.text:SetText(name) + end + end + row:Show() + y = y + ROW_HEIGHT + end + + 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) + hdr:Show() + y = y + SECTION_HEADER_H + 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 + + -- 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 + 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, function() PromptAlias(navigateData) end) + end + + local function renderEntries(entries, sectionKey) + for _, e in ipairs(entries) do + if e.type == "flat" then + placeRow(e.data, 0, nil) + elseif e.type == "version" then + -- 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 + 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 + -- 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 + 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 + 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 + -- Group duplicate-named children into nested + -- 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 "" + 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, ZoneMapIDDesc) + 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: mutating worldChildrenCache + -- entries would poison the cache. + 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 + 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) + 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, function() PromptAlias(pinRef) 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 + +-- 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 + EXCLUDE_FROM_LOCAL_MAPTYPES[Enum.UIMapType.World] = true + 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 + +-- 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 + 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 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 + 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 + +-- 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 + 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 + +-- 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 + 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() + local aliasMatches + if ns.Aliases and text ~= "" then + aliasMatches = ns.Aliases:GetMatches(text:lower()) + end + + -- 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 + 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 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 + end + + 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 + 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() + scrollChild:SetHeight(1) + 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 + end + + -- 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 reusable module-level table; the second + -- call wipes it. Shallow-copy 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-first dedup so a POI in the current zone wins local ownership. + local localFiltered = FilterAndDedupe(localRaw, seen, true) + 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-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 + 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 + + -- 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 = {} + 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, ZoneMapIDDesc) + 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 + + -- '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 + local e = globalEntries[i] + if e.type == "group" and e.ancestorMapID == viewedMapID then + tremove(globalEntries, i) + end + end + end + + -- 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 + 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, localEntries, globalEntries) + + if (not pinned or #pinned == 0) + and (not localFiltered or #localFiltered == 0) and #globalFiltered == 0 then + panel.emptyMsg:SetText("|cff999999No matches.|r") + panel.emptyMsg:Show() + end + restoreScroll() +end + +-- 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() + if navFrame then return navFrame end + if not panel then return nil end + navFrame = CreateFrame("Frame", nil, panel) + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + Utils.SafeCallMethod(navFrame, "SetPropagateKeyboardInput", true) + navFrame:SetScript("OnKeyDown", function(self, key) + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", not HandleNavKey(key, false)) + 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 UpdateNavHighlight() + for i = 1, #visibleNavRows do + local f = visibleNavRows[i] + if f then + local selected = (i == navRowIndex) + 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 + 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 + 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 SetNavFrameCapture(on) + local nf = EnsureNavFrame() + if not nf then return end + Utils.SafeCallMethod(nf, "EnableKeyboard", on and true or false) + -- 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 + +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 + return false +end + +-- 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 + 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 == "UP" or (alt and key == "K") then + if #visibleNavRows == 0 then return false end + -- Up from the first row exits back to the search box. + 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 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 + -- 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 + 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() + -- 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 + return true + end + return false + elseif key == "ESCAPE" then + if not keepSearchFocus then + -- First Esc clears selection and refocuses search; second + -- (nothing highlighted) propagates 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 + if panel and panel.searchBox then panel.searchBox:ClearFocus() end + return true + end + end + return false +end + +local function CreateSearchBox(parent) + local editBox = CreateFrame("EditBox", nil, parent, "SearchBoxTemplate") + editBox:SetSize(301, 20) + editBox:SetAutoFocus(false) + editBox:SetMaxLetters(60) + if editBox.Instructions then + editBox.Instructions:SetText("Search for POIs, zones, instances...") + end + + -- 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) + 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() ~= "") + end + end + + -- 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 + + 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 + + Utils.AttachAutocomplete(editBox, { + findCandidate = FindPrefixCandidate, + onTypedChanged = function(self, typed, _, grew) + lastTypeTime = GetTime() + UpdateClear(self) + SchedulePendingSearch(self, typed, grew) + end, + onAccepted = function(text, source) + if text and text ~= "" then MapTab:RunSearch(text) end + if source ~= "right" and source ~= "alt-l" and source ~= "click" then + MapTab:PushRecentSearch(text) + end + end, + }) + editBox:HookScript("OnEditFocusGained", UpdateClear) + editBox:HookScript("OnEditFocusLost", function(self) + UpdateClear(self) + end) + + -- 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 + self:AcceptAutocomplete("right") + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + elseif key == "L" and IsAltKeyDown() then + self:AcceptAutocomplete("alt-l") + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + return + end + end + if pendingSearchTimer + and (key == "DOWN" or key == "UP" + or (IsAltKeyDown() and (key == "J" or key == "K"))) then + CancelPendingSearch() + end + HandleNavKey(key, true) + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + end) + -- 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) + + -- 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 + 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) + if navRowIndex > 0 then return end + local current = self:GetText() or "" + local typed = self.GetTypedText and self:GetTypedText() or current + local accepted = false + 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 + if not accepted then MapTab:PushRecentSearch(current) end + self:ClearFocus() + end) + return editBox +end + +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 + +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" }, +} + +-- 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 + 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) + 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 Results") + GameTooltip:AddLine("Toggle category filters.", 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) + -- 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) + tab:SetFrameStrata("HIGH") + tab:SetFrameLevel(qmf.MapLegendTab:GetFrameLevel()) + tab.displayMode = "EasyFindMapSearch" + tab:EnableMouse(true) + + local bg = tab:CreateTexture(nil, "BACKGROUND") + bg:SetAtlas("QuestLog-tab-side", true) + 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", 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", 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", true) + hoverGlow:SetPoint("CENTER", bg, "CENTER", 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 + +-- Sibling content panel: paper backdrop, gold border, search + cog at +-- top, scrollable content below. +local function CreatePanel(qmf) + local anchor = qmf.ContentsAnchor or qmf.QuestsFrame or qmf + + local outer = CreateFrame("Frame", "EasyFindMapSearchOuter", qmf) + outer:SetAllPoints(anchor) + outer:EnableMouse(false) + + local p = CreateFrame("Frame", "EasyFindMapSearchPanel", outer) + p:SetPoint("TOPLEFT", anchor, "TOPLEFT", 0, -29) + p:SetPoint("BOTTOMRIGHT", anchor, "BOTTOMRIGHT", -22, 0) + p:EnableMouse(true) + p.outer = outer + outer:Hide() + + local paper = p:CreateTexture(nil, "BACKGROUND", nil, -1) + paper:SetAtlas("QuestLog-main-background", true) + paper:SetAllPoints(p) + p.paper = paper + + -- 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) + -- 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 + + local cog = CreateFilterCog(outer) + cog:SetSize(15, 16) + cog:SetPoint("TOPRIGHT", p, "TOPRIGHT", 19, 25) + p.cog = cog + + -- 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) + searchBox:SetPoint("TOPLEFT", outer, "TOPLEFT", 4, -5) + searchBox:SetPoint("RIGHT", cog, "LEFT", -6, 0) + p.searchBox = searchBox + + if ns.MapSearch and ns.MapSearch.CreateFilterDropdown then + local dropdown + dropdown = ns.MapSearch:CreateFilterDropdown( + "EasyFindMapTabFilterDropdown", FILTER_OPTIONS, + "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() + end + 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) + 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 + + -- 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 + 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 + + local scrollFrame = CreateFrame("ScrollFrame", nil, p) + scrollFrame:SetPoint("TOPLEFT", p, "TOPLEFT", 4, -4) + -- 28px bottom inset for the "Show recent searches" checkbox. + scrollFrame:SetPoint("BOTTOMRIGHT", p, "BOTTOMRIGHT", -4, 28) + scrollFrame:EnableMouseWheel(true) + 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()) + 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 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) + emptyMsg:SetJustifyH("CENTER") + emptyMsg:SetText("|cff999999Start typing to search for POIs, zones, dungeons, and raids.|r") + p.emptyMsg = emptyMsg + + 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 + + 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 + +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. + if not WorldMapFrame or not WorldMapFrame:IsShown() then + if ToggleWorldMap then ToggleWorldMap() end + if not initialized then self:Initialize() end + end + -- 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 + local clickHandler = tabFrame:GetScript("OnMouseUp") + if clickHandler then clickHandler(tabFrame, "LeftButton") end + -- 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 + clickHandler(tabFrame, "LeftButton") + end + if panel.searchBox and panel:IsShown() then + 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 + if not initialized then self:Initialize() end + end + -- ADDON_LOADED replays this once the panel exists. + if not panel or not tabFrame then + MapTab._pendingQuery = query + return false + end + -- 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() + 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 + if panel.searchBox:GetText() ~= (query or "") then + panel.searchBox:SetText(query or "") + end + panel.searchBox:ClearFocus() + end + end) + return true +end + +-- 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"] + 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 +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 + end + return + end + 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) + PlaceTab() + + -- 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 + 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 then + if not restoringBlizzardDisplayMode then + lastSelectedWasOurs = false + end + if selectedIsOurs then HideOurPanel() end + end + end) + end + end + + -- 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() + SafeAfter(0, PlaceTab) + SafeAfter(0, function() + if panel and panel.MeasureBlizzardSearch then + panel.MeasureBlizzardSearch() + end + end) + 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 + -- 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 + ShowOurPanel() + if panel.searchBox and panel:IsShown() then + panel.searchBox:SetFocus() + end + end + SafeAfter(0, function() + if panel then + ShowOurPanel() + if panel.searchBox and panel:IsShown() then + panel.searchBox:SetFocus() + end + end + end) + end + 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() + if panel.searchBox and panel:IsShown() then + panel.searchBox:SetText(q or "") + panel.searchBox:ClearFocus() + end + 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) + end + end) + end + + 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() + else + tabFrame:Show() + PlaceTab() + end + end + hooksecurefunc(WorldMapFrame, "Maximize", UpdateTabVisibility) + hooksecurefunc(WorldMapFrame, "Minimize", UpdateTabVisibility) + WorldMapFrame:HookScript("OnShow", UpdateTabVisibility) + UpdateTabVisibility() + end + + -- "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) + + -- Consume any Focus() that came in before init completed. + if MapTab._pendingFocus then + 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 + +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 + 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) 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/Options.lua b/Options.lua index a6797e6..37e4a93 100644 --- a/Options.lua +++ b/Options.lua @@ -4,63 +4,263 @@ 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 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 NIL = {} +local DEFAULT_UI_FILTERS = { + 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, + 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 = false, travel = true, + services = true, rares = true, +} + +local UI_DEFAULTS = { + smartShow = false, + autoHide = true, + lockPosition = false, + uiResultsAbove = false, + showResultShortcutHints = true, + fontSize = 0.9, + uiSearchScale = 1.0, + uiSearchWidth = 1.54, + uiSearchBarHeight = ns.SEARCHBAR_HEIGHT or 30, + 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 FRAME_BACKDROP = { - edgeFile = TOOLTIP_BORDER, - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 }, +local MAP_DEFAULTS = { + iconScale = 0.8, + mapPinHighlight = true, + blinkingPins = false, + autoPinClear = true, + autoTrackPins = true, + globalSearchFilters = DEFAULT_GLOBAL_SEARCH_FILTERS, + localSearchFilters = DEFAULT_LOCAL_SEARCH_FILTERS, + mapTabFilters = DEFAULT_MAP_TAB_FILTERS, + alwaysShowRares = false, } --- 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 GENERAL_DEFAULTS = { + tutorialDone = false, + resultsTheme = "Modern", + font = "Default", + indicatorStyle = "EasyFind Arrow", + indicatorColor = "Yellow", + showLoginMessage = false, + showAliasMessages = true, + showMinimapButton = true, + minimapButtonAngle = 200, + visible = true, + enableMapSearch = true, + nativePinScale = 1.5, + pinnedUIItems = {}, + pinnedUIItemsPerChar = {}, + pinnedMapItems = {}, + optionsPosition = NIL, } --- Helper to create a flyout selector (button + dropdown panel + toggle + click-away) --- Returns: btnFrame, btnText, flyout +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 RunSoon(fn) + Utils.SafeAfter(0, fn) +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.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 +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 SyncOptionControls() + if not optionsFrame then return 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 + optionsFrame.smartShowCheckbox:SetChecked(EasyFind.db.smartShow or false) + local lbl = optionsFrame.smartShowCheckbox:GetFontString() + if lbl then lbl:SetTextColor(1, 1, 1) end + end + if optionsFrame.lockPositionCheckbox then optionsFrame.lockPositionCheckbox:SetChecked(EasyFind.db.lockPosition or 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 + if optionsFrame.rareTrackCheckbox then optionsFrame.rareTrackCheckbox:SetChecked(EasyFind.db.alwaysShowRares or false) 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 + 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.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 + +local PaintRoundedFill = ns.SetRoundedRectFill +local function HideRoundedBorder(frame) + ns.SetRoundedRectBorderEdgeShown(frame, false) +end +local HideRoundedFrameBorder = HideRoundedBorder + +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 + 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 --- 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) - 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) @@ -74,20 +274,35 @@ 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 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) 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() @@ -95,369 +310,450 @@ 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) +-- 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 + 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()))) - end - self:ClearFocus() - end) - - inputBox:SetScript("OnEscapePressed", function(self) - self:SetText(tostring(getDisplayValue(slider:GetValue()))) - self:ClearFocus() - end) - - -- 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) - - -- Set initial value - inputBox:SetText(tostring(getDisplayValue(slider:GetValue()))) - - slider.inputBox = inputBox - - 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 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 - if tooltipText then - slider:SetScript("OnEnter", function(self) + 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) - slider:SetScript("OnLeave", GameTooltip_Hide) - end + end + end) + checkbox:HookScript("OnLeave", function(self) + UpdateVisual(self) + if tooltipText then GameTooltip_Hide() end + end) + checkbox:HookScript("OnEnable", UpdateVisual) + checkbox:HookScript("OnDisable", UpdateVisual) - return slider + UpdateVisual(checkbox) + return checkbox 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 DISABLED_TEXT = { 0.5, 0.5, 0.5 } +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 } + +local function TintRoundedFill(frame, r, g, b) + ns.SetRoundedRectFill(frame, r, g, b, 1, true) +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 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 + 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 + 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 - return checkbox +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 --- 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 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 + 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 - 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 SetModernButtonFill = ns.SetRoundedRectBorderFillColor +local SetModernButtonAlpha = ns.SetRoundedRectBorderBgAlpha - 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], 1) + 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:SetTextColor(NORMAL_TEXT[1], NORMAL_TEXT[2], NORMAL_TEXT[3], 1) + 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 + 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) + 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 + 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) + 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:SetTextColor(NORMAL_TEXT[1], NORMAL_TEXT[2], NORMAL_TEXT[3], 1) + 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) + 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() + else + minusBtn:Disable() + plusBtn:Disable() + end + end + row:SetValue(getter()) + return row +end local function SetControlsEnabled(controls, enabled) for _, ctrl in ipairs(controls) do @@ -468,6 +764,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 @@ -499,17 +796,16 @@ end function Options:Initialize() if isInitialized then return end - local FRAME_W = 380 - local FRAME_H = 380 - 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 + local WINDOW_W = 544 + local WINDOW_H = 408 + local SIDEBAR_W = 132 + local FRAME_W = WINDOW_W - SIDEBAR_W - 46 + local COL_LEFT = 4 - -- Create the main options frame (fixed size) 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]) @@ -528,60 +824,57 @@ 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.022, 0.022, 0.028) + + 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) + + 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) + 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 + 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 @@ -596,98 +889,46 @@ 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 local content = CreateFrame("Frame", nil, contentBorder) - content:SetPoint("TOPLEFT", 6, -6) - content:SetPoint("BOTTOMRIGHT", -6, 6) + content:SetAllPoints(contentBorder) content:Hide() tinsert(tabFrames, content) 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 @@ -720,6 +961,15 @@ function Options:Initialize() StopCapture(self, action) return end + -- 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 + return + end local combo = "" if IsAltKeyDown() then combo = combo .. "ALT-" end if IsControlKeyDown() then combo = combo .. "CTRL-" end @@ -737,23 +987,21 @@ 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 - -- HOME TAB local homeTab = CreateTab("Home") 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) @@ -765,7 +1013,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) @@ -773,12 +1021,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:" ) @@ -805,14 +1049,12 @@ function Options:Initialize() CreateURLBox(homeTab, "https://www.curseforge.com/wow/addons/easyfind", homeDesc, -6) - local sec3 = CreateTab("General") - - -- General tab layout (no inner border, content fills the tab) + local sec3 = CreateTab("General & Binds") 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) @@ -820,7 +1062,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("LEFT", loginMessageCheckbox, "RIGHT", 120, 0) + 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() @@ -828,19 +1079,30 @@ 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:SetTextColor(NORMAL_TEXT[1], NORMAL_TEXT[2], NORMAL_TEXT[3], 1) + 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 @@ -850,56 +1112,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]) + label:SetTextColor(NORMAL_TEXT[1], NORMAL_TEXT[2], NORMAL_TEXT[3], 1) colorBtn:SetScript("OnEnter", function(self) + PaintRoundedFill(rowBg, 1, 1, 1, 0.06) label:SetTextColor(1, 1, 1) end) colorBtn:SetScript("OnLeave", function(self) - label:SetTextColor(rgb[1], rgb[2], rgb[3]) + PaintRoundedFill(rowBg, 1, 1, 1, 0) + label:SetTextColor(NORMAL_TEXT[1], NORMAL_TEXT[2], NORMAL_TEXT[3], 1) 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() @@ -908,180 +1166,209 @@ function Options:Initialize() end optionsFrame.colorBtnText = colorBtnText - optionsFrame.colorSwatch = colorSwatch optionsFrame.colorFlyout = colorFlyout - local themeLabel = sec3:CreateFontString(nil, "OVERLAY", "GameFontNormal") - themeLabel:SetPoint("TOPLEFT", colorLabel, "BOTTOMLEFT", 0, -20) - themeLabel:SetText("Theme:") - - local themeChoices = {"Classic", "Retail"} + local fontRow, fontLabel = CreateSelectorRow(colorRow, "Font") - 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( + fontRow, "EasyFindFont", SELECTOR_BTN_W, 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 - end) - optionsFrame.themeBtnText = themeBtnText - optionsFrame.themeFlyout = themeFlyout - - 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: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 - if ns.MapSearch and ns.MapSearch.UpdateOpacity then - ns.MapSearch:UpdateOpacity() - end + 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 end) - optionsFrame.opacitySlider = opacitySlider + optionsFrame.fontBtnText = fontBtnText + optionsFrame.fontFlyout = fontFlyout - local RESET_BTN_W = 120 + 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) - -- SECTION 2: UI Search - local sec1 = CreateTab("UI") + 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 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 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 uiControls = {} - local function UpdateUIToggleVisual() - local enabled = EasyFind.db.enableUISearch ~= false - uiEnableCheckbox:SetChecked(enabled) - SetControlsEnabled(uiControls, enabled) - end - optionsFrame.UpdateUIToggleVisual = UpdateUIToggleVisual + 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 - 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) + 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) - -- 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 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, 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.") - 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) + 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 - end) - optionsFrame.directOpenCheckbox = directOpenCheckbox - local resizeUIBtn = CreateFrame("Button", nil, sec1, "UIPanelButtonTemplate") + 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 + + local sec1 = CreateTab("Search") + + 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) - - 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: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() - end - end) + 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.") + autoHideCheckbox:SetPoint("TOPLEFT", sec1, "TOPLEFT", 16, -8) + optionsFrame.autoHideCheckbox = autoHideCheckbox + + local smartShowCheckbox = CreateCheckbox(sec1, "SmartShow", "Smart Show", + "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) optionsFrame.smartShowCheckbox = smartShowCheckbox - 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.") - 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 + local function SetVisibilityMode(mode) + local smart = mode == "smart" + EasyFind.db.smartShow = smart + EasyFind.db.autoHide = not smart + autoHideCheckbox:SetChecked(not smart) + smartShowCheckbox:SetChecked(smart) + 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") + end) + smartShowCheckbox:SetScript("OnClick", function() + SetVisibilityMode("smart") end) - optionsFrame.staticOpacityCheckbox = staticOpacityCheckbox + 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 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", smartShowCheckbox, "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() + 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 - 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: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() - end + 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 self.RefreshVisual then self:RefreshVisual() end + RunSoon(function() + if ns.UI and ns.UI.RefreshResults then + ns.UI:RefreshResults() + end + end) end) - optionsFrame.uiFontSlider = uiFontSlider + optionsFrame.resultShortcutHintsCheckbox = resultShortcutHintsCheckbox + + 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", resultShortcutHintsCheckbox, "BOTTOMLEFT", 0, -8) + optionsFrame.uiFontPresetRow = uiFontPresetRow - local resetUIBtn = CreateFrame("Button", nil, sec1, "UIPanelButtonTemplate") + local function RefreshUIPresetRows() + if optionsFrame.uiFontPresetRow then + optionsFrame.uiFontPresetRow:SetValue(EasyFind.db.fontSize or 0.9) + end + end + optionsFrame.RefreshUIPresetRows = RefreshUIPresetRows + + local resetUIBtn = CreateModernButton(sec1) resetUIBtn:SetSize(RESET_BTN_W, 20) resetUIBtn:SetPoint("BOTTOMLEFT", sec1, "BOTTOMLEFT", 8, 8) resetUIBtn:SetText("Reset Settings") @@ -1089,7 +1376,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") @@ -1097,14 +1384,11 @@ function Options:Initialize() StaticPopup_Show("EASYFIND_RESET_UI_POS") end) - uiControls = { resizeUIBtn, resetUIBtn, resetUIPosBtn, uiFontSlider, directOpenCheckbox, smartShowCheckbox, staticOpacityCheckbox, 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, 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) @@ -1113,6 +1397,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 @@ -1127,232 +1412,186 @@ function Options:Initialize() end end) - -- 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 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 + 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") + 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) + 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() + if self.RefreshVisual then self:RefreshVisual() end + UpdateRecentCountEnabled() + RunSoon(function() + if ns.MapTab and ns.MapTab.RefreshIfOpen then ns.MapTab:RefreshIfOpen() end + 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 + 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") + 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) + 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 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 + + 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 self.RefreshVisual then self:RefreshVisual() end + RunSoon(function() + if ns.MapSearch and ns.MapSearch.UpdateBlinkingPins then ns.MapSearch:UpdateBlinkingPins() end + end) end) - optionsFrame.globalNavCheckbox = globalNavCheckbox + optionsFrame.blinkingPinsCheckbox = blinkingPinsCheckbox - 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) + 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") + 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) + 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() - 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 - 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() + 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) - resizeMapBtn:SetScript("OnLeave", GameTooltip_Hide) - - 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 = false, - 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 }, - }, 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) - value = mfloor(value + 0.5) - EasyFind.db.mapSearchYOffset = value - if ns.MapSearch and ns.MapSearch.UpdateYOffset then - ns.MapSearch:UpdateYOffset() - end - end) - optionsFrame.mapYOffsetSlider = mapYOffsetSlider - - 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", searchBarGroup, "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 - end) - optionsFrame.mapIconSlider = mapIconSlider - - local minimapGroup = CreateMultiSelectDropdown(sec2, "Minimap", { - { label = "Arrow Glow", shortLabel = "Arrow", dbKey = "minimapArrowGlow", default = true, - 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, - 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", - 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 + 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.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." }, - { 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." }, - }, nil, FLYOUT_W) - automationGroup:SetPoint("TOPLEFT", minimapGroup, "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 + optionsFrame.autoPinClearCheckbox = autoPinClearCheckbox + + mapPinSettings:AddControl(rareTrackCheckbox) + mapPinSettings:AddControl(autoTrackPinsCheckbox) + mapPinSettings:AddControl(autoPinClearCheckbox) - local resetMapBtn = CreateFrame("Button", nil, sec2, "UIPanelButtonTemplate") + 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) - 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 = { - resizeMapBtn, resetMapBtn, resetMapPosBtn, localNavCheckbox, globalNavCheckbox, rareTrackCheckbox, - searchBarGroup, mapPinGroup, minimapGroup, automationGroup + resetMapBtn, mapTabSettings, mapIconSettings, mapPinSettings } 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) @@ -1360,88 +1599,226 @@ 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 |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 |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 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 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\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 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" }, - } - - 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_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 - 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 rowLabel = sec4:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - if row == 0 then - rowLabel:SetPoint("TOPLEFT", shortcutText, "BOTTOMLEFT", col * COL2_X, -12) + 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 - rowLabel:SetPoint("TOPLEFT", shortcutText, "BOTTOMLEFT", col * COL2_X, -12 - KEYBIND_ROW_H) + self:ClearFocus() end - rowLabel:SetText(def.label .. ":") + end) - local keybindBtn = CreateFrame("Button", nil, sec4, "UIPanelButtonTemplate") - keybindBtn:SetNormalFontObject("GameFontHighlightSmall") - keybindBtn:SetHighlightFontObject("GameFontHighlightSmall") - keybindBtn:SetSize(KEYBIND_BTN_W, 18) - 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") + 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, 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 - 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 - 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", 8, 0) + row.text:SetJustifyH("LEFT") + 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 - else - StartCapture(self, def.action) + RefreshAliasList = function() + ReleaseAliasRows() + 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) + 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) - keybindBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp") - - local tip = keybindTooltips[def.action] - if tip then - MakeKeybindTooltip(keybindBtn, tip[1], tip[2]) + table.sort(entries, function(a, b) return (a.text or ""):lower() < (b.text or ""):lower() end) + 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", 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 - rowH end - - keybindButtons[def.action] = keybindBtn + aliasContent:SetHeight(math.max(32, -y + 4)) + UpdateAliasScrollBar() + C_Timer.After(0, UpdateAliasScrollBar) end - optionsFrame.keybindBtn = keybindButtons["EASYFIND_TOGGLE"] - optionsFrame.focusBtn = keybindButtons["EASYFIND_FOCUS"] - optionsFrame.toggleFocusBtn = keybindButtons["EASYFIND_TOGGLE_FOCUS"] - optionsFrame.clearBtn = keybindButtons["EASYFIND_CLEAR"] - - -- Reset buttons (tips on Home tab) + 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) StaticPopupDialogs["EASYFIND_RESET_ALL"] = { text = "Reset all EasyFind settings to defaults?", @@ -1465,23 +1842,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 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, 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() @@ -1490,9 +1852,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, @@ -1546,35 +1905,23 @@ 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") + 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") @@ -1582,7 +1929,6 @@ function Options:Initialize() StaticPopup_Show("EASYFIND_RESET_POSITIONS") end) - -- FEEDBACK TAB local feedbackTab = CreateTab("Feedback") local feedbackDesc = feedbackTab:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") @@ -1597,52 +1943,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() @@ -1653,182 +1985,22 @@ 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.directOpen = false - EasyFind.db.localMapDirectOpen = false - EasyFind.db.globalMapDirectOpen = false - EasyFind.db.smartShow = false - EasyFind.db.resultsTheme = "Retail" - 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.showMinimapButton = true - EasyFind.db.minimapButtonAngle = 200 - EasyFind.db.arrivalDistance = 10 - EasyFind.db.panelOpacity = 0.9 - EasyFind.db.minimapArrowGlow = true - EasyFind.db.glowOnlyEasyFind = false - EasyFind.db.circleOnlyEasyFind = false - EasyFind.db.minimapGuideCircle = true - EasyFind.db.autoPinClear = true - EasyFind.db.autoTrackPins = true - EasyFind.db.minimapPinGlow = true - EasyFind.db.guideCircleScale = 1.0 - EasyFind.db.mapSmartShow = false - EasyFind.db.hideSearchBarsMaximized = false - 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 } - EasyFind.db.uiSearchFilters = { ui = true, mounts = false, toys = false, pets = false, outfits = false, loot = false, map = false } - EasyFind.db.lootFilter = nil -- nil = current spec - EasyFind.db.lootDifficulty = "normal" - EasyFind.db.pinnedUIItems = {} - EasyFind.db.pinnedUIItemsPerChar = {} - EasyFind.db.pinnedMapItems = {} - EasyFind.db.uiMapSearchLocal = true - EasyFind.db.alwaysShowRares = false - EasyFind.db.optionsPosition = nil - - 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") - if old1 then SetBinding(old1) end - if old2 then SetBinding(old2) end - - old1, old2 = GetBindingKey("EASYFIND_FOCUS") - if old1 then SetBinding(old1) end - if old2 then SetBinding(old2) end - - old1, old2 = GetBindingKey("EASYFIND_TOGGLE_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 - 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.directOpenCheckbox:SetChecked(false) - 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) - 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 - if optionsFrame.UpdateUIToggleVisual then optionsFrame.UpdateUIToggleVisual() end - 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") - 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 - if _G["EasyFindSearchFrame"] and ns.UI and ns.UI.Show then ns.UI:Show() end + local needsReload = EasyFind.db.enableMapSearch == false + ApplyDefaults(UI_DEFAULTS) + ApplyDefaults(MAP_DEFAULTS) + ApplyDefaults(GENERAL_DEFAULTS) + ResetOptionsPosition() + ClearMapRuntime() + + SyncOptionControls() + if ns.RefreshAddonFont then ns.RefreshAddonFont() end + RefreshUIRuntime(true) + RefreshMapRuntime() EasyFind:UpdateMinimapButton() if needsReload then @@ -1837,143 +2009,27 @@ function Options:DoResetAll() end function Options:DoResetUI() - EasyFind.db.directOpen = false - EasyFind.db.smartShow = 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, mounts = false, toys = false, pets = false, outfits = false, loot = false, map = false } - EasyFind.db.uiMapSearchLocal = true - - optionsFrame.directOpenCheckbox:SetChecked(false) - 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 - 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.localMapDirectOpen = false - EasyFind.db.globalMapDirectOpen = false - EasyFind.db.mapSmartShow = false - EasyFind.db.hideSearchBarsMaximized = false - 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 = true - EasyFind.db.glowOnlyEasyFind = false - EasyFind.db.circleOnlyEasyFind = false - EasyFind.db.minimapGuideCircle = true - EasyFind.db.minimapPinGlow = true - 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 - - 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 - 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) - - 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 - 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 - 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" @@ -1982,13 +2038,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) @@ -2013,8 +2068,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 @@ -2039,50 +2092,14 @@ 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) - 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 - 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) - 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 - optionsFrame.themeBtnText:SetText(EasyFind.db.resultsTheme or "Retail") - optionsFrame.indicatorBtnText:SetText(EasyFind.db.indicatorStyle or "EasyFind Arrow") - 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") - 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") + 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 @@ -2093,9 +2110,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") @@ -2134,5 +2151,3 @@ function Options:Toggle() self:Show() end end - --- Options:Initialize() is called from Core.lua OnPlayerLogin diff --git a/Perf.lua b/Perf.lua new file mode 100644 index 0000000..475a208 --- /dev/null +++ b/Perf.lua @@ -0,0 +1,457 @@ +local _, ns = ... + +local Perf = {} +ns.Perf = Perf + +local CreateFrame = CreateFrame +local C_Timer = C_Timer +local mfloor = math.floor +local mhuge = math.huge + +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 }, + { "resultEntryPool", "Database.resultEntryPool", function() return ns.Database and ns.Database._resultEntryPool 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 }, + { "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 + +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 + + 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 + +-- 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 + 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 + +-- keyDelay <= 0 yields one frame (~16ms), 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 + +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 + +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 + +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 + +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 + +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 + + 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 + + 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 = { + { 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 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)", + 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 }, + + { 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 }, + + { 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 }, + + { 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 "?")) + + 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)) + + 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/README.md b/README.md index 1b58058..d50fe0a 100644 --- a/README.md +++ b/README.md @@ -1,291 +1,95 @@ # EasyFind -EasyFind lets you search for any panel, tab, or setting in WoW's interface and any point of interest on the map. Type what you're looking for, and EasyFind walks you to it step by step, or if you prefer, opens it directly. +EasyFind is WoW's Raycast, Spotlight, or Alfred: a fast command bar that lets you search anything in World of Warcraft. -

-guide mode
-Guide mode walks you through each step -

- -

-direct open
-Direct open skips straight to the panel instantly -

- -## Table of Contents +## How to Use -- [Features](#features) - - [UI Search](#ui-search) - - [Map Search](#map-search) - - [Search Features](#search-features) - - [Two Navigation Modes](#two-navigation-modes) -- [How to Use](#how-to-use) -- [First-Time Setup](#first-time-setup) -- [Slash Commands](#slash-commands) -- [Keybinds](#keybinds) -- [Options](#options) -- [Moving the Search Bars](#moving-the-search-bars) -- [Feedback](#feedback) -- [Links](#links) +Set a Search Bar keybind during the tutorial, or later in EasyFind options. Press that keybind to show and focus the search bar, type what you want, then select a result. Press the keybind again or click anywhere outside EasyFind to close it. ## Features -### UI Search - -Find and navigate to any interface element such as: - -* Character panels (titles, currency, reputation) -* Talents and spellbook -* Group Finder (dungeons, raids, PvP queues) -* Collections (mounts, pets, toys, transmog) -* Achievements and statistics (including nested categories) -* Guild and social features -* Professions -* All currencies from your character's Currency tab, including seasonal and legacy currencies (shows current amounts inline) -* Your collected mounts, toys, and battle pets (click to summon or use) -* Dungeon and raid loot filtered by class, spec, and difficulty (click to open in the Encounter Journal) -* Transmog outfits with click-to-equip, cooldown tracking, and lock status display -* Player portrait menu options (Set Focus, Loot Specialization, Dungeon/Raid Difficulty, Edit Mode, PvP Flag, etc.) -* Map search results (zones, dungeons, POIs) via the filter dropdown -* Coverage is always expanding. If a panel exists in the default UI, the goal is for EasyFind to reach it - -

-mounts and toys search
-Mount and toy search with click-to-summon. Results are scrollable with full category paths shown. -

- -Currencies show your current amounts inline, and reputations display progress bars with renown level, friendship rank, or traditional standing. - -

-currency search -Reputation progress bars (left) and currency amounts (right) in search results -

- -### Map Search - -Locate places and NPCs across Azeroth: - -* Portals, zeppelins, boats, and trams -* Banks and auction houses -* Flight masters and city innkeepers -* Dungeon, raid, and delve entrances (search globally or within current map) -* Profession trainers and class trainers -* Vendors, PvP vendors, and quartermasters -* Mailboxes, barbers, transmogrifiers, and repair vendors -* Stable masters, void storage, and guild services -* The Great Vault, Creation Catalyst, and Trading Post -* Chromie (Timewalking Campaigns) -* Rare mobs (with optional auto-tracking that always shows active rares on the map) +### Main Search -

-all rares search -single rare search
-Search "rare" to see all active rares, or select a specific one to track it -

+Search across: -

-rares filter
-Rares category with auto-track toggle in the filter menu -

+- UI panels, tabs, options, addon settings, and Blizzard settings. +- Achievements, statistics, titles, reputations, currencies, talents, abilities, and spellbook entries. +- Mounts, pets, toys, transmogs, outfits, heirlooms, gear sets, macros, bosses, loot, and bag items. +- Zones, dungeons, raids, delves, services, travel points, rares, and other map destinations. -Search the zone of the map you're currently focused on with the local bar (left side), or search across all of Azeroth with the global bar (right side). Map search results also appear in the UI search bar when the Map Search filter is enabled. +Supported results can do more than just open a panel: -

-local search
-Local search finding the auction houses -

+- Mount up, summon pets, use toys, equip outfits, run macros, and cast or use abilities directly from search. +- Shift-click supported abilities from results to drag them to action bars. +- Use consumables, equip gear, or Ctrl-click supported bag items to open and highlight the item in your bags. +- Open boss and loot entries in the Encounter Journal. +- Guide to collection entries, favorite pets, rename pets, and open supported collection tools. +- Open map destinations in the Map Search tab and track or preview them from the world map. -

-local dungeon search
-Local search finding a dungeon entrance -

+### Map Search Tab -

-global raid search
-Global search across all of Azeroth -

+Search from the world map with nested results built for location-first browsing: -The global bar finds zones, dungeons, raids, and delves with full breadcrumb paths. +- **"This Zone"** shows matches on the map currently being viewed. +- **"Across the World"** groups broader matches by continent and zone. +- Hover map results for POI previews, navigate between zones, place pins, and track destinations. +- The same map results can also be found from the main search bar, just without the nested map layout. -

-zone and instance search
-Zone and instance results with breadcrumb paths -

+### Search Tools -### Search Features +- **Pins**: Right-click a result to keep it visible before typing. +- **Aliases**: Add your own search terms for any supported result. Aliases are shared between normal search and map search where applicable. +- **Guide mode and direct open**: Learn where things live with step-by-step highlights, or open supported destinations directly. +- **Quick filters**: Type `@` to search within a category such as pets, mounts, bags, macros, abilities, achievements, statistics, bosses, gear, currencies, reputations, talents, titles, collections, or map results. +- **Slash-command results**: Type `/` in the search bar to see supported EasyFind commands such as `/reset` and `/resize`. +- **Calculator**: Type math directly into search, including arithmetic, trig functions, and factorials, or open the full calculator with `Alt+C`. +- **Keyboard control**: Use arrows, Enter, Tab, Alt+number row shortcuts, or Alt+H/J/K/L navigation. +- **Context menus**: Right-click results for row-specific actions such as Guide, Pin, Add Alias, favorite toggles, tracking, edit actions, and collection tools. -* **Pinned Paths**: Right-click any search result to pin it as a bookmark. Pinned items always appear at the top of your results and persist across sessions. +### Options -

-pinned paths
-Pinned 3v3 Arena appears at the top of every search -

+Configure: -* **Category Filters**: Narrow results by type. Filter global results by zones, dungeons, raids, and delves. Filter local results by instances, travel, and services. The UI search bar also has its own filter to mix in map results, mounts, toys, and pets alongside UI results. - -

-UI search bar filter -map search filter
-UI search bar filter (left) and map search filter (right) -

- -* **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) -

- -### Two Navigation Modes - -* **Guide Mode** (default): Walks you through each step to reach your destination. Highlights the correct button or tab with a yellow pulsing border and an animated arrow so you learn where things live. -* **Direct Open Mode**: Opens panels and tabs directly in a single instant, for when you already know the UI and just want the convenience. - -Toggle between modes using the mode button on the left of each search bar. The icon changes to reflect the current mode. UI search and map search each have their own toggle. - -

-standard mode toggle -fast mode toggle
-Standard mode (left) and Fast mode (right) toggle button states -

- -

-instant zone navigation
-Fast mode jumps straight to the target zone -

- -## How to Use +- Search behavior, auto-hide behavior, result placement, visible row count, font size, and Alt+number hints. +- Map Search behavior, map pins, icon sizing, tracking, recent searches, and result categories. +- Aliases, keybindings, indicator style/color, search window sizing, and reset tools. -Type at least 2 characters and results appear as you type. Click a result or press Enter to select the first match. +## Examples -**Examples:** +- `talents` opens or guides to the Talents panel. +- `3v3` finds Rated Arena. +- `@pets beetle` searches pets only. +- `calculator` shows the full calculator launcher. +- `sin30 + 12/3` shows an inline calculator result. +- `nexus` can find map results and open the Map Search tab. -* `talents` → opens the Talents panel -* `currency` → opens the Currency tab -* `3v3` → navigates to the 3v3 Arena queue in Group Finder -* `duel` → finds duel statistics in Achievements +## Commands -Results show their full path so you always know where you're going: +- `/ef` or `/ef o`: open EasyFind options. +- `/ef clear` or `/ef c`: clear active highlights, guides, pins, and map indicators. +- `/ef reset` or `/ef r`: reset the search position and size after confirmation. +- `/ef setup`: run the tutorial again. +- `/ef bug`: show the bug-report link. +- `/ef feature`: show the feature-request link. -* Character Info > Currency -* Group Finder > Player vs. Player > Rated - -## First-Time Setup - -When you install EasyFind for the first time, you'll see an interactive setup overlay that helps you position and resize the search bar. Simply drag the bar where you want it and use the corner handle to adjust the size, then click **Done** when ready. You can always resize it later through the options panel (`/ef`) and reposition it by holding **Shift** and dragging. - -

-first-time setup
-First-time setup overlay -

- -## Slash Commands - -| 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 bug |Get a link to submit a bug report on GitHub | -| /ef feature |Get a link to submit a feature request on GitHub | - -Options are also accessible via ESC > Interface > AddOns > EasyFind or through the addon compartment button. +Search-bar commands are also available by typing `/` directly in the EasyFind search bar. ## Keybinds -EasyFind provides customizable keybinds: - -* **Toggle Bar**: Show/hide the search bar -* **Focus Bar**: Jump to the search bar and start typing (or unfocus if already active) -* **Toggle+Focus**: Opens and focuses the search bar in one press. Press again to close. When the world map is open, focuses the local map search bar instead -* **Clear All**: Dismiss all active highlights, map pins, zone highlights, and waypoints - -**No keybinds are set by default.** Configure them in the Options panel or via ESC > Keybinds > EasyFind. - -## Options - -

-options panel
-Options panel -

+No keybinds are forced by default. Configure them in EasyFind options or WoW keybindings. -### General -* **Show Login Message**: Show or hide the "EasyFind loaded!" chat message on login. -* **Minimap Button**: Show or hide the minimap icon. Drag to reposition. -* **Indicator Style**: Choose from 5 arrow textures (EasyFind Arrow, Classic Quest Arrow, Minimap Player Arrow, Low-res Gauntlet, HD Gauntlet). All indicators update in real-time. -* **Indicator Color**: Pick from 8 color presets (Yellow, Gold, Orange, Red, Green, Blue, Purple, White). -* **Results Theme**: Choose between Classic (colorful tree lines) or Retail (quest log style) for the search results dropdown. -* **Panel Opacity**: Adjust the options panel background transparency. +Available binds include: -### UI Search - -* **Enable UI Search Module**: Toggle the entire UI search feature on or off (requires reload). -* **Open Panels Directly**: Selecting a UI result opens the target panel instantly instead of guiding you through each step. Off by default. -* **Smart Show**: Hide the search bar until you hover over it. - -

-smart show
-Smart Show hides the bar until you hover -

- -* **Static Opacity**: Keep the search bar at constant opacity while moving (off by default; bar fades while moving). -* **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
-Visual rescaler with drag handles for width, rows, and font size -

- -### 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. -* **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. - -Map Search options are organized into grouped dropdowns (Search Bars, Map Pins, Minimap, Automation) 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 - -Both search bars can be repositioned by holding **Shift** and dragging. The map search bar stays constrained to the bottom of the map frame. +- Search Bar +- Map Search Tab +- Clear All ## Feedback -Found a bug or have an idea? You can submit feedback through GitHub Issues: - -* **In-game**: Type `/ef bug` or `/ef feature`, or use the buttons at the bottom of the Options panel. A link will appear that you can copy and paste into your browser. -* **On GitHub**: Open an issue directly at the [Issues page](https://github.com/wowaddonmaker/EasyFind/issues/new/choose). -* **On CurseForge**: Leave a comment on the [CurseForge page](https://www.curseforge.com/wow/addons/easyfind). +- GitHub issues: https://github.com/wowaddonmaker/EasyFind/issues/new/choose +- CurseForge: https://www.curseforge.com/wow/addons/easyfind ## Links -* [GitHub](https://github.com/wowaddonmaker/EasyFind) -* [Changelog](https://github.com/wowaddonmaker/EasyFind/blob/main/CHANGELOG.md) +- GitHub: https://github.com/wowaddonmaker/EasyFind +- Changelog: https://github.com/wowaddonmaker/EasyFind/blob/main/CHANGELOG.md diff --git a/Rescaler.lua b/Rescaler.lua index f2f014b..457325f 100644 --- a/Rescaler.lua +++ b/Rescaler.lua @@ -5,88 +5,115 @@ 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 -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 - --- Active rescaler state -local activeMode = nil -- "ui" or "map" -local activeSearchBar = nil -- the search bar being rescaled -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 previewResults = nil -- fake results frame for preview +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 +local liveSearchBar = nil +local liveResultsFrame = nil +local liveContainerFrame = nil +local liveState = nil +local backdrop = nil +local barOverlay = nil +local donePanel = nil +local mockSearchBar = nil +local mockWindowFrame = nil +local previewResults = nil 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 DEFAULT_RESULTS_HEIGHT 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 DEFAULT_RESULTS_HEIGHT end -local function GetFontScale() - if activeMode == "ui" then return EasyFind.db.fontSize or 1.0 end - return EasyFind.db.mapFontSize 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) - if activeMode == "ui" then EasyFind.db.fontSize = val - else EasyFind.db.mapFontSize = val end +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 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 +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 -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) @@ -158,14 +185,13 @@ 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) 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 @@ -173,14 +199,13 @@ 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) 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) @@ -198,9 +223,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") @@ -219,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") @@ -238,9 +262,59 @@ local function CreateGlowOverlay(name, parent, target) return glow end --- Preview results (fake rows to show results area) +local function HideRoundedFrameBorder(frame) + ns.SetRoundedRectBorderEdgeShown(frame, false) +end + +local SetModernButtonFill = ns.SetRoundedRectBorderFillColor + +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 -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,27 +323,32 @@ 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 - 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) @@ -324,7 +403,103 @@ local function CreatePreviewResults(parent, targetFrame, width, heightPx, anchor return frame end --- Dimension label wiring +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()) + 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) + 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 local function WireDimLabel(box, getter, setter) box:SetText(mfloor(getter() + 0.5)) @@ -342,8 +517,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 @@ -360,9 +533,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 @@ -373,54 +545,14 @@ 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) handle:SetScript("OnDragStart", function(self) 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 @@ -431,162 +563,76 @@ 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)) - end + local cx, cy = GetCursorPosition() + local scale = UIParent:GetEffectiveScale() + cx, cy = cx / scale, cy / scale - local optPanel = _G["EasyFindOptionsFrame"] - if optPanel then - local slider = activeMode == "ui" and optPanel.uiFontSlider or optPanel.mapFontSlider - if slider then slider:SetValue(newFont) end + 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 - 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(mode == "ui" and "UI Search Bar" or "Map 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() + 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) - 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 + 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 drag handle (top or bottom edge) - local heightHandle = CreateHandle(overlay, heightEdge, 0, 0, nil, false) - overlay.heightHandle = heightHandle - - -- Height field (outside the resize edge) local heightBox = CreateFrame("EditBox", nil, overlay, "InputBoxTemplate") heightBox:SetSize(50, 20) heightBox:SetAutoFocus(false) @@ -616,43 +662,27 @@ local function BuildResultsOverlay(parent, targetFrame, anchorAbove) return overlay end --- Done panel - local function CreateDonePanel(parent) local BACK_W = 110 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 end --- Full-screen dim backdrop - local function GetOrCreateBackdrop() if backdrop then return backdrop end backdrop = CreateFrame("Frame", "EasyFindRescaleBackdrop", UIParent) @@ -664,8 +694,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 @@ -680,250 +708,141 @@ local function GetOrCreateBackdrop() return backdrop end --- Enter rescale mode - 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"] - - if not searchBar then - activeMode = nil - return - end - - -- Force visible - searchBar:Show() - searchBar:SetAlpha(1.0) - - getBarWidth = function() return searchBar:GetWidth() end - setBarWidth = function(w) - w = ClampWidth(w) - searchBar:SetWidth(w) - EasyFind.db.uiSearchWidth = w / 250 - end - - getResultsScale = function() return EasyFind.db.uiResultsScale or 1.0 end + liveSearchBar = _G["EasyFindSearchFrame"] + liveResultsFrame = _G["EasyFindResultsFrame"] + liveContainerFrame = _G["EasyFindContainerFrame"] - 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 - - 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 - - getResultsScale = function() return EasyFind.db.mapResultsScale or 1.0 end + if not liveSearchBar then + activeMode = nil + return + end - 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 + 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) + mockSearchBar:SetWidth(w) + EasyFind.db.uiSearchWidth = w / 250 + EasyFind.db.uiResultsWidth = w end - -- Hide options panel + local getBarWidth = function() return mockSearchBar:GetWidth() end + local setBarWidth = setUiWidth + local optPanel = _G["EasyFindOptionsFrame"] if optPanel and optPanel:IsShown() then 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) - -- 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 previewW = getResultsWidth() - local currentH = mmin(GetResultsHeight(), GetScreenMaxHeight(resultsAbove)) - local leftAligned = (mode == "map") - previewResults = CreatePreviewResults(bg, searchBar, previewW, currentH, resultsAbove, leftAligned) - previewResults:SetScale(getResultsScale()) + mockSearchBar = CreateMockSearchBar(bg, liveSearchBar, liveCenterX, liveCenterY) + mockSearchBar:Show() + activeSearchBar = mockSearchBar + + local resultsAbove = EasyFind.db.uiResultsAbove + 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) + 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() + -- 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) 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 - 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) + local function setBarWidthAndPreview(w) + setBarWidth(w) + 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) - setBarWidth(defW) + 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) + barOverlay.heightBox:SetText(mfloor(GetUnifiedWindowHeight() + 0.5)) + barOverlay.heightBox: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() + local ratio = GetSearchBarHeight() / mmax(1, GetUnifiedWindowHeight()) + SetUnifiedWindowHeight(val, ratio, previewResults, self, resultsAbove) + self:SetText(mfloor(GetUnifiedWindowHeight() + 0.5)) end self:ClearFocus() end) - barOverlay.fontBox:SetScript("OnEscapePressed", function(self) - self:SetText(mfloor(GetFontScale() * 100 + 0.5)) + barOverlay.heightBox:SetScript("OnEscapePressed", function(self) + self:SetText(mfloor(GetUnifiedWindowHeight() + 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) - - -- 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) + 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) - AddResetButton(resultsOverlay.widthBox, function() - local defW = 300 - setResultsWidth(defW) - previewResults:SetWidth(defW) - resultsOverlay.widthBox:SetText(mfloor(defW + 0.5)) - end) - - -- Wire results corner (width + height combo) - resultsOverlay.heightBox:SetText(currentH) - resultsOverlay.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) - end - self:ClearFocus() - end) - resultsOverlay.heightBox:SetScript("OnEscapePressed", function(self) - self:SetText(GetResultsHeight()) - self:ClearFocus() - end) - local heightReset = AddResetButton(resultsOverlay.heightBox, function() - local def = GetDefaultResultsHeight() - SetResultsHeight(def) - previewResults:SetPreviewHeight(def) - resultsOverlay.heightBox:SetText(def) - 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 - SetupCornerDrag(resultsOverlay.scaleHandle, previewResults, getResultsWidth, function(w) - setResultsWidth(w) - previewResults:SetWidth(w) - end, resultsOverlay.widthBox, 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) - if mode == "map" then - donePanel:SetPoint("BOTTOM", barOverlay, "TOP", 0, 0) - else - donePanel:SetPoint("TOP", resultsOverlay, "BOTTOM", 0, -50) - end + donePanel:SetPoint("TOP", barOverlay, "BOTTOM", 0, -50) donePanel.doneBtn:SetScript("OnClick", function() Rescaler:Exit() end) @@ -933,31 +852,29 @@ 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 - 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 @@ -972,21 +889,35 @@ 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 + 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/StaticLocations.lua b/StaticLocations.lua index 4ae88cb..b9316eb 100644 --- a/StaticLocations.lua +++ b/StaticLocations.lua @@ -1,4 +1,4 @@ --- EasyFind Static Locations (auto-generated 2026-03-17 03:17) +-- EasyFind Static Locations (auto-generated 2026-04-09 03:37) local _, ns = ... ns.STATIC_LOCATIONS = { [18] = { -- Tirisfal Glades @@ -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"} }, }, } diff --git a/TutorialBox.lua b/TutorialBox.lua new file mode 100644 index 0000000..ddd1fdd --- /dev/null +++ b/TutorialBox.lua @@ -0,0 +1,297 @@ +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) + -- 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 + + 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 + + -- 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 diff --git a/UI.lua b/UI.lua index 0116666..94af108 100644 --- a/UI.lua +++ b/UI.lua @@ -3,7 +3,14 @@ local _, ns = ... local UI = {} ns.UI = UI +function UI:ApplySearchWindowFill(frame) + 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 + local Utils = ns.Utils +local UIPins = ns.UIPins local GetButtonText = Utils.GetButtonText local SearchFrameTreeFuzzy = Utils.SearchFrameTreeFuzzy local ClickButton = Utils.ClickButton @@ -11,11 +18,11 @@ 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 local TOOLTIP_BORDER = ns.TOOLTIP_BORDER -local DARK_PANEL_BG = ns.DARK_PANEL_BG local CreateFrame = CreateFrame local C_Timer = C_Timer @@ -24,1664 +31,6100 @@ local GameTooltip = GameTooltip local GameTooltip_Hide = GameTooltip_Hide local IsShiftKeyDown = IsShiftKeyDown local GetCursorPosition = GetCursorPosition +local InCombatLockdown = InCombatLockdown local wipe = wipe -local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind\\textures\\lightning-bolt" local REP_BAR_WIDTH = 100 -local searchFrame -local resultsFrame -local resultButtons = {} -local MAX_BUTTON_POOL = 50 -- Maximum buttons (scroll handles overflow beyond this) -local inCombat = false -local selectingResult = false -- guard: suppress OnTextChanged re-renders during SelectResult -local deferredRepRefreshPending = false -- deferred re-render to let IsTruncated() settle -local outfitCdStart, outfitCdDuration = 0, 0 -- shared outfit swap cooldown -local lastEquippedOutfitID -- tracks most recent equip for immediate green tint +function UI:GetDefaultSearchBarPoint() + local parentH = UIParent and UIParent.GetHeight and UIParent:GetHeight() or 768 + return "CENTER", "CENTER", 0, parentH / 6 +end --- 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", - "itemID", "encounterID", "instanceID", "lootSlotName", "lootSourceName", "lootInstanceName", "lootSourceType", - "factionID", "hasRepBar", "canQueue", "isPvP", "isPvE"} -local CLEAN_TABLE_FIELDS = {"path", "steps", "keywords", "keywordsLower"} - -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 arr = {} - for i2, v2 in ipairs(v) do - if type(v2) == "table" then - local sub = {} - for sk, sv in pairs(v2) do sub[sk] = sv end - arr[i2] = sub - else - arr[i2] = v2 - end - end - clean[k] = arr - 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", + [3] = "TokenFrame", +} + +local function SecureCall(fn, ...) + if not fn then return false end + if securecallfunction then + securecallfunction(fn, ...) + else + pcall(fn, ...) end - return clean + return true 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.itemID and data.category == "Loot")) +local function SecureShowUIPanel(frame) + if not frame or not ShowUIPanel then return false end + return SecureCall(ShowUIPanel, frame) 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" +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 charKey + return false 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] +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 EasyFind.db.pinnedUIItems + + return ClickButton(_G["PlayerSpellsMicroButton"]) end -local function GetAllPins() - local all = {} - for _, pin in ipairs(EasyFind.db.pinnedUIItems) do - all[#all + 1] = pin +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 - 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 + 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 all + return false 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 +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 - 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 + + 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 function PinUIItem(data) - if IsUIItemPinned(data) then return end - local clean = CleanUIForStorage(data) - clean.isPinned = true - tinsert(GetPinList(data), clean) +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 +-- of searchFrame, anchored to it; grows downward to cover +-- resultsFrame when ShowHierarchicalResults runs. +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 +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 +-- 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 +local idleTrimSerial = 0 +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 + +-- 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 }, + 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 } }, + 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 + -- 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-SpecTalents-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 } }, + 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 } }, + 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(). + 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 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 +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 --- 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 +-- 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 } }, +} - syncList(EasyFind.db.pinnedUIItems) - local ck = GetCharKey() - local charPins = EasyFind.db.pinnedUIItemsPerChar and EasyFind.db.pinnedUIItemsPerChar[ck] - syncList(charPins) +local function GetFlatCategoryIcon(data) + if not data then return nil 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 + 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 + 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.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 + 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.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() + 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 --- 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", "EasyFindPinPopup", UIParent, "BackdropTemplate") - pinPopup:SetSize(80, 28) - 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", "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) - 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) - if pinPopup._hideTimer then pinPopup._hideTimer:Cancel(); pinPopup._hideTimer = nil end - pinPopup:Show() +-- 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 --- Centralized icon setter - resets texture state before applying to prevent --- atlas/texture bleed between rows. -local function SetRowIcon(btn, kind, value, iconSize) - btn.icon:SetTexture(nil) - btn.icon:SetTexCoord(0, 1, 0, 1) - btn.icon:SetVertexColor(1, 1, 1, 1) - -- Clear mount/toy/pet tooltip data and cooldown from previous render - btn.icon.mountID = nil - btn.icon.toyItemID = nil - btn.icon.petID = nil - btn.icon.spellID = nil - btn.icon.outfitID = nil - btn.icon.lootItemID = nil - if btn.iconCooldown then btn.iconCooldown:Hide() end - if btn._lockOverlay then btn._lockOverlay:Hide() end - if kind == "atlas" then - btn.icon:SetAtlas(value) - elseif kind == "file" or kind == "path" then - if type(value) == "table" and value.file then - btn.icon:SetTexture(value.file) - if value.coords then - local c = value.coords - btn.icon:SetTexCoord(c[1], c[2], c[3], c[4]) - end - else - btn.icon:SetTexture(value) - end - elseif kind == "hidden" then - btn.icon:Hide() - return +-- 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 - btn.icon:SetSize(iconSize or 16, iconSize or 16) - 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 unearnedTooltip -- Custom tooltip for unearned currencies - --- THEME DEFINITIONS -local THEMES = {} +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 + return data.path[#data.path] + end + if data.mapSearchResult then + 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 --- Classic: colorful tree connectors, +/- icons, gold leaf text -THEMES["Classic"] = { - rowHeight = 22, - 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}, +UI._calculator = { + PATH = { "Calculator" }, + LAUNCHER = { + name = "Calculator", + nameLower = "calculator", + category = "Calculator", + path = { "Calculator" }, + noPin = true, + calculatorLauncher = true, + keywords = { "calculator", "calc", "math" }, }, - -- 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 }, + 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, }, } --- Retail: quest-log style - raised tab headers, golden tree lines, grey border -THEMES["Retail"] = { - rowHeight = 28, - indentPx = 20, -- matches INDENT_PX so tree lines align - lineWidth = 2, - resultsWidth = 350, - resultsPadTop = 10, - resultsPadBot = 10, - resultsPadLeft = 12, - btnWidth = 366, - iconSize = 16, - pathIconSize = 14, - -- fonts - pathFont = ns.SEARCHBAR_FONT, - leafFont = ns.LEAF_FONT, - pathColor = {0.65, 0.60, 0.55, 1.0}, -- muted gray-tan (normal state) - 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, - indentColors = { - {0.85, 0.65, 0.15, 0.80}, - {0.85, 0.65, 0.15, 0.80}, - {0.85, 0.65, 0.15, 0.80}, - {0.85, 0.65, 0.15, 0.80}, - {0.85, 0.65, 0.15, 0.80}, - {0.85, 0.65, 0.15, 0.80}, +UI.searchBarCommands = { + { + command = "reset", + display = "/reset", + desc = "Reset search bar position and size", + aliases = { "reset", "resetpos", "resetposition" }, }, - -- 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 = true, - 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 - resultsBackdrop = { - edgeFile = TOOLTIP_BORDER, - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 }, + { + 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" }, }, - 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 } +UI.searchBarCommandEntries = {} +UI.searchBarCommandData = {} -local function GetActiveTheme() - return THEMES[EasyFind.db.resultsTheme or "Classic"] or THEMES["Classic"] +function UI._calculator.IsFinite(value) + return type(value) == "number" and value == value + and value ~= math.huge and value ~= -math.huge end -function UI:CreateUnearnedTooltip() - -- Create simple tooltip frame - unearnedTooltip = CreateFrame("Frame", "EasyFindUnearnedTooltip", UIParent, "BackdropTemplate") - unearnedTooltip:SetFrameStrata("TOOLTIP") - unearnedTooltip:SetFrameLevel(9999) - unearnedTooltip:SetClampedToScreen(true) +function UI._calculator.ToRadians(value) + return value * math.pi / 180 +end - -- Simple black background with border - unearnedTooltip:SetBackdrop({ - bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", - edgeFile = TOOLTIP_BORDER, - tile = true, tileSize = 16, edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 } - }) - unearnedTooltip:SetBackdropColor(0, 0, 0, 0.95) - unearnedTooltip:SetBackdropBorderColor(0.6, 0.6, 0.6, 1) +function UI._calculator.ToDegrees(value) + return value * 180 / math.pi +end - -- 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 +function UI._calculator.Round(value) + if value >= 0 then return math.floor(value + 0.5) end + return math.ceil(value - 0.5) +end - -- Auto-size tooltip to fit text with padding - local textWidth = text:GetStringWidth() - local textHeight = text:GetStringHeight() - unearnedTooltip:SetSize(textWidth + 20, textHeight + 16) -- Add padding +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 - unearnedTooltip:Hide() +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:Initialize() - self:CreateUnearnedTooltip() - self:CreateSearchFrame() - self:CreateResultsFrame() - self:RegisterCombatEvents() +function UI._calculator.Sinh(value) + if math.sinh then return math.sinh(value) end + return (math.exp(value) - math.exp(-value)) / 2 +end - if EasyFind.db.visible ~= false then - searchFrame:Show() - -- Apply smart show on startup - if EasyFind.db.smartShow then - searchFrame.hoverZone:Show() - searchFrame:SetAlpha(0) - searchFrame.setSmartShowVisible(false) +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 - else - searchFrame:Hide() - if EasyFind.db.smartShow then - searchFrame.hoverZone:Show() + 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 - inCombat = InCombatLockdown() - if inCombat then - searchFrame:Hide() - end +function UI._calculator.LooksLikeInput(raw) + local text = raw and strtrim(raw) or "" + if text == "" or #text > 96 then return false end - self:UpdateScale() - self:UpdateWidth() - self:UpdateFontSize() + local lower = slower(text) + if lower == "pi" or lower == "tau" then return true end - -- Block auto-focus on creation - WoW may focus visible EditBoxes after creation. - -- Block for two frames (enough for WoW's auto-focus to fire and get rejected). - searchFrame.editBox.blockFocus = true - searchFrame.editBox:ClearFocus() - C_Timer.After(0, function() - C_Timer.After(0, function() - if searchFrame and searchFrame.editBox then - searchFrame.editBox.blockFocus = nil - searchFrame.editBox:ClearFocus() - end - end) - 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 - -- 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) + 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:RegisterCombatEvents() - ns.eventFrame:RegisterEvent("PLAYER_REGEN_DISABLED") - ns.eventFrame:RegisterEvent("PLAYER_REGEN_ENABLED") - ns.eventFrame:HookScript("OnEvent", function(self, event) - if event == "PLAYER_REGEN_DISABLED" then - inCombat = true - searchFrame:Hide() - searchFrame.hoverZone:Hide() - UI:HideResults() - 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 +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 - if EasyFind.db.smartShow then - searchFrame.hoverZone:Show() + 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("[%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 } + i = i + 1 + else + return nil end - end) + end + + tokens[#tokens + 1] = { type = "eof" } + return tokens end -function UI:CreateSearchFrame() - searchFrame = CreateFrame("Frame", "EasyFindSearchFrame", UIParent, "BackdropTemplate") - UI.searchFrame = searchFrame - searchFrame:SetSize(250, ns.SEARCHBAR_HEIGHT) - searchFrame:SetFrameStrata("MEDIUM") - searchFrame:SetMovable(true) - searchFrame:EnableMouse(true) - searchFrame:SetClampedToScreen(true) +function UI._calculator.Parse(tokens) + local pos = 1 + local c = UI._calculator + local parseExpression, parseAdd, parseMul, parseUnary, parsePower, parsePostfix, parsePrimary - -- 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]) - else - searchFrame:SetPoint("TOP", UIParent, "TOP", 0, -12) + local function fail() + error("calculator parse failed", 0) end - local theme = GetActiveTheme() - local WHITE8x8 = "Interface\\BUTTONS\\WHITE8x8" - ns.CreateSearchBorder(searchFrame) - if theme.searchBarRounded 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 = 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) + local function current() + return tokens[pos] end - -- Mode toggle button (search icon area, 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") + 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 - ns.UpdateModeButtonVisual = UpdateModeButtonVisual - modeBtn:SetScript("OnEnter", function(self) - 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) - else - GameTooltip:SetText("Standard Search") - GameTooltip:AddLine("Click to enable fast search (opens panels directly).", 1, 1, 1, true) + 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 - GameTooltip:Show() - end) + return false + end - modeBtn:SetScript("OnLeave", function(self) - if not self.keyboardFocused then self.btnBg:Hide() end - GameTooltip_Hide() - end) + parseExpression = function() + return parseAdd() + 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) + 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) + end - UpdateModeButtonVisual(modeBtn) + parseUnary = function() + if acceptOp("+") then return parseUnary() end + if acceptOp("-") then return -parseUnary() end - -- Editbox - local editBox = CreateFrame("EditBox", "EasyFindSearchBox", searchFrame) - editBox:SetHeight(contentSz) - editBox:SetPoint("LEFT", modeBtn, "RIGHT", 0, 0) - editBox:SetPoint("RIGHT", searchFrame, "RIGHT", -8, 0) - editBox:SetFontObject(ns.SEARCHBAR_FONT) - editBox:SetAutoFocus(false) - editBox:SetMaxLetters(50) + 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 - -- 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() + 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 - if searchFrame.setupMode then - self.blockFocus = true + 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) - editBox:HookScript("OnMouseUp", function(self) - self.blockFocus = nil - if searchFrame:IsMovable() then - searchFrame:StopMovingOrSizing() - local point, _, relPoint, x, y = searchFrame:GetPoint() - EasyFind.db.uiSearchPosition = {point, relPoint, x, y} + 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 - end) + fail() + 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") - editBox.placeholder = placeholder + 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 - editBox:SetScript("OnEditFocusGained", function(self) - if self.blockFocus then - self:ClearFocus() - return +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: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 escCatcher then escCatcher:Hide() end - self.placeholder:Hide() - if selectedIndex > 0 then - selectedIndex = 0 - toggleFocused = false - UI:UpdateSelectionHighlight(true) + 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 - if self:GetText() == "" then - UI:ShowPinnedItems() + 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) + end + return true +end - editBox:SetScript("OnEditFocusLost", function(self) - -- Skip cleanup when SelectResult is actively clearing text/focus - if selectingResult then return end - if strtrim(self:GetText()) == "" then - self:SetText("") -- Clear any stray whitespace - self.placeholder:Show() - -- Defer hide by one frame so pending pin/result clicks (LeftButtonDown) - -- can fire before the results frame is hidden. Without the delay the - -- parent frame hides and the child button never receives its OnClick. - C_Timer.After(0, function() - if selectingResult then return end - if searchFrame.editBox:HasFocus() then return end - if navFrame and navFrame:IsKeyboardEnabled() then return end - if strtrim(searchFrame.editBox:GetText()) ~= "" then return end - -- Don't hide if spec/class flyouts are open - local sf = _G["EasyFindSpecFlyout"] - local ssf = _G["EasyFindSpecSubFlyout"] - 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 - UI:HideResults() - -- Now that results are hidden, let smart show fade the bar out - if EasyFind.db.smartShow then - searchFrame.smartShowFadeOut() +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 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 - editBox:SetScript("OnTextChanged", function(self) - if self:GetText() ~= "" then - self.placeholder:Hide() - end - UI:OnSearchTextChanged(self:GetText()) - end) +function UI:SetCalculatorRoundedFill(frame, r, g, b, a, br, bg, bb, ba) + 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 - editBox:SetScript("OnEnterPressed", function(self) - UI:ActivateSelected() - end) +function UI:HideCalculatorRoundedBorder(frame) + ns.SetRoundedRectBorderEdgeShown(frame, false) +end - editBox:SetScript("OnEscapePressed", function(self) - self:ClearFocus() - -- Text and results stay visible; user can click back in to resume. +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) - - -- 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() + btn:SetScript("OnLeave", function(self) + if self:IsEnabled() then UI:SetCalculatorRoundedFill(self, 0.095, 0.095, 0.108, 1) end end) - clearTextBtn:SetScript("OnEnter", function(self) - GameTooltip:SetOwner(self, "ANCHOR_BOTTOM") - GameTooltip:SetText("Clear search text and active highlights") - GameTooltip:Show() + btn:SetScript("OnMouseDown", function(self) + if self:IsEnabled() then UI:SetCalculatorRoundedFill(self, 0.065, 0.065, 0.078, 1) end 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) - filterBtn:SetPoint("BOTTOM", searchFrame, "BOTTOM", 0, 0) - filterBtn:SetPoint("RIGHT", searchFrame, "RIGHT", 0, 0) - filterBtn:SetWidth(searchFrame:GetHeight()) - filterBtn:SetFrameLevel(searchFrame:GetFrameLevel() + 10) - - local filterArrow = filterBtn:CreateTexture(nil, "OVERLAY") - filterArrow:SetSize(11, 11) - filterArrow:SetPoint("CENTER") - filterArrow:SetTexture(423808) - filterArrow:SetTexCoord(0.453, 0.203, 0.453, 0.016, 0.641, 0.203, 0.641, 0.016) - filterArrow:SetDesaturated(true) - filterArrow:SetBlendMode("ADD") - filterArrow:SetVertexColor(1, 1, 1) - filterBtn.arrow = filterArrow + 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 - local filterBtnBg = filterBtn:CreateTexture(nil, "ARTWORK") - filterBtnBg:SetAllPoints() - filterBtnBg:SetTexture(796424) - filterBtnBg:Hide() - filterBtn.btnBg = filterBtnBg +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 - filterBtn:SetHighlightTexture(130757) +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 = 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() + else + frame.clearInputButton:Hide() + end + end + if submitted and data then + frame.resultText:SetText(data.calculatorResult) + 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 "") + 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 - filterBtn:SetScript("OnEnter", function(self) - self.btnBg:Show() - 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 - GameTooltip_Hide() - end) - searchFrame.filterBtn = filterBtn +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 - -- Reposition clear button to left of filter button - clearTextBtn:ClearAllPoints() - clearTextBtn:SetPoint("RIGHT", filterBtn, "LEFT", -2, 0) +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 - -- Anchor editBox right edge to left of clear button area (filter button zone) - editBox:ClearAllPoints() - editBox:SetPoint("LEFT", modeBtn, "RIGHT", 0, 0) - editBox:SetPoint("RIGHT", filterBtn, "LEFT", -4, 0) +function UI:UseCalculatorPopupResult() + local editBox = UI._calculator.popupEditBox + if not editBox then return false end + return self:SubmitCalculatorPopupExpression(editBox:GetText() or "") +end - -- 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) - searchFrame:HookScript("OnMouseDown", function(self, button) - if button == "LeftButton" and not IsShiftKeyDown() and not self.setupMode then - editBox:SetFocus() - end - 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 - -- Show/hide the clear X based on whether there's text or an active guide - local function UpdateClearButtonVisibility() - local hasText = editBox:GetText() ~= "" - local guideActive = ns.Highlight and ns.Highlight:IsActive() - clearTextBtn:SetShown(hasText or guideActive) - end - editBox:HookScript("OnTextChanged", UpdateClearButtonVisibility) - searchFrame.UpdateClearButtonVisibility = UpdateClearButtonVisibility - - -- Key repeat with progressive acceleration for held arrow/tab keys. - -- Starts at REPEAT_INITIAL delay, accelerates toward REPEAT_FAST over REPEAT_ACCEL seconds. - 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 +function UI:IsCalculatorPopupSubmitKey(key) + if key == "ENTER" then return true end + if IsShiftKeyDown and IsShiftKeyDown() + and (key == "=" or key == "EQUAL" or key == "EQUALS") then + return false end - searchFrame.StartKeyRepeat = StartKeyRepeat + return 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 - 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 + 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 - -- 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. - editBox:SetScript("OnKeyDown", function(self, key) - if resultsFrame and resultsFrame:IsShown() and selectedIndex == 0 then - if EasyFind.db.uiResultsAbove then - if key == "UP" then UI:JumpToEnd() end - else - if key == "DOWN" then UI:MoveSelection(1) 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 - Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) end) +end - searchFrame.editBox = editBox +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 - -- Toolbar keyboard focus: 0 = editbox, 1+ = toolbar control index - local toolbarFocus = 0 +function UI:ClearCalculatorPopupCopyTarget(release) + if UI._calculator.activeSource ~= "calculator" then return end + UI._calculator.activeRow = nil + 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 + UI._calculator.ctrlWasDown = nil + UI._calculator.copyKeyWasDown = nil + if UI._calculator.copyWatcher then + UI._calculator.copyWatcher:Hide() + end + if release then + self:ReleaseCalculatorCopyBox() + end +end - local toolbarHighlight = CreateFrame("Frame", nil, UIParent) - toolbarHighlight:SetFrameStrata("MEDIUM") - toolbarHighlight:SetFrameLevel(searchFrame:GetFrameLevel() + 100) - 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) +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 - local function GetToolbarControls() - local controls = {} - tinsert(controls, modeBtn) - if clearTextBtn:IsShown() then - tinsert(controls, clearTextBtn) - end - tinsert(controls, filterBtn) - return controls +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 - 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 +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(CALC_W, 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", CALC_PAD, -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", -CALC_PAD, -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", CALC_PAD, -44) + inputShell:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -CALC_PAD, -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 - toolbarFocus = idx - local controls = GetToolbarControls() - local target = controls[idx] - if target then - target.keyboardFocused = true - if target.btnBg then - target.btnBg:Show() - if target.LockHighlight then target:LockHighlight() end - toolbarHighlight:Hide() + 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 - toolbarHighlight:SetParent(target) - toolbarHighlight:ClearAllPoints() - toolbarHighlight:SetAllPoints(target) - toolbarHighlight:Show() + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) end - else - toolbarHighlight:Hide() + return end - 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 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 + 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) + expressionText:Hide() + + local resultText = resultCard:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge") + frame.resultText = resultText + 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", resultCard, "RIGHT", -12, 0) + hintText:SetJustifyH("LEFT") + hintText:SetTextColor(0.54, 0.54, 0.56, 1) + hintText:Hide() + + resultCard:SetScript("OnEnter", function(self) + UI:ArmCalculatorPopupResult("hover") + end) + resultCard:SetScript("OnLeave", function(self) + if UI._calculator.activeSource == "calculator" + and UI._calculator.popupArmedSource == "hover" then + UI:ClearCalculatorPopupCopyTarget(true) end - toolbarFocus = 0 - toolbarHighlight:Hide() - end - searchFrame.ClearToolbarFocus = ClearToolbarFocus - - -- 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 + UI:RefreshCalculatorPopup() + end) + resultCard:SetScript("OnClick", function() + UI:ArmCalculatorPopupResult("click") + end) - if key == "DOWN" then - if IsControlKeyDown() then - UI:JumpToEnd() - elseif IsShiftKeyDown() then - UI:JumpToNextSection(1) - else - StartKeyRepeat(key, function() UI:MoveSelection(1) end) - end - elseif key == "UP" then - if IsControlKeyDown() then - UI:JumpToStart() - elseif IsShiftKeyDown() then - UI:JumpToNextSection(-1) - else - StartKeyRepeat(key, function() UI:MoveSelection(-1) end) - end - elseif key == "PAGEDOWN" then - StartKeyRepeat(key, function() UI:MoveSelection(5) end) - elseif key == "PAGEUP" then - StartKeyRepeat(key, function() UI:MoveSelection(-5) end) - elseif key == "HOME" then - UI:JumpToStart() - 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 - 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() - 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 == 1 then - ClearToolbarFocus() - Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) - searchFrame.editBox:SetFocus() - elseif toolbarFocus >= #controls then - SetToolbarFocus(1) - else - SetToolbarFocus(toolbarFocus + 1) - end - end - end - elseif key == "ENTER" then - if toolbarFocus > 0 then - local controls = GetToolbarControls() - local target = controls[toolbarFocus] - if target then target:Click() end - else - UI:ActivateSelected() - end - elseif key == "ESCAPE" then - if toolbarFocus > 0 then - ClearToolbarFocus() - if selectedIndex == 0 then - Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) - end - elseif toggleFocused then - toggleFocused = false - UI:UpdateSelectionHighlight() + local function makeButton(label, insertText) + local b = CreateFrame("Button", nil, frame) + 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) + 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 - selectedIndex = 0 - toggleFocused = false - Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) - if searchFrame.StopKeyRepeat then searchFrame.StopKeyRepeat() end - UI:UpdateSelectionHighlight(true) - end - else - -- If no selection and editbox isn't focused, let the key propagate - -- to the game (e.g. WASD movement) instead of typing into the bar. - if selectedIndex == 0 and not searchFrame.editBox:HasFocus() then - Utils.SafeCallMethod(navFrame, "SetPropagateKeyboardInput", true) - return - end - ClearToolbarFocus() - selectedIndex = 0 - toggleFocused = false - UI:UpdateSelectionHighlight() - if not IsControlKeyDown() and not IsAltKeyDown() and #key == 1 then - local char = IsShiftKeyDown() and key or slower(key) - searchFrame.editBox:Insert(char) + 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", + CALC_PAD + (col - 1) * (CALC_BUTTON_W + CALC_BUTTON_GAP_X), + gridTop - (row - 1) * (CALC_BUTTON_H + CALC_BUTTON_GAP_Y) + ) end end - navFrame:SetScript("OnKeyDown", function(self, key) - -- Outfits/toys: let Enter propagate to the override binding - -- so the secure action handler fires (same as mouse click). - if key == "ENTER" and selectedIndex > 0 and not InCombatLockdown() then - local selRow = resultButtons[selectedIndex] - local rd = selRow and selRow.data - if rd and (rd.outfitID or rd.toyItemID) then - Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) - return - end + frame:SetScript("OnHide", function(self) + if UI._calculator.activeSource == "calculator" then + UI:ClearCalculatorCopyHighlight() + UI:ReleaseCalculatorCopyBox() end - Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) - HandleNavKeyDown(key) - end) - navFrame:SetScript("OnKeyUp", function(_, key) - if repeatKey == key then StopKeyRepeat() end end) + frame:Hide() + self:RefreshCalculatorPopup() + return frame +end - -- UISpecialFrames fallback: WoW closes these on ESC before opening the - -- game menu. Shown after the editbox loses focus with results visible so - -- the next ESC clears+closes instead of toggling the game menu. - escCatcher = CreateFrame("Frame", "EasyFindEscCatcher", searchFrame) - escCatcher:SetSize(1, 1) - escCatcher:Hide() - tinsert(UISpecialFrames, "EasyFindEscCatcher") - escCatcher:SetScript("OnHide", function() - if searchFrame.editBox:HasFocus() then return end - if not resultsFrame or not resultsFrame:IsShown() then return end - Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) - if searchFrame.StopKeyRepeat then searchFrame.StopKeyRepeat() end - selectedIndex = 0 - toggleFocused = false - searchFrame.editBox:SetText("") - searchFrame.editBox.placeholder:Show() - UI:HideResults() - end) +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 - -- 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) - editBox:HookScript("OnKeyDown", function(self, key) - if key ~= "TAB" then return end - self:ClearFocus() - Utils.SafeCallMethod(navFrame, "EnableKeyboard", true) - if IsShiftKeyDown() then - SetToolbarFocus(1) - else - SetToolbarFocus(2) - end - end) +function UI:OpenCalculator(expression, deferFocus) + expression = tostring(expression or "") + 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(expression) + editBox:SetCursorPosition(#expression) + 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 - -- Draggable with Shift key - searchFrame:RegisterForDrag("LeftButton") - searchFrame:SetScript("OnDragStart", function(self) - if IsShiftKeyDown() then - self:StartMoving() +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 = "" + 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 + +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) + Utils.SafeCallMethod(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) - searchFrame:SetScript("OnDragStop", function(self) - self:StopMovingOrSizing() - -- Save position - local point, _, relPoint, x, y = self:GetPoint() - EasyFind.db.uiSearchPosition = {point, relPoint, x, y} + 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 - -- Apply saved scale - self:UpdateScale() - self:UpdateOpacity() +function UI:ReleaseCalculatorCopyBox(source) + if source and UI._calculator.copySource ~= source then return end - -- 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 + UI._calculator.copyToken = (UI._calculator.copyToken or 0) + 1 - local function GetEffectiveAlpha() - if moveFading then return MOVE_FADE_FACTOR end - return 1.0 + local prev = UI._calculator.prevFocus + local box = UI._calculator.copyBox + local restoreFocus = UI._calculator.copyFocused + or UI._calculator.copySource ~= nil + or UI._calculator.prevFocus ~= nil + or (box and box.HasFocus and box:HasFocus()) + + UI._calculator.copySource = nil + UI._calculator.releasingCopyBox = true + if box then + box:HighlightText(0, 0) + box:ClearFocus() end - searchFrame.getEffectiveAlpha = GetEffectiveAlpha + UI._calculator.releasingCopyBox = nil + UI._calculator.copyFocused = nil - -- Smart Show: invisible hover zone that triggers show/hide - local hoverZone = CreateFrame("Frame", "EasyFindHoverZone", UIParent) - hoverZone:SetFrameStrata("MEDIUM") - hoverZone:SetFrameLevel(searchFrame:GetFrameLevel() - 1) - hoverZone:EnableMouse(true) - hoverZone:SetSize(340, 76) -- larger than the search bar to catch the mouse nearby - hoverZone:SetPoint("CENTER", searchFrame, "CENTER", 0, 0) - hoverZone:Hide() - searchFrame.hoverZone = hoverZone + UI._calculator.prevFocus = nil + if not restoreFocus then return end + if not (prev and prev.SetFocus and prev.IsVisible and prev:IsVisible()) then + prev = searchFrame and searchFrame.editBox + end + if prev and (prev.blockFocus or prev._dragMoving) then + prev = nil + end + if prev and prev.SetFocus and prev.IsVisible and prev:IsVisible() then + prev:SetFocus() + end +end - -- Track whether the mouse is over the zone or the bar - local smartShowVisible = false - local smartShowTimer = nil +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 + 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 - local function SmartShowFadeIn() - if smartShowTimer then smartShowTimer:Cancel(); smartShowTimer = nil end - if EasyFind.db.visible == false then return end - if not smartShowVisible then - smartShowVisible = true - UIFrameFadeIn(searchFrame, 0.15, searchFrame:GetAlpha(), GetEffectiveAlpha()) - searchFrame:Show() - 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 + local ok = self:CopyCalculatorResult(result, source or "active") + if ok and source == "ctrl" then + self:SuspendCalculatorNavForCopy() end + return ok +end - local function SmartShowFadeOut() - 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 - if smartShowTimer then smartShowTimer:Cancel() end - smartShowTimer = C_Timer.NewTimer(0.4, function() - smartShowTimer = nil - -- Re-check conditions after the delay - if searchFrame.editBox:HasFocus() or searchFrame.editBox:GetText() ~= "" then return end - if resultsFrame and resultsFrame:IsShown() then return end - if hoverZone:IsMouseOver() or searchFrame:IsMouseOver() then return end - smartShowVisible = false - UIFrameFadeOut(searchFrame, 0.25, searchFrame:GetAlpha(), 0) - C_Timer.After(0.25, function() - if not smartShowVisible and EasyFind.db.smartShow then - searchFrame:SetAlpha(0) - end - end) - 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 - hoverZone:SetScript("OnEnter", SmartShowFadeIn) - hoverZone:SetScript("OnLeave", SmartShowFadeOut) - searchFrame:HookScript("OnEnter", function() - if EasyFind.db.smartShow then SmartShowFadeIn() end - end) - searchFrame:HookScript("OnLeave", function() - if EasyFind.db.smartShow then SmartShowFadeOut() end - 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 - searchFrame.smartShowFadeIn = SmartShowFadeIn - searchFrame.smartShowFadeOut = SmartShowFadeOut - searchFrame.smartShowVisible = function() return smartShowVisible end - searchFrame.setSmartShowVisible = function(val) smartShowVisible = val end +function UI:EnsureCalculatorCopyWatcher() + if UI._calculator.copyWatcher then return UI._calculator.copyWatcher 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 + 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 EasyFind.db.staticOpacity then - if moveFading then - moveFading = false - self:SetAlpha(1.0) - 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) + Utils.SafeOnUpdate(frame, 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 - if EasyFind.db.smartShow and not smartShowVisible then 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 + 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 - if shouldFade ~= moveFading then - moveFading = shouldFade - UIFrameFadeRemoveFrame(self) - self:SetAlpha(GetEffectiveAlpha()) + 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") + Utils.SafeAfter(0, function() + UI:ConfirmCalculatorCopied() + end) end + UI._calculator.copyKeyWasDown = copyDown and true or nil end) - - -- UI search filter dropdown - self:CreateUIFilterDropdown(filterBtn, searchFrame, editBox) + UI._calculator.copyWatcher = frame + return frame end -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 = 132599 }, -- PetJournalPortrait (Inv_Box_PetCarrier_01) - { key = "outfits", label = "Outfits", iconTex = 132649 }, -- INV_Chest_Cloth_17 - { key = "loot", label = "Loot", iconTex = 132281 }, -- INV_Sword_04 -} - -function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) - local ROW_HEIGHT = 20 - local DROPDOWN_WIDTH = 207 - local PADDING_TOP = 8 - local PADDING_BOTTOM = 8 - local CHECK_SIZE = 16 - - local dropdown = CreateFrame("Frame", "EasyFindUIFilterDropdown", UIParent, "BackdropTemplate") - dropdown:SetFrameStrata("FULLSCREEN_DIALOG") - dropdown:SetFrameLevel(9999) - dropdown:Hide() - dropdown:EnableMouse(true) - dropdown:SetClampedToScreen(true) - - dropdown:SetBackdrop({ - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", - edgeFile = TOOLTIP_BORDER, - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 }, - }) - - 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" - local POPUP_BG_COLOR = { 0.05, 0.05, 0.05, 0.95 } - local POPUP_BORDER_COLOR = { 0.6, 0.6, 0.6, 1 } - local POPUP_BACKDROP = { - bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", - edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 14, - insets = { left = 3, right = 3, top = 3, bottom = 3 }, - } - local ROW_HIGHLIGHT_COLOR = { 1, 1, 1, 0.1 } +function UI:StartCalculatorCopyWatcher() + local frame = self:EnsureCalculatorCopyWatcher() + frame:Show() +end - local function StylePopup(frame) - frame:SetBackdrop(POPUP_BACKDROP) - frame:SetBackdropColor(unpack(POPUP_BG_COLOR)) - frame:SetBackdropBorderColor(unpack(POPUP_BORDER_COLOR)) +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 - -- Creates a single radio texture that swaps between off/on states. - -- Returns the texture and a SetChecked(bool) function. - local function CreateRadioTexture(parent) - local tex = parent:CreateTexture(nil, "ARTWORK") - tex:SetSize(RADIO_SIZE, RADIO_SIZE) - tex:SetTexture(RADIO_OFF_TEX) - local function SetChecked(checked) - tex:SetTexture(checked and RADIO_ON_TEX or RADIO_OFF_TEX) +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 - return tex, SetChecked + 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 - -- "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) - local uncheckLabel = uncheckRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - uncheckLabel:SetPoint("LEFT", 8, 0) - uncheckLabel:SetText("Toggle All") - local uncheckHL = uncheckRow:CreateTexture(nil, "HIGHLIGHT") - uncheckHL:SetAllPoints() - uncheckHL:SetColorTexture(1, 1, 1, 0.1) - - local checkRows = {} - local checkRowsByIndex = {} - local LayoutDropdown -- forward declaration - local dropdownKeyboardMode = false +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 - -- Reusable keyboard nav for popup menus (diff popup, spec popup, class flyout). - -- Uses a single dropdownKeyboardMode flag: when true, any popup hiding returns - -- keyboard to the dropdown. No parent tracking needed. - local function AddPopupKeyboardNav(popup, getRows) - local popupFocus = 0 - local popupHL = popup:CreateTexture(nil, "BACKGROUND") - popupHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) - popupHL:Hide() +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 function SetPopupFocus(idx) - local rows = getRows() - popupFocus = idx - local target = rows[idx] - if target then - popupHL:SetParent(target) - popupHL:ClearAllPoints() - popupHL:SetAllPoints(target) - popupHL:Show() - else - popupHL:Hide() - end + 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 - Utils.SafeCallMethod(popup, "EnableKeyboard", false) - Utils.SafeCallMethod(popup, "SetPropagateKeyboardInput", false) +function UI:ArmCalculatorResultFromRow(row, source) + return self:ArmCalculatorPartFromRow(row, "result", source or "click") +end - popup:HookScript("OnKeyDown", function(self, key) - Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) - local rows = getRows() - if key == "DOWN" then - searchFrame.StartKeyRepeat(key, function() - local r = getRows() - local next = popupFocus + 1 - if next > #r then next = 1 end - SetPopupFocus(next) - end) - elseif key == "UP" then - searchFrame.StartKeyRepeat(key, function() - local r = getRows() - local prev = popupFocus - 1 - if prev < 1 then prev = #r end - SetPopupFocus(prev) - end) - elseif key == "ENTER" then - local target = rows[popupFocus] - if target and target.Click then target:Click() end - elseif key == "ESCAPE" then - self:Hide() - else - Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) - end - end) - popup:HookScript("OnKeyUp", function(_, key) - if searchFrame.IsRepeatKey(key) then searchFrame.StopKeyRepeat() end - 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 - popup:HookScript("OnShow", function(self) - if dropdownKeyboardMode then - -- Disable keyboard on whoever currently has it - Utils.SafeCallMethod(dropdown, "EnableKeyboard", false) - local sp = _G["EasyFindSpecPopup"] - if sp then Utils.SafeCallMethod(sp, "EnableKeyboard", false) end - local cf = _G["EasyFindSpecFlyout"] - if cf then Utils.SafeCallMethod(cf, "EnableKeyboard", false) end - local dp = _G["EasyFindDiffPopup"] - if dp then Utils.SafeCallMethod(dp, "EnableKeyboard", false) end - Utils.SafeCallMethod(self, "EnableKeyboard", true) - SetPopupFocus(1) +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() + else + tex:SetAlpha(0.35) + Utils.SafeAfter(0.35, function() + if tex then + tex:SetAlpha(0.0) + tex:Hide() end end) + end +end - popup:HookScript("OnHide", function(self) - popupFocus = 0 - popupHL:Hide() - Utils.SafeCallMethod(self, "EnableKeyboard", false) - if dropdownKeyboardMode and dropdown:IsShown() then - Utils.SafeCallMethod(dropdown, "EnableKeyboard", true) +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 + 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 end - for i, opt in ipairs(UI_FILTER_OPTIONS) do - local row = CreateFrame("CheckButton", nil, dropdown) - row:SetSize(DROPDOWN_WIDTH - 16, ROW_HEIGHT) - row:SetHitRectInsets(0, 0, 0, 0) - row.optKey = opt.key + if row then + self:SetCalculatorCopyHighlight(row, part) + self:PlayCalculatorCopyFlash(row, part) + end + if popupCopy then + self:UpdateCalculatorPopupCopyVisual(UI._calculator.popupData) + end + Utils.SafeAfter(0, function() + UI:ReleaseCalculatorCopyBox("confirm") + if popupCopy then + UI:RestoreCalculatorPopupFocus() + else + UI:RestoreSearchFocusAfterCalculatorCopy() + end + end) + return true +end - row:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") - row:GetNormalTexture():SetSize(CHECK_SIZE, CHECK_SIZE) - row:GetNormalTexture():ClearAllPoints() - row:GetNormalTexture():SetPoint("LEFT", 4, 0) +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 - row:SetCheckedTexture("Interface\\Buttons\\UI-CheckBox-Check") - row:GetCheckedTexture():SetSize(CHECK_SIZE, CHECK_SIZE) - row:GetCheckedTexture():ClearAllPoints() - row:GetCheckedTexture():SetPoint("LEFT", 4, 0) +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 - local label = row:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - label:SetPoint("LEFT", row:GetNormalTexture(), "RIGHT", 4, 0) - label:SetText(opt.label) - row.label = label +function UI:IsCalculatorCopyConfirmKey(key) + return UI._calculator.activeData + and IsControlKeyDown and IsControlKeyDown() + and (key == "C" or key == "c") +end - if opt.iconAtlas or opt.iconTex then - local icon = row:CreateTexture(nil, "ARTWORK") - icon:SetSize(ICON_SIZE, ICON_SIZE) - icon:SetPoint("RIGHT", -4, 0) - if opt.iconAtlas then - icon:SetAtlas(opt.iconAtlas) - else - icon:SetTexture(opt.iconTex) +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 IsAltKeyDown and IsAltKeyDown() 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 - -- 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 = {} + 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 - 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) +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 + 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 - subRow:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") - subRow:GetNormalTexture():SetSize(CHECK_SIZE, CHECK_SIZE) - subRow:GetNormalTexture():ClearAllPoints() - subRow:GetNormalTexture():SetPoint("LEFT", 4, 0) +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 - subRow:SetCheckedTexture("Interface\\Buttons\\UI-CheckBox-Check") - subRow:GetCheckedTexture():SetSize(CHECK_SIZE, CHECK_SIZE) - subRow:GetCheckedTexture():ClearAllPoints() - subRow:GetCheckedTexture():SetPoint("LEFT", 4, 0) +local function IsSpellbookOnlyAbility(data) + return data and data.category == "Ability" and data.spellID and data.isSpellbookOnly +end - local subLabel = subRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") - subLabel:SetPoint("LEFT", subRow:GetNormalTexture(), "RIGHT", 4, 0) - subLabel:SetText(sub.label) +-- 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). +-- 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 subHL = subRow:CreateTexture(nil, "HIGHLIGHT") - subHL:SetAllPoints() - subHL:SetColorTexture(1, 1, 1, 0.1) +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 - subRow.isLocalKey = sub.key - mapSubRows[si] = subRow + -- Fallback when the panel isn't measurable. + tooltip:SetOwner(ownerFrame, "ANCHOR_RIGHT") +end - 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 +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 - 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 +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 - - -- Wrap the original OnClick to also show/hide sub-rows - local origMapRowIdx = i - row.mapSubRowIdx = origMapRowIdx end + elseif fallbackBag ~= nil then + opened = self:OpenContainerBag(fallbackBag) or opened + end + return opened +end - -- Loot: indented sub-options for search mode and spec toggle - if opt.key == "loot" then - local SUB_INDENT = 24 - 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) - subRow:SetHitRectInsets(0, 0, 0, 0) +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 - subRow:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") - subRow:GetNormalTexture():SetSize(CHECK_SIZE, CHECK_SIZE) - subRow:GetNormalTexture():ClearAllPoints() - subRow:GetNormalTexture():SetPoint("LEFT", 4, 0) +UI.NON_EQUIP_LOCS = { + INVTYPE_NON_EQUIP = true, + INVTYPE_NON_EQUIP_IGNORE = true, + INVTYPE_AMMO = true, + INVTYPE_QUIVER = true, +} - subRow:SetCheckedTexture("Interface\\Buttons\\UI-CheckBox-Check") - subRow:GetCheckedTexture():SetSize(CHECK_SIZE, CHECK_SIZE) - subRow:GetCheckedTexture():ClearAllPoints() - subRow:GetCheckedTexture():SetPoint("LEFT", 4, 0) +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, +} - local subLabel = subRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") - subLabel:SetPoint("LEFT", subRow:GetNormalTexture(), "RIGHT", 4, 0) - subLabel:SetText(sub.label) +function UI:IsRealEquipLoc(slot) + return type(slot) == "string" + and self.EQUIP_LOCS[slot] == true + and not self.NON_EQUIP_LOCS[slot] +end - local subHL = subRow:CreateTexture(nil, "HIGHLIGHT") - subHL:SetAllPoints() - subHL:SetColorTexture(1, 1, 1, 0.1) +function UI:GetItemEquipLoc(itemID) + local getItemInfoInstant = (C_Item and C_Item.GetItemInfoInstant) or GetItemInfoInstant + if not getItemInfoInstant then return nil end - subRow.dbKey = sub.dbKey - lootSubRows[si] = subRow + local info, _, _, equipLoc = getItemInfoInstant(itemID) + if type(info) == "table" then + return info.itemEquipLoc or info.equipLoc or info.inventoryType + end + return equipLoc +end - subRow:SetScript("OnClick", function(self) - EasyFind.db[sub.dbKey] = self:GetChecked() - if searchEditBox:GetText() ~= "" then - UI:OnSearchTextChanged(searchEditBox:GetText()) - end - end) - end +function UI:GetBagItemActionKind(data) + if not data or not data.itemID or data.category ~= "Bag" then return nil end - -- Separator line under Loot checkbox - local lootSep = dropdown:CreateTexture(nil, "ARTWORK") - lootSep:SetHeight(1) - lootSep:SetColorTexture(0.5, 0.5, 0.5, 0.4) - lootSep:Hide() - row.lootSep = lootSep + -- 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 - -- Difficulty dropdown (single-select, matches EJ style) - local DIFF_OPTIONS = { - { key = "lfr", label = "Raid Finder" }, - { key = "normal", label = "Normal" }, - { key = "heroic", label = "Heroic" }, - { key = "mythic", label = "Mythic" }, - } - local DIFF_LABELS = { lfr = "Raid Finder", normal = "Normal", heroic = "Heroic", mythic = "Mythic" } + local 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 - local diffBtn = CreateFrame("Button", nil, dropdown) - diffBtn:SetSize(120, 27) - local diffBg = diffBtn:CreateTexture(nil, "BACKGROUND") - diffBg:SetAtlas("common-dropdown-textholder") - diffBg:SetAllPoints() - local diffArrow = diffBtn:CreateTexture(nil, "OVERLAY") - diffArrow:SetAtlas("common-dropdown-a-button-hover") - diffArrow:SetSize(20, 20) - diffArrow:SetPoint("RIGHT", -2, -1) - diffArrow:SetVertexColor(0.7, 0.7, 0.7) - local diffText = diffBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - diffText:SetPoint("LEFT", 8, 0) - diffText:SetPoint("RIGHT", diffArrow, "LEFT", -2, 0) - diffText:SetJustifyH("LEFT") - diffText:SetWordWrap(false) - diffBtn:SetScript("OnEnter", function() - diffArrow:SetVertexColor(1, 1, 1) - end) - diffBtn:SetScript("OnLeave", function() - diffArrow:SetVertexColor(0.7, 0.7, 0.7) - end) - diffBtn:Hide() + 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 - local function UpdateDiffLabel() - local key = EasyFind.db.lootDifficulty or "normal" - diffText:SetText(DIFF_LABELS[key] or "Normal") - end + return "show" +end - -- Difficulty popup menu - local diffPopup = CreateFrame("Frame", "EasyFindDiffPopup", UIParent, "BackdropTemplate") - diffPopup:SetFrameStrata("TOOLTIP") - StylePopup(diffPopup) - diffPopup:EnableMouse(true) - diffPopup:Hide() +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 + 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 | 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 + 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 + 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 == "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 + return "Select to open settings menu" + end + return nil +end - local diffPopupRows = {} - local py = -6 - for _, def in ipairs(DIFF_OPTIONS) do - local dRow = CreateFrame("Button", nil, diffPopup) - dRow:SetSize(130, 20) - dRow:SetPoint("TOPLEFT", 8, py) - local radio, setRadioChecked = CreateRadioTexture(dRow) - radio:SetPoint("LEFT", 0, 0) - local dLabel = dRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - dLabel:SetPoint("LEFT", radio, "RIGHT", 4, 0) - dLabel:SetText(def.label) - local dHL = dRow:CreateTexture(nil, "HIGHLIGHT") - dHL:SetAllPoints() - dHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) - dRow._diffKey = def.key - dRow._setRadioChecked = setRadioChecked - dRow:SetScript("OnClick", function() - EasyFind.db.lootDifficulty = def.key - UpdateDiffLabel() - diffPopup:Hide() - if ns.Database and ns.Database.PopulateDynamicLoot then - ns.Database:PopulateDynamicLoot() - end - if searchEditBox:GetText() ~= "" then - UI:OnSearchTextChanged(searchEditBox:GetText()) - end - end) - diffPopupRows[#diffPopupRows + 1] = dRow - py = py - 20 - end - diffPopup:SetSize(146, -py + 6) +-- Tracks the row currently displaying an action hint so we can restore +-- its normal subtext when selection moves away. +local actionHintRow - local function SyncDiffRadios() - local key = EasyFind.db.lootDifficulty or "normal" - for _, dr in ipairs(diffPopupRows) do - dr._setRadioChecked(dr._diffKey == key) - end - end +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 - diffBtn:SetScript("OnClick", function() - if diffPopup:IsShown() then - diffPopup:Hide() - else - SyncDiffRadios() - diffPopup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) - diffPopup:ClearAllPoints() - diffPopup:SetPoint("TOPLEFT", diffBtn, "BOTTOMLEFT", 0, 2) - diffPopup:Show() - end - end) - diffPopup:SetScript("OnShow", function(self) - self:RegisterEvent("GLOBAL_MOUSE_DOWN") - end) - diffPopup:SetScript("OnHide", function(self) - self:UnregisterEvent("GLOBAL_MOUSE_DOWN") - end) - diffPopup:SetScript("OnEvent", function(self, event) - if event == "GLOBAL_MOUSE_DOWN" then - if not self:IsMouseOver() and not diffBtn:IsMouseOver() then - self:Hide() - end - end - end) +-- 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 - AddPopupKeyboardNav(diffPopup, function() return diffPopupRows end) +local GetAllPins = UIPins.GetAll +local IsUIItemPinned = UIPins.IsPinned +local PinUIItem = UIPins.Pin +local UnpinUIItem = UIPins.Unpin +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 +-- "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 + + 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 - row.diffBtn = diffBtn - row.diffPopup = diffPopup - row.UpdateDiffButtons = function() - UpdateDiffLabel() - end + if DressUpTransmogSet then + DressUpTransmogSet(allIDs) + end +end - -- Build class/spec data - local CLASS_COLORS = RAID_CLASS_COLORS - local allClassSpecs = {} - for classIdx = 1, GetNumClasses() do - local className, classFile, classID = GetClassInfo(classIdx) - if className then - local specs = {} - for specIdx = 1, GetNumSpecializationsForClassID(classID) do - local sid, sname, _, sicon = GetSpecializationInfoForClassID(classID, specIdx) - if sid then - specs[#specs + 1] = { specID = sid, specName = sname, specIcon = sicon } - end - end - if #specs > 0 then - allClassSpecs[#allClassSpecs + 1] = { - classID = classID, className = className, - classFile = classFile, specs = specs, - } - end - end - end +function UI:SyncOutfitPins() + UIPins.SyncOutfits() +end +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 FLYOUT_ROW_H = 20 - local POPUP_WIDTH = 180 - local CLASSFLYOUT_WIDTH = 160 - -- Determine which class to display in the spec popup. - -- Always returns a class (falls back to player's class for "all" or nil). - local function GetSelectedClass() - local lf = EasyFind.db.lootFilter - if type(lf) == "table" and lf.classID then - for _, cls in ipairs(allClassSpecs) do - if cls.classID == lf.classID then return cls end +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 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 - -- Default: player's class - local _, _, playerClassID = UnitClass("player") - for _, cls in ipairs(allClassSpecs) do - if cls.classID == playerClassID then return cls end - end - return allClassSpecs[1] end + 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 - -- Apply single selection and rebuild from cache - local function ApplyFilterSelection() - if ns.Database then - if ns.Database.PopulateDynamicLoot then - ns.Database:PopulateDynamicLoot() - end - ns.Database:SyncEJLootFilter() + local frame = ns.optionsFrame + if isInside(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 isInside(guards[i]) then return true end + end + return false +end + +local unearnedTooltip + +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 searchEditBox:GetText() ~= "" then - UI:OnSearchTextChanged(searchEditBox:GetText()) + if GameTooltip:IsOwned(row) then + GameTooltip:Hide() + end + end + end + end + if ns.MapSearch and ns.MapSearch.ClearUIPreview then + ns.MapSearch:ClearUIPreview() + end +end + +local function SetRowIcon(btn, kind, value, iconSize) + local sz = iconSize or 16 + btn.icon:SetTexture(nil) + btn.icon:SetTexCoord(0, 1, 0, 1) + btn.icon:SetVertexColor(1, 1, 1, 1) + -- Clear mount/toy/pet tooltip data and cooldown from previous render + btn.icon.mountID = nil + btn.icon.toyItemID = nil + 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 + if kind == "atlas" then + btn.icon:SetAtlas(value) + elseif kind == "file" or kind == "path" then + if type(value) == "table" and value.file then + btn.icon:SetTexture(value.file) + if value.coords then + local c = value.coords + btn.icon:SetTexCoord(c[1], c[2], c[3], c[4]) + end + else + btn.icon:SetTexture(value) + end + elseif kind == "hidden" then + btn.icon:Hide() + return + end + btn.icon:SetSize(sz, sz) + btn.icon:Show() +end + +local THEMES = {} + +THEMES["Modern"] = { + rowHeight = 20, + indentPx = 20, -- matches INDENT_PX so tree lines align + lineWidth = 2, + resultsWidth = 350, + resultsPadTop = 8, + resultsPadBot = 8, + resultsPadLeft = 12, + btnWidth = 366, + iconSize = 15, + pathIconSize = 13, + pathFont = ns.SEARCHBAR_FONT, + leafFont = ns.LEAF_FONT, + pathColor = {0.65, 0.60, 0.55, 1.0}, -- muted gray-tan (normal state) + 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 = false, + indentColors = { + {0.85, 0.65, 0.15, 0.80}, + {0.85, 0.65, 0.15, 0.80}, + {0.85, 0.65, 0.15, 0.80}, + {0.85, 0.65, 0.15, 0.80}, + {0.85, 0.65, 0.15, 0.80}, + {0.85, 0.65, 0.15, 0.80}, + }, + expandIcon = "Interface\\Buttons\\UI-PlusButton-Up", + collapseIcon = "Interface\\Buttons\\UI-MinusButton-Up", + highlightTex = "Interface\\QuestFrame\\UI-QuestTitleHighlight", + selectionColor = {0.25, 0.5, 0.9, 0.35}, + showHeaderBar = false, + showHeaderTab = false, + headerTabAtlas = "QuestLog-tab", -- WoW atlas for tab background + headerHighlightAlpha = 0.40, -- highlight layer alpha + 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) + showSeparators = false, + separatorColor = {0.5, 0.45, 0.3, 0.35}, + -- results backdrop - grey tooltip border, quest log background + resultsBackdrop = { + edgeFile = TOOLTIP_BORDER, + edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 }, + }, + 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 + searchBarRounded = true, -- rounded Common-Input-Border style +} + +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 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() + 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.calcExpressionText, "GameFontHighlightLarge") + ScaleFont(row.calcArrowText, "GameFontHighlight") + 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") + if UI.LayoutResultShortcut then + UI:LayoutResultShortcut(row) + end +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" + and UI:GetBagItemActionKind(data) ~= "show")) +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: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() + unearnedTooltip = CreateFrame("Frame", "EasyFindUnearnedTooltip", UIParent, "BackdropTemplate") + unearnedTooltip:SetFrameStrata("TOOLTIP") + unearnedTooltip:SetFrameLevel(9999) + unearnedTooltip:SetClampedToScreen(true) + + unearnedTooltip:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = TOOLTIP_BORDER, + tile = true, tileSize = 16, edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 } + }) + unearnedTooltip:SetBackdropColor(0, 0, 0, 0.95) + unearnedTooltip:SetBackdropBorderColor(0.6, 0.6, 0.6, 1) + + 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 + + local textWidth = text:GetStringWidth() + local textHeight = text:GetStringHeight() + unearnedTooltip:SetSize(textWidth + 20, textHeight + 16) -- Add padding + + unearnedTooltip:Hide() +end + +function UI:Initialize() + self:CreateUnearnedTooltip() + self:CreateSearchFrame() + self:CreateResultsFrame() + self:RegisterCombatEvents() + self:HookBlizzardFilterChanges() + + if EasyFind.db.autoHide then + searchFrame:Hide() + elseif EasyFind.db.visible ~= false then + searchFrame:Show() + if EasyFind.db.smartShow then + searchFrame.hoverZone:Show() + searchFrame:SetAlpha(0) + searchFrame.setSmartShowVisible(false) + end + else + searchFrame:Hide() + if EasyFind.db.smartShow then + searchFrame.hoverZone:Show() + end + end + + inCombat = InCombatLockdown() + if inCombat then + searchFrame:Hide() + end + + self:UpdateScale() + self:UpdateWidth() + self:UpdateFontSize() + + -- Block auto-focus on creation - WoW may focus visible EditBoxes after creation. + -- Block for two frames (enough for WoW's auto-focus to fire and get rejected). + searchFrame.editBox.blockFocus = true + searchFrame.editBox:ClearFocus() + C_Timer.After(0, function() + C_Timer.After(0, function() + if searchFrame and searchFrame.editBox then + searchFrame.editBox.blockFocus = nil + searchFrame.editBox:ClearFocus() + end + end) + 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 + +end + +function UI:WarmSearchHotPath() + for i = 1, MAX_BUTTON_POOL do + EnsureResultButton(i):Hide() + end +end + +function UI:RegisterCombatEvents() + ns.eventFrame:RegisterEvent("PLAYER_REGEN_DISABLED") + ns.eventFrame:RegisterEvent("PLAYER_REGEN_ENABLED") + ns.eventFrame:HookScript("OnEvent", function(self, event) + if event == "PLAYER_REGEN_DISABLED" then + inCombat = true + searchFrame:Hide() + searchFrame.hoverZone:Hide() + UI:HideResults() + searchFrame.editBox:ClearFocus() + elseif event == "PLAYER_REGEN_ENABLED" then + inCombat = false + -- 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 + end + end) +end + +function UI:CreateSearchFrame() + searchFrame = CreateFrame("Frame", "EasyFindSearchFrame", UIParent, "BackdropTemplate") + UI.searchFrame = searchFrame + 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 + -- top instead of getting buried. + searchFrame:SetFrameStrata("FULLSCREEN_DIALOG") + searchFrame:SetMovable(true) + searchFrame:EnableMouse(true) + searchFrame:SetClampedToScreen(true) + + if EasyFind.db.uiSearchPosition then + local pos = EasyFind.db.uiSearchPosition + searchFrame:SetPoint(pos[1], UIParent, pos[2], pos[3], pos[4]) + else + local point, relPoint, x, y = self:GetDefaultSearchBarPoint() + searchFrame:SetPoint(point, UIParent, relPoint, x, y) + end + + local theme = GetActiveTheme() + 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("FULLSCREEN_DIALOG") + 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()) + self:ApplySearchWindowFill(containerFrame) + + -- 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 + + 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, ns.SEARCH_WINDOW_ALPHA or 0.95) + + -- Static magnifying-glass icon (non-interactive, flush left) + local contentSz = barH * ns.SEARCHBAR_FILL + local iconSz = contentSz * ns.SEARCHBAR_ICON_SCALE + + 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 + searchFrame.modeBtn = iconHolder + searchFrame.iconHolder = iconHolder + + local editBox = CreateFrame("EditBox", "EasyFindSearchBox", searchFrame) + editBox:SetHeight(contentSz) + editBox:SetPoint("LEFT", iconHolder, "RIGHT", 0, 0) + editBox:SetPoint("RIGHT", searchFrame, "RIGHT", -8, 0) + editBox:SetFontObject(ns.SEARCHBAR_FONT) + editBox:SetAutoFocus(false) + editBox:SetMaxLetters(50) + + -- 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) + 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("Type to search...") + editBox.placeholder = placeholder + + editBox:SetScript("OnEditFocusGained", function(self) + if self.blockFocus then + self:ClearFocus() + return + end + if EasyFind.EnsureDynamicLoaded then EasyFind:EnsureDynamicLoaded() end + if selectedIndex > 0 then + selectedIndex = 0 + toggleFocused = false + UI:UpdateSelectionHighlight(true) + end + 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 / + -- 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. + 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 + -- Shift-drag deliberately clears focus while blockFocus is set so + -- the editbox does not steal the drag. Do not run the normal + -- click-outside cleanup path from that synthetic focus loss. + if self._dragMoving then + self:HighlightText(0, 0) + 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 and IsOptionsSurfaceMouseOver() 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 Utils.IsFrameOrChildMouseOver(g) 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() + -- Defer hide by one frame so pending pin/result clicks (LeftButtonDown) + -- can fire before the results frame is hidden. Without the delay the + -- parent frame hides and the child button never receives its OnClick. + C_Timer.After(0, function() + if selectingResult then return end + 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() + if searchFrame.editBox.blockFocus then + searchFrame.editBox.blockFocus = nil + end + return + end + end + -- Don't hide if spec/class flyouts are open + local sf = _G["EasyFindSpecFlyout"] + local ssf = _G["EasyFindSpecSubFlyout"] + 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 + searchFrame.smartShowFadeOut() + end + end) + end + end) + + local lastTypedLen = 0 + local lastSearchTime = 0 + local pendingUISearchText = "" + local pendingUISearchGrew = false + local pendingUISearchDue = 0 + local pendingUISearchFrame = CreateFrame("Frame") + pendingUISearchFrame:Hide() + Utils.SafeOnUpdate(pendingUISearchFrame, 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 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() == "") + -- 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 + if self:GetText() == "" and self.ResetPendingSearch then + self:ResetPendingSearch() + end + return + end + 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 + -- 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 + local elapsed = GetTime() - lastSearchTime + local delay = elapsed >= SEARCH_THROTTLE and 0 or (SEARCH_THROTTLE - elapsed) + pendingUISearchText = typedNow + pendingUISearchGrew = grew + pendingUISearchDue = GetTime() + delay + pendingUISearchFrame:Show() + end) + + editBox:SetScript("OnEnterPressed", function(self) + -- 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 restores the default + -- position and size, /resize opens the drag-to-resize overlay. + if typed:sub(1, 1) == "/" then + UI:RunSearchBarCommand(typed) + return + end + + if typed ~= "" then + UI:PushSearchHistory(typed) + end + historyIndex = 0 + historyDraft = "" + + -- 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 + 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() + local row = resultButtons[idx] + if row and row.data and row.data.calculatorResult then + UI:ArmCalculatorResultFromRow(row, "key") + end + return + end + UI:ActivateSelected("key") + end) + + editBox:SetScript("OnEscapePressed", function(_) + UI:HandleEscape() + 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. + -- 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 + 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 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 + local prefix = nm:sub(1, #typed):lower() + if prefix == lower and nm:lower() ~= lower then + return nm + end + end + 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 + 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, + }) + + -- 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 + + local filterBtn = CreateFrame("Button", "EasyFindUIFilterButton", searchFrame) + filterBtn:SetPoint("TOP", searchFrame, "TOP", 0, 0) + filterBtn:SetPoint("BOTTOM", searchFrame, "BOTTOM", 0, 0) + filterBtn:SetPoint("RIGHT", searchFrame, "RIGHT", 0, 0) + filterBtn:SetWidth(searchFrame:GetHeight()) + -- 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) + filterArrow:SetPoint("CENTER") + filterArrow:SetTexture(423808) + filterArrow:SetTexCoord(0.453, 0.203, 0.453, 0.016, 0.641, 0.203, 0.641, 0.016) + filterArrow:SetDesaturated(true) + filterArrow:SetBlendMode("ADD") + filterArrow:SetVertexColor(1, 1, 1) + filterBtn.arrow = filterArrow + + local filterBtnBg = filterBtn:CreateTexture(nil, "ARTWORK") + filterBtnBg:SetAllPoints() + filterBtnBg:SetTexture(796424) + filterBtnBg:Hide() + filterBtn.btnBg = filterBtnBg + + 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() + SetRingShown(self, false) + end + GameTooltip_Hide() + end) + searchFrame.filterBtn = filterBtn + + 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; + -- 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" or IsShiftKeyDown() or self.setupMode then return end + if (filterBtn and filterBtn:IsMouseOver()) + or (iconHolder and iconHolder:IsMouseOver()) then + return + end + if activeKeybindBtn and activeKeybindBtn._stopCapture then + activeKeybindBtn._stopCapture(activeKeybindBtn) + end + editBox.blockFocus = nil + editBox:SetFocus() + 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.StopKeyRepeat = StopKeyRepeat + searchFrame.IsRepeatKey = keyRepeat.IsKey + + -- Arrow key / Tab navigation for results dropdown. + -- 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) + 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 + 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. + 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 + 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 IsAltKeyDown() then + self:AcceptAutocomplete("alt-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. + -- 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 + -- 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 (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) + 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/Alt+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 + else + if key == "DOWN" then UI:MoveSelection(1) end + end + end + -- 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 IsAltKeyDown() then + if key == "J" then + UI:MoveSelection(1) + elseif key == "K" then + UI:MoveSelection(-1) + 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 + local toolbarFocus = 0 + + local toolbarHighlight = CreateFrame("Frame", nil, UIParent) + toolbarHighlight:SetFrameStrata("MEDIUM") + toolbarHighlight:SetFrameLevel(searchFrame:GetFrameLevel() + 100) + 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() + return { filterBtn } + end + + local function SetToolbarFocus(idx) + local prevControls = GetToolbarControls() + local prevTarget = prevControls[toolbarFocus] + 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 + local controls = GetToolbarControls() + local target = controls[idx] + if target then + 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 + 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.ringDisc then prevTarget.ringDisc:Hide() end + if prevTarget.ringInner then prevTarget.ringInner:Hide() end + if prevTarget.UnlockHighlight then prevTarget:UnlockHighlight() end + end + toolbarFocus = 0 + toolbarHighlight:Hide() + end + searchFrame.ClearToolbarFocus = ClearToolbarFocus + + -- 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) + + -- Cycle focus through editBox → [clearBtn] → filterBtn → row → + -- toggle button (chevron / pin toggle) and back. Shared by Tab, + -- Shift+Tab, and the Alt+L / Alt+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.blockFocus = nil + 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.blockFocus = nil + 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 + + 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 + + -- 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 IsAltKeyDown() and key == "J" then + if IsShiftKeyDown() then + UI:JumpToNextSection(1) + else + StartKeyRepeat(key, function() UI:MoveSelection(1) end) + end + return + elseif IsAltKeyDown() and key == "K" then + if IsShiftKeyDown() then + UI:JumpToNextSection(-1) + else + StartKeyRepeat(key, function() UI:MoveSelection(-1) end) + end + return + elseif IsAltKeyDown() and key == "L" then + CycleFocus(false) + return + elseif IsAltKeyDown() and key == "H" then + CycleFocus(true) + return + end + + if key == "DOWN" then + if IsControlKeyDown() then + UI:JumpToEnd() + elseif IsShiftKeyDown() then + UI:JumpToNextSection(1) + else + StartKeyRepeat(key, function() UI:MoveSelection(1) end) + end + elseif key == "UP" then + if IsControlKeyDown() then + UI:JumpToStart() + elseif IsShiftKeyDown() then + UI:JumpToNextSection(-1) + 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 + StartKeyRepeat(key, function() UI:MoveSelection(-5) end) + elseif key == "HOME" then + UI:JumpToStart() + elseif key == "END" then + 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 + local controls = GetToolbarControls() + local target = controls[toolbarFocus] + if target then target:Click() end + else + UI:ActivateSelected("key") + end + elseif key == "ESCAPE" 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() + end + if selectedIndex > 0 or toggleFocused then + selectedIndex = 0 + toggleFocused = false + 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. + if selectedIndex == 0 and not searchFrame.editBox:HasFocus() then + 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() + end + 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") + Utils.SafeAfter(0, function() + 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) + 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 + -- 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 + or (rd.spellID and not IsSpellbookOnlyAbility(rd)) + or rd.mountID or rd.macroIndex or rd.slashCommand + or (rd.itemID and rd.category == "Bag")) then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) + return + end + end + -- 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 UI:IsCalculatorCopyKey(key) then + consume = true + 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: + -- 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 + end) + + -- 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() + -- 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 + -- 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 / + -- 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 + -- 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 EasyFind.db.lockPosition then return end + self:StartMoving() + end) + searchFrame:SetScript("OnDragStop", function(self) + self:StopMovingOrSizing() + local point, _, relPoint, x, y = self:GetPoint() + EasyFind.db.uiSearchPosition = {point, relPoint, x, y} + end) + + self:UpdateScale() + self:UpdateOpacity() + + local function GetEffectiveAlpha() + return 1.0 + end + searchFrame.getEffectiveAlpha = GetEffectiveAlpha + + -- Smart Show: invisible hover zone that triggers show/hide + local hoverZone = CreateFrame("Frame", "EasyFindHoverZone", UIParent) + hoverZone:SetFrameStrata("MEDIUM") + hoverZone:SetFrameLevel(searchFrame:GetFrameLevel() - 1) + hoverZone:EnableMouse(true) + hoverZone:SetSize(340, 76) -- larger than the search bar to catch the mouse nearby + hoverZone:SetPoint("CENTER", searchFrame, "CENTER", 0, 0) + hoverZone:Hide() + searchFrame.hoverZone = hoverZone + + local smartShowVisible = false + local smartShowTimer = nil + + local function SmartShowFadeIn() + if smartShowTimer then smartShowTimer:Cancel(); smartShowTimer = nil end + if EasyFind.db.visible == false then return end + if not smartShowVisible then + smartShowVisible = true + UIFrameFadeIn(searchFrame, 0.15, searchFrame:GetAlpha(), GetEffectiveAlpha()) + searchFrame:Show() + end + end + + local function SmartShowFadeOut() + 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 + 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. 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) + C_Timer.After(0.25, function() + if not smartShowVisible and EasyFind.db.smartShow then + searchFrame:SetAlpha(0) + end + end) + end) + end + + hoverZone:SetScript("OnEnter", SmartShowFadeIn) + hoverZone:SetScript("OnLeave", SmartShowFadeOut) + searchFrame:HookScript("OnEnter", function() + if EasyFind.db.smartShow then SmartShowFadeIn() end + end) + searchFrame:HookScript("OnLeave", function() + if EasyFind.db.smartShow then SmartShowFadeOut() end + end) + + searchFrame.smartShowFadeIn = SmartShowFadeIn + 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 + + self:CreateUIFilterDropdown(filterBtn, searchFrame, editBox) + + searchFrame:HookScript("OnShow", function(self) + 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") + UI:HideQuickFilterSuggestions() + end) + 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 + -- 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 + -- 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 IsOptionsSurfaceMouseOver() 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 Utils.IsFrameOrChildMouseOver(g) then return end + end + end + local extras = { + _G["EasyFindPinPopup"], + _G["EasyFindUIWizard"], + } + for _, g in ipairs(extras) do + if Utils.IsFrameOrChildMouseOver(g) then return end + end + UI:Hide() + end) + +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 = { + -- 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 }, + 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 = "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, + 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 = "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 = "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, + } }, + -- 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 -- + -- 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 }, hasFlyout = true }, + { key = "map", label = "Map Search", iconTex = 1121272, + 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 = { + { 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 }, + 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-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 } }, +} + +-- 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 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", + ["Achievement"] = "achievements", + ["Achievements"] = "achievements", + ["Achievement Category"] = "achievements", + ["Guild Achievements"] = "achievements", + ["Statistics"] = "statistics", + ["Statistic"] = "statistics", + ["Currency"] = "currencies", + ["Reputation"] = "reputations", + ["Bag"] = "bags", + ["Macro"] = "macros", + ["Talent"] = "talents", + ["Title"] = "titles", + ["Gear Set"] = "gearSets", + ["Game Settings"] = "gameOptions", + ["AddOn Settings"] = "addonOptions", +} + +local function GetUIBucket(d) + -- 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] +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 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 + + 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) + ns.SetRoundedRectFill(frame, r, g, b, a, true) +end + +function UI:HideQuickFilterPillBorder(frame) + ns.SetRoundedRectBorderEdgeShown(frame, false) +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 + 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 + 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. +-- 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.RefreshDynamicCategory then + ns.Database:RefreshDynamicCategory("transmogSets") + 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() + + 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) + + 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) + + 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 + local PADDING_TOP = 8 + 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) + -- 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({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", + edgeFile = TOOLTIP_BORDER, + edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 }, + }) + + local ICON_SIZE = 14 + + local RADIO_SIZE = 14 + local RADIO_OFF_TEX = "Interface\\AddOns\\EasyFind\\radio-off" + local RADIO_ON_TEX = "Interface\\AddOns\\EasyFind\\radio-on" + local POPUP_BG_COLOR = { 0.05, 0.05, 0.05, 0.95 } + local POPUP_BORDER_COLOR = { 0.6, 0.6, 0.6, 1 } + local POPUP_BACKDROP = { + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 14, + insets = { left = 3, right = 3, top = 3, bottom = 3 }, + } + local ROW_HIGHLIGHT_COLOR = { 1, 1, 1, 0.1 } + + local function StylePopup(frame) + frame:SetBackdrop(POPUP_BACKDROP) + frame:SetBackdropColor(unpack(POPUP_BG_COLOR)) + frame:SetBackdropBorderColor(unpack(POPUP_BORDER_COLOR)) + end + + local function CreateRadioTexture(parent) + local tex = parent:CreateTexture(nil, "ARTWORK") + tex:SetSize(RADIO_SIZE, RADIO_SIZE) + tex:SetTexture(RADIO_OFF_TEX) + local function SetChecked(checked) + tex:SetTexture(checked and RADIO_ON_TEX or RADIO_OFF_TEX) + end + return tex, SetChecked + end + + local uncheckRow = CreateFrame("Button", nil, dropdown) + uncheckRow:SetSize(DROPDOWN_WIDTH - 16, ROW_HEIGHT) + uncheckRow:SetPoint("TOPLEFT", 8, -PADDING_TOP) + local uncheckLabel = uncheckRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + uncheckLabel:SetPoint("LEFT", 8, 0) + uncheckLabel:SetText("Toggle All") + local uncheckHL = uncheckRow:CreateTexture(nil, "HIGHLIGHT") + uncheckHL:SetAllPoints() + uncheckHL:SetColorTexture(1, 1, 1, 0.1) + + local checkRows = {} + local checkRowsByIndex = {} + local LayoutDropdown -- forward declaration + local dropdownKeyboardMode = false + + -- Reusable keyboard nav for popup menus (diff popup, spec popup, class flyout). + -- Uses a single dropdownKeyboardMode flag: when true, any popup hiding returns + -- keyboard to the dropdown. No parent tracking needed. + local function AddPopupKeyboardNav(popup, getRows) + local popupFocus = 0 + local popupHL = popup:CreateTexture(nil, "BACKGROUND") + popupHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + popupHL:Hide() + + local function SetPopupFocus(idx) + local rows = getRows() + popupFocus = idx + local target = rows[idx] + if target then + popupHL:SetParent(target) + popupHL:ClearAllPoints() + popupHL:SetAllPoints(target) + popupHL:Show() + else + popupHL:Hide() + end + end + + Utils.SafeCallMethod(popup, "EnableKeyboard", false) + Utils.SafeCallMethod(popup, "SetPropagateKeyboardInput", false) + + popup:HookScript("OnKeyDown", function(self, key) + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + local rows = getRows() + if key == "DOWN" then + searchFrame.StartKeyRepeat(key, function() + local r = getRows() + local next = popupFocus + 1 + if next > #r then next = 1 end + SetPopupFocus(next) + end) + elseif key == "UP" then + searchFrame.StartKeyRepeat(key, function() + local r = getRows() + local prev = popupFocus - 1 + if prev < 1 then prev = #r end + SetPopupFocus(prev) + end) + elseif key == "ENTER" then + local target = rows[popupFocus] + if target and target.Click then target:Click() end + elseif key == "ESCAPE" then + -- 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 + end) + popup:HookScript("OnKeyUp", function(_, key) + if searchFrame.IsRepeatKey(key) then searchFrame.StopKeyRepeat() end + end) + + popup:HookScript("OnShow", function(self) + if dropdownKeyboardMode then + Utils.SafeCallMethod(dropdown, "EnableKeyboard", false) + local sp = _G["EasyFindSpecPopup"] + if sp then Utils.SafeCallMethod(sp, "EnableKeyboard", false) end + local cf = _G["EasyFindSpecFlyout"] + if cf then Utils.SafeCallMethod(cf, "EnableKeyboard", false) end + local dp = _G["EasyFindDiffPopup"] + if dp then Utils.SafeCallMethod(dp, "EnableKeyboard", false) end + Utils.SafeCallMethod(self, "EnableKeyboard", true) + SetPopupFocus(1) + end + end) + + popup:HookScript("OnHide", function(self) + popupFocus = 0 + popupHL:Hide() + Utils.SafeCallMethod(self, "EnableKeyboard", false) + if dropdownKeyboardMode and dropdown:IsShown() then + Utils.SafeCallMethod(dropdown, "EnableKeyboard", true) + end + end) + end + + for i, opt in ipairs(UI_FILTER_OPTIONS) do + -- 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(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) + row:GetNormalTexture():ClearAllPoints() + row:GetNormalTexture():SetPoint("LEFT", 4, 0) + + row:SetCheckedTexture("Interface\\Buttons\\UI-CheckBox-Check") + row:GetCheckedTexture():SetSize(CHECK_SIZE, CHECK_SIZE) + row:GetCheckedTexture():ClearAllPoints() + row:GetCheckedTexture():SetPoint("LEFT", 4, 0) + + -- 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 + icon = row:CreateTexture(nil, "ARTWORK") + icon:SetSize(ICON_SIZE, ICON_SIZE) + 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) + if opt.iconCoords then + icon:SetTexCoord(opt.iconCoords[1], opt.iconCoords[2], + opt.iconCoords[3], opt.iconCoords[4]) + end + end + 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. 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.flyoutRadio or opt.hasFlyout 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 + 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] + -- 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 + -- 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") + ClearActiveFlyout(self) + 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 + 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 subIcon + if sub.iconAtlas or sub.iconTex then + subIcon = subRow:CreateTexture(nil, "ARTWORK") + subIcon:SetSize(SUB_ICON, SUB_ICON) + 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") + subHL:SetAllPoints() + subHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + + subRow:SetScript("OnClick", function(self) + 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 + end) + + 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) + 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 + + -- "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() + for _, sub in ipairs(opt.flyoutSubFilters) do + local sr = subRows[sub.key] + 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 + + -- 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 + SetActiveFlyout(popup) + SyncSubChecks() + popup:SetScale(EasyFind.db.uiSearchScale or 1.0) + PositionPopup() + popup:Show() + 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() + 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 + + -- 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") + 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") + ClearActiveFlyout(self) + 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(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 radio.onChange then radio.onChange(self.value) end + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end) + + radioRows[ri] = rRow + end + + 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() + 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 + + 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 + SetActiveFlyout(popup) + SyncRadio() + popup:SetScale(EasyFind.db.uiSearchScale 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 + -- on hover, like the Collections sub-flyout. + if opt.key == "loot" then + 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" }, + { dbKey = "hideTooltips.loot", label = "Hide tooltips" }, + } + local lootSubRows = {} + for si, sub in ipairs(lootSubDefs) do + 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") + subRow:GetNormalTexture():SetSize(CHECK_SIZE, CHECK_SIZE) + subRow:GetNormalTexture():ClearAllPoints() + subRow:GetNormalTexture():SetPoint("LEFT", 4, 0) + + subRow:SetCheckedTexture("Interface\\Buttons\\UI-CheckBox-Check") + subRow:GetCheckedTexture():SetSize(CHECK_SIZE, CHECK_SIZE) + subRow:GetCheckedTexture():ClearAllPoints() + subRow:GetCheckedTexture():SetPoint("LEFT", 4, 0) + + local subLabel = subRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + subLabel:SetPoint("LEFT", subRow:GetNormalTexture(), "RIGHT", 4, 0) + subLabel:SetText(sub.label) + + local subHL = subRow:CreateTexture(nil, "HIGHLIGHT") + subHL:SetAllPoints() + subHL:SetColorTexture(1, 1, 1, 0.1) + + subRow.dbKey = sub.dbKey + lootSubRows[si] = subRow + + -- 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) + 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 + -- difficulty/spec selectors. + local lootSep = gearOptionsPopup:CreateTexture(nil, "ARTWORK") + lootSep:SetHeight(1) + lootSep:SetColorTexture(0.5, 0.5, 0.5, 0.4) + row.lootSep = lootSep + + -- Difficulty dropdown (single-select, matches EJ style) + local DIFF_OPTIONS = { + { key = "lfr", label = "Raid Finder" }, + { key = "normal", label = "Normal" }, + { key = "heroic", label = "Heroic" }, + { key = "mythic", label = "Mythic" }, + } + local DIFF_LABELS = { lfr = "Raid Finder", normal = "Normal", heroic = "Heroic", mythic = "Mythic" } + + local diffBtn = CreateFrame("Button", nil, gearOptionsPopup) + diffBtn:SetSize(GEAR_POPUP_WIDTH - GEAR_POPUP_PAD * 2, 27) + local diffBg = diffBtn:CreateTexture(nil, "BACKGROUND") + diffBg:SetAtlas("common-dropdown-textholder") + diffBg:SetAllPoints() + local diffArrow = diffBtn:CreateTexture(nil, "OVERLAY") + diffArrow:SetAtlas("common-dropdown-a-button-hover") + diffArrow:SetSize(20, 20) + diffArrow:SetPoint("RIGHT", -2, -1) + diffArrow:SetVertexColor(0.7, 0.7, 0.7) + local diffText = diffBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + diffText:SetPoint("LEFT", 8, 0) + diffText:SetPoint("RIGHT", diffArrow, "LEFT", -2, 0) + diffText:SetJustifyH("LEFT") + diffText:SetWordWrap(false) + diffBtn:SetScript("OnEnter", function() + diffArrow:SetVertexColor(1, 1, 1) + end) + diffBtn:SetScript("OnLeave", function() + diffArrow:SetVertexColor(0.7, 0.7, 0.7) + end) + + local function UpdateDiffLabel() + local key = EasyFind.db.lootDifficulty or "normal" + diffText:SetText(DIFF_LABELS[key] or "Normal") + end + + -- Difficulty popup menu + local diffPopup = CreateFrame("Frame", "EasyFindDiffPopup", UIParent, "BackdropTemplate") + diffPopup:SetFrameStrata("TOOLTIP") + diffPopup:SetFrameLevel(gearOptionsPopup:GetFrameLevel() + 20) + StylePopup(diffPopup) + diffPopup:EnableMouse(true) + diffPopup:Hide() + + local diffPopupRows = {} + local py = -6 + for _, def in ipairs(DIFF_OPTIONS) do + local dRow = CreateFrame("Button", nil, diffPopup) + dRow:SetSize(130, 20) + dRow:SetPoint("TOPLEFT", 8, py) + local radio, setRadioChecked = CreateRadioTexture(dRow) + radio:SetPoint("LEFT", 0, 0) + local dLabel = dRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + dLabel:SetPoint("LEFT", radio, "RIGHT", 4, 0) + dLabel:SetText(def.label) + local dHL = dRow:CreateTexture(nil, "HIGHLIGHT") + dHL:SetAllPoints() + dHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + dRow._diffKey = def.key + dRow._setRadioChecked = setRadioChecked + dRow:SetScript("OnClick", function() + EasyFind.db.lootDifficulty = def.key + UpdateDiffLabel() + diffPopup:Hide() + if ns.Database and ns.Database.RefreshDynamicCategory then + ns.Database:RefreshDynamicCategory("loot") + end + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end) + diffPopupRows[#diffPopupRows + 1] = dRow + py = py - 20 + end + diffPopup:SetSize(146, -py + 6) + + local function SyncDiffRadios() + local key = EasyFind.db.lootDifficulty or "normal" + for _, dr in ipairs(diffPopupRows) do + dr._setRadioChecked(dr._diffKey == key) + end + end + + diffBtn:SetScript("OnClick", function() + if diffPopup:IsShown() then + diffPopup:Hide() + else + SyncDiffRadios() + diffPopup:SetScale(EasyFind.db.uiSearchScale or 1.0) + diffPopup:ClearAllPoints() + diffPopup:SetPoint("TOPLEFT", diffBtn, "BOTTOMLEFT", 0, 2) + diffPopup:Show() + end + end) + diffPopup:SetScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end) + diffPopup:SetScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + end) + diffPopup:SetScript("OnEvent", function(self, event) + if event == "GLOBAL_MOUSE_DOWN" then + if not self:IsMouseOver() and not diffBtn:IsMouseOver() then + self:Hide() + end + end + end) + + AddPopupKeyboardNav(diffPopup, function() return diffPopupRows end) + dropdownGuardFrames[#dropdownGuardFrames + 1] = diffPopup + + row.diffBtn = diffBtn + row.diffPopup = diffPopup + row.UpdateDiffButtons = function() + UpdateDiffLabel() + end + + + local CLASS_COLORS = RAID_CLASS_COLORS + local allClassSpecs = {} + for classIdx = 1, GetNumClasses() do + local className, classFile, classID = GetClassInfo(classIdx) + if className then + local specs = {} + for specIdx = 1, GetNumSpecializationsForClassID(classID) do + local sid, sname, _, sicon = GetSpecializationInfoForClassID(classID, specIdx) + if sid then + specs[#specs + 1] = { specID = sid, specName = sname, specIcon = sicon } + end + end + if #specs > 0 then + allClassSpecs[#allClassSpecs + 1] = { + classID = classID, className = className, + classFile = classFile, specs = specs, + } + end + end + end + + + local FLYOUT_ROW_H = 20 + local POPUP_WIDTH = 180 + local CLASSFLYOUT_WIDTH = 160 + -- Determine which class to display in the spec popup. + -- Always returns a class (falls back to player's class for "all" or nil). + local function GetSelectedClass() + local lf = EasyFind.db.lootFilter + if type(lf) == "table" and lf.classID then + for _, cls in ipairs(allClassSpecs) do + if cls.classID == lf.classID then return cls end + end + end + -- Default: player's class + local _, _, playerClassID = UnitClass("player") + for _, cls in ipairs(allClassSpecs) do + if cls.classID == playerClassID then return cls end + end + return allClassSpecs[1] + end + + local function ApplyFilterSelection() + if ns.Database then + if ns.Database.RefreshDynamicCategory then + ns.Database:RefreshDynamicCategory("loot") + end + ns.Database:SyncEJLootFilter() + end + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) end end @@ -1698,3340 +6141,7482 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local className, classFile = UnitClass("player") local cc = classFile and CLASS_COLORS[classFile] local colorStr = cc and string.format("|cff%02x%02x%02x", cc.r * 255, cc.g * 255, cc.b * 255) or "" - if sname and className then - lbl:SetText(colorStr .. className .. " (" .. sname .. ")|r") - else - lbl:SetText(colorStr .. (className or "Current Spec") .. "|r") + if sname and className then + lbl:SetText(colorStr .. className .. " (" .. sname .. ")|r") + else + lbl:SetText(colorStr .. (className or "Current Spec") .. "|r") + end + elseif lf == "all" then + lbl:SetText("All Classes") + elseif lf.classID then + local cls + for _, c in ipairs(allClassSpecs) do + if c.classID == lf.classID then cls = c; break end + end + if not cls then lbl:SetText("?"); return end + local cc = CLASS_COLORS[cls.classFile] + local colorStr = cc and string.format("|cff%02x%02x%02x", cc.r * 255, cc.g * 255, cc.b * 255) or "" + if lf.specID then + local sname + for _, s in ipairs(cls.specs) do + if s.specID == lf.specID then sname = s.specName; break end + end + lbl:SetText(colorStr .. cls.className .. " (" .. (sname or "?") .. ")|r") + else + lbl:SetText(colorStr .. cls.className .. "|r") + end + end + end + + -- Check if a filter value matches the current lootFilter + local function IsFilterMatch(filterVal) + local lf = EasyFind.db.lootFilter + -- nil lootFilter = current spec; resolve to player class+spec for comparison + if not lf then + if filterVal == nil then return true end + if type(filterVal) == "table" and filterVal.specID then + local _, _, cid = UnitClass("player") + local si = GetSpecialization and GetSpecialization() + local sid = si and GetSpecializationInfo and GetSpecializationInfo(si) + return filterVal.classID == cid and filterVal.specID == sid + end + return false + end + if filterVal == "all" and lf == "all" then return true end + if type(filterVal) == "table" and type(lf) == "table" then + if filterVal.classID == lf.classID then + if filterVal.specID == nil and lf.specID == nil then return true end + if filterVal.specID == lf.specID then return true end + end + end + return false + end + + ------------------------------------------------------------------- + -- Main spec popup (opens BELOW the bar) + -- Layout: "Class >" row, then class header, then specs, then "All Specializations" + ------------------------------------------------------------------- + local specPopup = CreateFrame("Frame", "EasyFindSpecPopup", UIParent, "BackdropTemplate") + specPopup:SetFrameStrata("TOOLTIP") + specPopup:SetFrameLevel(gearOptionsPopup:GetFrameLevel() + 20) + StylePopup(specPopup) + specPopup:EnableMouse(true) + specPopup:Hide() + + ------------------------------------------------------------------- + -- Class flyout (opens to the RIGHT of the "Class" row) + ------------------------------------------------------------------- + local classFlyout = CreateFrame("Frame", "EasyFindSpecFlyout", UIParent, "BackdropTemplate") + classFlyout:SetFrameStrata("TOOLTIP") + classFlyout:SetFrameLevel(gearOptionsPopup:GetFrameLevel() + 30) + StylePopup(classFlyout) + classFlyout:EnableMouse(true) + classFlyout:Hide() + + local LayoutSpecPopup -- forward declaration for closures below + + -- Helper: create a radio-style row + local function CreateRadioRow(parent, label, filterVal, width) + local btn = CreateFrame("Button", nil, parent) + btn:SetSize(width - 16, FLYOUT_ROW_H) + btn:SetFrameLevel(parent:GetFrameLevel() + 10) + local radio, setChecked = CreateRadioTexture(btn) + radio:SetPoint("LEFT", 4, 0) + local lbl = btn:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + lbl:SetPoint("LEFT", radio, "RIGHT", 4, 0) + lbl:SetText(label) + local hl = btn:CreateTexture(nil, "HIGHLIGHT") + hl:SetAllPoints() + hl:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + btn._setRadioChecked = setChecked + btn._filterVal = filterVal + return btn + end + + local classFlyoutRows = {} + local allClassRow = CreateRadioRow(classFlyout, "All Classes", "all", CLASSFLYOUT_WIDTH) + allClassRow:SetScript("OnClick", function() + EasyFind.db.lootFilter = "all" + UpdateSpecLabel() + classFlyout:Hide() + if not classFlyout._keyboardParent then specPopup:Hide() end + ApplyFilterSelection() + if specPopup:IsShown() then LayoutSpecPopup() end + end) + classFlyoutRows[#classFlyoutRows + 1] = allClassRow + for _, cls in ipairs(allClassSpecs) do + local clsRow = CreateRadioRow(classFlyout, "", { classID = cls.classID }, CLASSFLYOUT_WIDTH) + -- Override label with class-colored text + local ccl = CLASS_COLORS[cls.classFile] + local csStr = ccl and string.format("|cff%02x%02x%02x", ccl.r * 255, ccl.g * 255, ccl.b * 255) or "" + local clsLabel = clsRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + clsLabel:SetPoint("LEFT", 22, 0) + clsLabel:SetText(csStr .. cls.className .. "|r") + clsRow:SetScript("OnClick", function() + EasyFind.db.lootFilter = { classID = cls.classID } + UpdateSpecLabel() + classFlyout:Hide() + if not classFlyout._keyboardParent then specPopup:Hide() end + ApplyFilterSelection() + if specPopup:IsShown() then LayoutSpecPopup() end + end) + classFlyoutRows[#classFlyoutRows + 1] = clsRow + end + + local function LayoutClassFlyout() + local fy = -6 + local lvl = classFlyout:GetFrameLevel() + 10 + for _, r in ipairs(classFlyoutRows) do + r:ClearAllPoints() + r:SetPoint("TOPLEFT", classFlyout, "TOPLEFT", 8, fy) + r:SetFrameLevel(lvl) + r:Show() + if r._setRadioChecked then + local lf = EasyFind.db.lootFilter + local match = false + if r._filterVal == "all" and lf == "all" then + match = true + elseif type(r._filterVal) == "table" then + if type(lf) == "table" and r._filterVal.classID == lf.classID then + match = true + elseif not lf then + -- nil = current spec; dot the player's class + local _, _, cid = UnitClass("player") + match = r._filterVal.classID == cid + end + end + r._setRadioChecked(match) + end + fy = fy - FLYOUT_ROW_H + end + classFlyout:SetSize(CLASSFLYOUT_WIDTH, -fy + 6) + end + + ------------------------------------------------------------------- + -- Build spec popup rows (main dropdown below bar) + ------------------------------------------------------------------- + -- Row 1: "Class" with arrow (opens class flyout to the right) + local classSelectBtn = CreateFrame("Button", nil, specPopup) + classSelectBtn:SetSize(POPUP_WIDTH - 16, FLYOUT_ROW_H) + classSelectBtn:SetFrameLevel(specPopup:GetFrameLevel() + 10) + local csLabel = classSelectBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + csLabel:SetPoint("LEFT", 8, 0) + csLabel:SetText("Class") + local csArrow = classSelectBtn:CreateTexture(nil, "ARTWORK") + csArrow:SetSize(16, 16) + csArrow:SetPoint("RIGHT", -4, 0) + csArrow:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") + local csHL = classSelectBtn:CreateTexture(nil, "HIGHLIGHT") + csHL:SetAllPoints() + csHL:SetColorTexture(1, 1, 1, 0.1) + local function OpenClassFlyout() + LayoutClassFlyout() + classFlyout:SetScale(EasyFind.db.uiSearchScale or 1.0) + classFlyout:ClearAllPoints() + classFlyout:SetPoint("TOPLEFT", classSelectBtn, "TOPRIGHT", 2, 6) + classFlyout:Show() + end + classSelectBtn:SetScript("OnEnter", function() OpenClassFlyout() end) + classSelectBtn:SetScript("OnClick", function() OpenClassFlyout() end) + + -- Spec rows (rebuilt each time popup opens based on selected class) + local specRadioRows = {} + local MAX_SPECS = 5 -- druid has 4 + "All Specializations" = 5 + for si = 1, MAX_SPECS do + local sRow = CreateRadioRow(specPopup, "", nil, POPUP_WIDTH) + sRow:Hide() + specRadioRows[si] = sRow + end + + -- Class header (non-clickable, shows selected class name) + local classHeader = CreateFrame("Frame", nil, specPopup) + classHeader:SetSize(POPUP_WIDTH - 16, FLYOUT_ROW_H) + classHeader:SetFrameLevel(specPopup:GetFrameLevel() + 10) + local classHeaderLabel = classHeader:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + classHeaderLabel:SetPoint("LEFT", 8, 0) + + LayoutSpecPopup = function() + local selCls = GetSelectedClass() + local py = -6 + local lvl = specPopup:GetFrameLevel() + 10 + + -- Row 1: "Class >" + classSelectBtn:ClearAllPoints() + classSelectBtn:SetPoint("TOPLEFT", specPopup, "TOPLEFT", 8, py) + classSelectBtn:SetFrameLevel(lvl) + classSelectBtn:Show() + py = py - FLYOUT_ROW_H + + if selCls then + local cc = CLASS_COLORS[selCls.classFile] + local colorStr = cc and string.format("|cff%02x%02x%02x", cc.r * 255, cc.g * 255, cc.b * 255) or "" + classHeaderLabel:SetText(colorStr .. selCls.className .. "|r") + classHeader:ClearAllPoints() + classHeader:SetPoint("TOPLEFT", specPopup, "TOPLEFT", 8, py) + classHeader:SetFrameLevel(lvl) + classHeader:Show() + py = py - FLYOUT_ROW_H + + local ri = 1 + for _, spec in ipairs(selCls.specs) do + local sRow = specRadioRows[ri] + if sRow then + local children = { sRow:GetRegions() } + for _, child in ipairs(children) do + if child:GetObjectType() == "FontString" and child:GetText() ~= "" then + if child:GetPoint() then + local _, rel = child:GetPoint() + if rel and rel:GetObjectType() == "Texture" then + child:SetText(spec.specName) + end + end + end + end + sRow._filterVal = { classID = selCls.classID, specID = spec.specID } + sRow._setRadioChecked(IsFilterMatch(sRow._filterVal)) + sRow:SetScript("OnClick", function() + EasyFind.db.lootFilter = { classID = selCls.classID, specID = spec.specID } + UpdateSpecLabel() + classFlyout:Hide() + specPopup:Hide() + ApplyFilterSelection() + end) + sRow:SetScript("OnEnter", function() + classFlyout:Hide() + end) + sRow:ClearAllPoints() + sRow:SetPoint("TOPLEFT", specPopup, "TOPLEFT", 8, py) + sRow:SetFrameLevel(lvl) + sRow:Show() + py = py - FLYOUT_ROW_H + ri = ri + 1 + end + end + + local allRow = specRadioRows[ri] + if allRow then + local children = { allRow:GetRegions() } + for _, child in ipairs(children) do + if child:GetObjectType() == "FontString" and child:GetText() ~= "" then + if child:GetPoint() then + local _, rel = child:GetPoint() + if rel and rel:GetObjectType() == "Texture" then + child:SetText("All Specializations") + end + end + end + end + allRow._filterVal = { classID = selCls.classID } + allRow._setRadioChecked(IsFilterMatch(allRow._filterVal)) + allRow:SetScript("OnClick", function() + EasyFind.db.lootFilter = { classID = selCls.classID } + UpdateSpecLabel() + classFlyout:Hide() + specPopup:Hide() + ApplyFilterSelection() + end) + allRow:SetScript("OnEnter", function() + classFlyout:Hide() + end) + allRow:ClearAllPoints() + allRow:SetPoint("TOPLEFT", specPopup, "TOPLEFT", 8, py) + allRow:SetFrameLevel(lvl) + allRow:Show() + py = py - FLYOUT_ROW_H + ri = ri + 1 + end + + for hi = ri, MAX_SPECS do + specRadioRows[hi]:Hide() + end + else + classHeader:Hide() + for _, sr in ipairs(specRadioRows) do sr:Hide() end + end + + specPopup:SetSize(POPUP_WIDTH, -py + 6) + end + + 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() + local specSelectArrow = specSelectRow:CreateTexture(nil, "OVERLAY") + specSelectArrow:SetAtlas("common-dropdown-a-button-hover") + specSelectArrow:SetSize(20, 20) + specSelectArrow:SetPoint("RIGHT", -2, -1) + specSelectArrow:SetVertexColor(0.7, 0.7, 0.7) + local specSelectLabel = specSelectRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + specSelectLabel:SetPoint("LEFT", 8, 0) + specSelectLabel:SetPoint("RIGHT", specSelectArrow, "LEFT", -2, 0) + specSelectLabel:SetJustifyH("LEFT") + specSelectLabel:SetWordWrap(false) + + specSelectRow:SetScript("OnEnter", function() + specSelectArrow:SetVertexColor(1, 1, 1) + end) + specSelectRow:SetScript("OnLeave", function() + specSelectArrow:SetVertexColor(0.7, 0.7, 0.7) + end) + specSelectRow:SetScript("OnClick", function() + if specPopup:IsShown() then + specPopup:Hide() + else + LayoutSpecPopup() + specPopup:SetScale(EasyFind.db.uiSearchScale or 1.0) + specPopup:ClearAllPoints() + specPopup:SetPoint("TOPLEFT", specSelectRow, "BOTTOMLEFT", 0, 2) + specPopup:Show() + end + end) + + row.specSelectRow = specSelectRow + row.specSelectLabel = specSelectLabel + + local function GetSpecPopupNavRows() + local rows = { classSelectBtn } + for _, sr in ipairs(specRadioRows) do + if sr:IsShown() then rows[#rows + 1] = sr end + end + return rows + end + + specPopup:SetScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end) + specPopup:SetScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + classFlyout:Hide() + end) + specPopup:SetScript("OnEvent", function(self, event) + if event == "GLOBAL_MOUSE_DOWN" then + if not self:IsMouseOver() + and not classFlyout:IsMouseOver() + and not specSelectRow:IsMouseOver() then + self:Hide() + end + end + end) + + 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 + self._leaveTimer = C_Timer.NewTimer(0.2, function() + self._leaveTimer = nil + if not self:IsMouseOver() and not classSelectBtn:IsMouseOver() then + self:Hide() + end + end) + end + else + if self._leaveTimer then + self._leaveTimer:Cancel() + self._leaveTimer = nil + end + end + end) + + dropdownGuardFrames[#dropdownGuardFrames + 1] = specPopup + dropdownGuardFrames[#dropdownGuardFrames + 1] = classFlyout + + dropdown:HookScript("OnHide", function() + classFlyout:Hide() + specPopup:Hide() + end) + + -- Keyboard nav MUST be added AFTER SetScript calls above + AddPopupKeyboardNav(specPopup, GetSpecPopupNavRows) + AddPopupKeyboardNav(classFlyout, function() return classFlyoutRows end) + + -- Keep EasyFindSpecFlyout/EasyFindSpecSubFlyout names for dropdown close guard + local specFlyout = classFlyout + row.specFlyout = specFlyout + row.allClassSpecs = allClassSpecs + row.lootSubRows = lootSubRows + + 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 + SetActiveFlyout(gearOptionsPopup) + 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 + end + gearOptionsPopup:SetScale(EasyFind.db.uiSearchScale 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() + ClearActiveFlyout(self) + 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.SetChecked and sr.resolveDbPath then + local tbl, leaf = sr.resolveDbPath() + sr:SetChecked(tbl[leaf] == true) + end + end + UpdateSpecLabel() + if row.UpdateDiffButtons then row.UpdateDiffButtons() end + end + end + + local highlight = row:CreateTexture(nil, "HIGHLIGHT") + highlight:SetAllPoints() + highlight:SetColorTexture(1, 1, 1, 0.1) + + local kbHighlight = row:CreateTexture(nil, "BACKGROUND") + kbHighlight:SetAllPoints() + kbHighlight:SetColorTexture(1, 1, 1, 0.1) + kbHighlight:Hide() + row.kbHighlight = kbHighlight + + 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 + LayoutDropdown() + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end) + + checkRows[opt.key] = row + checkRowsByIndex[i] = row + end + + -- Layout: positions all rows including map sub-rows, adjusts dropdown height + local SUB_INDENT = 24 + local dropdownNavRows = {} -- ordered list of navigable rows (rebuilt on layout) + local dropdownFocus = 0 + local dropdownKbHighlight = dropdown:CreateTexture(nil, "BACKGROUND") + dropdownKbHighlight:SetColorTexture(1, 1, 1, 0.1) + dropdownKbHighlight:Hide() + + local function SetDropdownFocus(idx) + dropdownFocus = idx + local target = dropdownNavRows[idx] + if target then + dropdownKbHighlight:SetParent(target) + dropdownKbHighlight:ClearAllPoints() + dropdownKbHighlight:SetAllPoints(target) + dropdownKbHighlight:Show() + else + dropdownKbHighlight:Hide() + end + end + + local function ClearDropdownFocus() + dropdownFocus = 0 + dropdownKbHighlight:Hide() + end + + function LayoutDropdown() + local savedFocus = dropdownFocus + wipe(dropdownNavRows) + dropdownKbHighlight:Hide() + local filters = EasyFind.db.uiSearchFilters + local y = -PADDING_TOP + uncheckRow:ClearAllPoints() + uncheckRow:SetPoint("TOPLEFT", 8, y) + dropdownNavRows[#dropdownNavRows + 1] = uncheckRow + y = y - ROW_HEIGHT + for i, opt in ipairs(UI_FILTER_OPTIONS) do + local row = checkRowsByIndex[i] + local parentVisible = (not opt.parentKey) or (filters[opt.parentKey] ~= false) + if not parentVisible then + row: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 + end + end + dropdown:SetSize(DROPDOWN_WIDTH, -y + PADDING_BOTTOM) + if savedFocus > 0 and dropdown:IsKeyboardEnabled() then + if savedFocus > #dropdownNavRows then savedFocus = #dropdownNavRows end + SetDropdownFocus(savedFocus) + end + end + + Utils.SafeCallMethod(dropdown, "EnableKeyboard", false) + Utils.SafeCallMethod(dropdown, "SetPropagateKeyboardInput", false) + + dropdown:SetScript("OnKeyDown", function(self, key) + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + if key == "DOWN" then + searchFrame.StartKeyRepeat(key, function() + local next = dropdownFocus + 1 + if next > #dropdownNavRows then next = 1 end + SetDropdownFocus(next) + end) + elseif key == "UP" then + if dropdownFocus <= 1 then + self._escapedViaKeyboard = true + self:Hide() + return + end + searchFrame.StartKeyRepeat(key, function() + local prev = dropdownFocus - 1 + if prev < 1 then prev = 1 end + SetDropdownFocus(prev) + end) + elseif key == "ENTER" then + local target = dropdownNavRows[dropdownFocus] + if target and target.Click then + target:Click() + end + elseif key == "ESCAPE" then + self._escapedViaKeyboard = true + -- 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 + end) + dropdown:SetScript("OnKeyUp", function(_, key) + if searchFrame.IsRepeatKey(key) then searchFrame.StopKeyRepeat() end + end) + + -- (keyboard OnShow/OnHide hooks moved after SetScript calls below) + + -- Uncheck All: toggles all checkboxes off, or all back on if already all unchecked + uncheckRow:SetScript("OnClick", function() + local filters = EasyFind.db.uiSearchFilters + local allUnchecked = true + 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, 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] + if row then + row:SetChecked(newState) + if row.SyncFlyoutSubChecks then row.SyncFlyoutSubChecks() end + end + end + local lootRow = checkRows["loot"] + if lootRow and lootRow.updateLootToggle then lootRow.updateLootToggle() end + LayoutDropdown() + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end) + + LayoutDropdown() + + dropdown:SetScript("OnShow", function(self) + local filters = EasyFind.db.uiSearchFilters + for key, row in pairs(checkRows) do + row:SetChecked(filters[key] ~= false) + if row.updateLootToggle then row.updateLootToggle() end + if row.SyncFlyoutSubChecks then row.SyncFlyoutSubChecks() end + end + LayoutDropdown() + end) + + dropdown:SetScript("OnHide", function() end) + + -- Keyboard: enable when opened via Enter on filter button + dropdown:HookScript("OnShow", function(self) + -- 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" + 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.RefreshDynamicCategory then + ns.Database:RefreshDynamicCategory("transmogSets") + if searchEditBox and searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end + + if UI._SyncAppearanceSetOptions then + UI._SyncAppearanceSetOptions() + end + end + if searchFrame.filterBtn and searchFrame.filterBtn.keyboardFocused then + dropdownKeyboardMode = true + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + Utils.SafeCallMethod(self, "EnableKeyboard", true) + SetDropdownFocus(1) + end + end) + + -- Keyboard: cleanup on hide + dropdown:HookScript("OnHide", function(self) + ClearDropdownFocus() + Utils.SafeCallMethod(self, "EnableKeyboard", false) + if self._escapedViaKeyboard then + self._escapedViaKeyboard = nil + dropdownKeyboardMode = false + Utils.SafeCallMethod(navFrame, "EnableKeyboard", true) + else + dropdownKeyboardMode = false + if searchFrame.ClearToolbarFocus then searchFrame.ClearToolbarFocus() end + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + if searchFrame.filterBtn then + 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 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 + -- 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 + end) + + -- 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. + Utils.SafeOnUpdate(dropdown, function(self) + 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 + end + self:Hide() + end + end + end) + + toggleBtn:SetScript("OnClick", function() + if dropdown:IsShown() then + dropdown:Hide() + else + local barScale = EasyFind.db.uiSearchScale or 1.0 + dropdown:SetScale(barScale) + local scale = anchorFrame:GetEffectiveScale() / (UIParent:GetEffectiveScale() * barScale) + local right = anchorFrame:GetRight() * scale + local bottom = anchorFrame:GetBottom() * scale + dropdown:ClearAllPoints() + dropdown:SetPoint("TOPRIGHT", UIParent, "BOTTOMLEFT", right, bottom) + dropdown:Show() + end + end) + + searchFrame.filterDropdown = dropdown +end + +function EnsureResultButton(index) + local row = resultButtons[index] + if not row then + row = UI:CreateResultButton(index) + resultButtons[index] = row + end + 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 + if UI.LayoutResultShortcut then + UI:LayoutResultShortcut(row) + end + + 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 + and not row.data.calculatorResult + and not row.data.calculatorLauncher +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 - elseif lf == "all" then - lbl:SetText("All Classes") - elseif lf.classID then - local cls - for _, c in ipairs(allClassSpecs) do - if c.classID == lf.classID then cls = c; break end + if row.shortcutGroup then + row.shortcutGroup:SetShown(showShortcutHints) end - if not cls then lbl:SetText("?"); return end - local cc = CLASS_COLORS[cls.classFile] - local colorStr = cc and string.format("|cff%02x%02x%02x", cc.r * 255, cc.g * 255, cc.b * 255) or "" - if lf.specID then - local sname - for _, s in ipairs(cls.specs) do - if s.specID == lf.specID then sname = s.specName; break 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 - lbl:SetText(colorStr .. cls.className .. " (" .. (sname or "?") .. ")|r") - else - -- All specs for this class - lbl:SetText(colorStr .. cls.className .. "|r") + 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: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 + 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) + 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 + end + return nil +end + +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("FULLSCREEN_DIALOG") + resultsFrame:SetFrameLevel(searchFrame:GetFrameLevel() + 1) + + 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 } + }) + + 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 + -- 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 + if IsOptionsSurfaceMouseOver() then return end + local guards = { + _G["EasyFindUIFilterDropdown"], + _G["EasyFindPinPopup"], + _G["EasyFindAsOptionsPopup"], + _G["EasyFindAsClassPopup"], + _G["EasyFindGearOptionsPopup"], + _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 + -- 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 + end + UI:RequestHideResults() + end) + + local resizeTimer + resultsFrame:SetScript("OnSizeChanged", function() + if not resultsFrame:IsShown() or not cachedHierarchical then return end -- luacheck: ignore 113 + if resizeTimer then resizeTimer:Cancel() end + resizeTimer = C_Timer.NewTimer(0.02, function() + resizeTimer = nil + UI:ShowHierarchicalResults(cachedHierarchical, true) -- luacheck: ignore 113 + end) + end) + + -- 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) + 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 + + -- 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 + + local pinSeparator = scrollChild:CreateTexture(nil, "ARTWORK") + pinSeparator:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.4) + pinSeparator:SetHeight(1) + pinSeparator:Hide() + resultsFrame.pinSeparator = pinSeparator + + -- Category separator lines (between result category groups) + local categorySeps = {} + for sepIdx = 1, 6 do + local sep = scrollChild:CreateTexture(nil, "ARTWORK") + sep:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.3) + sep:SetHeight(1.5) + sep:Hide() + categorySeps[sepIdx] = sep + end + resultsFrame.categorySeps = categorySeps +end + +-- 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) +local LINE_W = 2 -- connector line thickness +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 flatEntries = {} +local flatCombined = {} +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 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" }, + { waitForFrame = "AchievementFrame", tabIndex = 1 }, + } + local getCat = _G["GetAchievementCategory"] + local getInfo = _G["GetCategoryInfo"] + if not getCat or not getInfo then + steps[#steps + 1] = { + waitForFrame = "AchievementFrame", + achievementID = achievementID, + } + return steps + end + local catID = getCat(achievementID) + if not catID or catID < 0 then + steps[#steps + 1] = { + waitForFrame = "AchievementFrame", + 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] = { 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 = cat.name, + achievementCategoryID = cat.id, + } + 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 + 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 = BuildAchievementSteps(id), + }, ACHIEVEMENT_MT) + achievementEntryByID[id] = entry + return entry +end + +local function CollectAchievementSearchResults(query) + SyncAchievementSearchStatsVersion() + + 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 = {} + 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 and not (isStat and isStat(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 + SyncAchievementSearchStatsVersion() + + 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 = {}, + catSepYPositions = {}, + aliasSeen = {}, + calculatorResults = {}, + filteredResults = {}, + quickFilterResults = {}, + skipCategories = {}, +} + +UI._flatEntries = flatEntries +UI._flatCombined = flatCombined +UI._pinnedSearchEntries = pinnedSearchEntries +UI._pinnedSearchPinEntries = pinnedSearchPinEntries +UI._SCRATCH = SCRATCH +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 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) + 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. + RefreshSearchAfterHeavyLoad(anyChanged) + end) + 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 +function UI:RefocusSearchEditBox() RefocusSearchEditBox() 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) + -- 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 + -- 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. + -- 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 + 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 + +local function ActivateSettingResult(data, ctrlHeld) + if not data or not data.settingVariable then return false end + local stype = data.settingType + 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. 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 + -- 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 +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 + +-- 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 + 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) + 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 - -- Check if a filter value matches the current lootFilter - local function IsFilterMatch(filterVal) - local lf = EasyFind.db.lootFilter - -- nil lootFilter = current spec; resolve to player class+spec for comparison - if not lf then - if filterVal == nil then return true end - if type(filterVal) == "table" and filterVal.specID then - local _, _, cid = UnitClass("player") - local si = GetSpecialization and GetSpecialization() - local sid = si and GetSpecializationInfo and GetSpecializationInfo(si) - return filterVal.classID == cid and filterVal.specID == sid +function UI:CreateResultButton(index) + local scrollChild = resultsFrame.scrollChild + local resultRow = CreateFrame("Button", "EasyFindResultButton"..index, scrollChild, "SecureActionButtonTemplate") + resultRow:SetSize(360, 22) + resultRow:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 10, -8 - (index - 1) * 22) + + -- 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 + + -- 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) + separator:SetHeight(1) + separator:SetPoint("BOTTOMLEFT", resultRow, "BOTTOMLEFT", 4, 0) + separator:SetPoint("BOTTOMRIGHT", resultRow, "BOTTOMRIGHT", -4, 0) + separator:Hide() + resultRow.separator = separator + + resultRow.treeVert = {} -- vertical │ pass-through for ancestors + resultRow.treeBranch = {} -- horizontal ─ branch connector + resultRow.treeElbow = {} -- vertical half-line for └ / ├ + + for d = 1, MAX_DEPTH do + local c = INDENT_COLORS[d] + local xCenter = (d - 1) * INDENT_PX + LINE_X_OFF + + local vert = resultRow:CreateTexture(nil, "BACKGROUND") + vert:SetColorTexture(c[1], c[2], c[3], 1) + vert:SetWidth(LINE_W) + vert:SetPoint("TOP", resultRow, "TOPLEFT", xCenter, 3) + vert:SetPoint("BOTTOM", resultRow, "BOTTOMLEFT", xCenter, -1) + vert:Hide() + resultRow.treeVert[d] = vert + + local elbow = resultRow:CreateTexture(nil, "BACKGROUND") + elbow:SetColorTexture(c[1], c[2], c[3], 1) + elbow:SetWidth(LINE_W) + elbow:SetPoint("TOP", resultRow, "TOPLEFT", xCenter, 3) + elbow:SetHeight(13) + elbow:Hide() + resultRow.treeElbow[d] = elbow + + local branch = resultRow:CreateTexture(nil, "BACKGROUND") + branch:SetColorTexture(c[1], c[2], c[3], 1) + branch:SetHeight(LINE_W) + branch:SetPoint("LEFT", resultRow, "TOPLEFT", xCenter - 1, -11) + branch:SetPoint("RIGHT", resultRow, "TOPLEFT", xCenter + INDENT_PX - LINE_X_OFF, -11) + branch:Hide() + resultRow.treeBranch[d] = branch + end + + local icon = resultRow:CreateTexture(nil, "ARTWORK") + icon:SetSize(16, 16) + icon:SetPoint("LEFT", 0, 0) + resultRow.icon = icon + + -- Cooldown sweep overlay for toy icons + local iconCooldown = CreateFrame("Cooldown", nil, resultRow, "CooldownFrameTemplate") + iconCooldown:SetDrawEdge(true) + iconCooldown:SetHideCountdownNumbers(true) + iconCooldown:Hide() + resultRow.iconCooldown = iconCooldown + + -- Pin indicator (small map pin badge on the icon) + local pinIcon = resultRow:CreateTexture(nil, "OVERLAY") + pinIcon:SetSize(13, 13) + pinIcon:SetPoint("BOTTOMLEFT", icon, "BOTTOMRIGHT", -4, -1) + pinIcon:SetAtlas("Waypoint-MapPin-ChatIcon") + pinIcon:Hide() + resultRow.pinIcon = pinIcon + + -- Pin header toggle icon (expand/collapse, right-aligned on the button itself) + local pinToggle = resultRow:CreateTexture(nil, "ARTWORK") + pinToggle:SetSize(14, 14) + pinToggle:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) + pinToggle:SetAtlas("QuestLog-icon-shrink") + pinToggle:Hide() + resultRow.pinToggle = pinToggle + + -- Pin header underline (thin golden line below the header text) + local pinHeaderLine = resultRow:CreateTexture(nil, "ARTWORK") + pinHeaderLine:SetHeight(1) + pinHeaderLine:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.4) + pinHeaderLine:SetPoint("BOTTOMLEFT", resultRow, "BOTTOMLEFT", 0, 0) + pinHeaderLine:SetPoint("BOTTOMRIGHT", resultRow, "BOTTOMRIGHT", 0, 0) + 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) + amountText:SetJustifyH("RIGHT") + amountText:SetTextColor(0.9, 0.82, 0.65, 1.0) + amountText:Hide() + resultRow.amountText = amountText + + local calcCard = CreateFrame("Frame", nil, resultRow) + ns.CreateRoundedRectBorder(calcCard) + ns.SetRoundedRectBarHeight(calcCard, 12) + 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 + + 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.34, 0.34, 0.36, 0.86) + calcDividerTop:SetWidth(1) + calcDividerTop:Hide() + resultRow.calcDividerTop = calcDividerTop + + local calcDividerBottom = calcCard:CreateTexture(nil, "ARTWORK") + calcDividerBottom:SetColorTexture(0.34, 0.34, 0.36, 0.86) + 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 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 + + 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:SetJustifyH("RIGHT") + shortcutNumberText:SetTextColor(0.58, 0.58, 0.58, 0.85) + shortcutNumberText:SetText("") + resultRow.shortcutNumberText = shortcutNumberText + self:LayoutResultShortcut(resultRow) + + -- 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 + -- 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 + 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 false + return end - if filterVal == "all" and lf == "all" then return true end - if type(filterVal) == "table" and type(lf) == "table" then - if filterVal.classID == lf.classID then - if filterVal.specID == nil and lf.specID == nil then return true end - if filterVal.specID == lf.specID then return true end - end + end + end + if SetCVar then + pcall(SetCVar, variable, tostring(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 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 - return false 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 + 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) - ------------------------------------------------------------------- - -- Main spec popup (opens BELOW the bar) - -- Layout: "Class >" row, then class header, then specs, then "All Specializations" - ------------------------------------------------------------------- - local specPopup = CreateFrame("Frame", "EasyFindSpecPopup", UIParent, "BackdropTemplate") - specPopup:SetFrameStrata("TOOLTIP") - StylePopup(specPopup) - specPopup:EnableMouse(true) - specPopup:Hide() + -- 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 - ------------------------------------------------------------------- - -- Class flyout (opens to the RIGHT of the "Class" row) - ------------------------------------------------------------------- - local classFlyout = CreateFrame("Frame", "EasyFindSpecFlyout", UIParent, "BackdropTemplate") - classFlyout:SetFrameStrata("TOOLTIP") - StylePopup(classFlyout) - classFlyout:EnableMouse(true) - classFlyout:Hide() + 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)) + + -- Hovering a kb button overrides the row's action-hint subtext with a + -- 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) + 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() + GameTooltip:Hide() + 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) + -- 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) + ddTxt:SetNonSpaceWrap(false) + ddTxt:SetMaxLines(1) + end + resultRow.settingDropdownLabel = ddCenter + -- 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. 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) + -- 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 + 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 + ShowInlineSettingDropdown(self, opts, + function() return ReadSettingVariable(var) end, + function(value) UI:SetSettingDropdownValue(rowData, value) 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) + 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) + 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() + RefocusSearchEditBox() + end) + resetBtn:SetScript("OnClick", function() + 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() + RefocusSearchEditBox() + 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) + local repBarBackdrop = { + bgFile = "Interface\\BUTTONS\\WHITE8X8", + edgeFile = TOOLTIP_BORDER, + tile = true, tileSize = 8, edgeSize = 14, + insets = { left = 3, right = 3, top = 3, bottom = 3 }, + } + + local repBar = CreateFrame("Frame", nil, resultRow, BackdropTemplateMixin and "BackdropTemplate") + repBar:SetSize(REP_BAR_WIDTH, 19) + repBar:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) + if repBar.SetBackdrop then + repBar:SetBackdrop(repBarBackdrop) + repBar:SetBackdropColor(0.06, 0.06, 0.06, 1.0) + repBar:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.8) + end + repBar:Hide() + resultRow.repBar = repBar + + -- Clip frame controls how much of the fill is visible (left→right) + local repClip = CreateFrame("Frame", nil, repBar) + repClip:SetPoint("TOPLEFT", repBar, "TOPLEFT", 0, 0) + repClip:SetPoint("BOTTOMLEFT", repBar, "BOTTOMLEFT", 0, 0) + repClip:SetWidth(REP_BAR_WIDTH) + repClip:SetClipsChildren(true) + resultRow.repClip = repClip + + local repFill = CreateFrame("Frame", nil, repClip, BackdropTemplateMixin and "BackdropTemplate") + repFill:SetPoint("TOPLEFT", repBar, "TOPLEFT", 0, 0) + repFill:SetPoint("BOTTOMRIGHT", repBar, "BOTTOMRIGHT", 0, 0) + if repFill.SetBackdrop then + repFill:SetBackdrop(repBarBackdrop) + repFill:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.8) + end + resultRow.repFill = repFill + + -- Glossy bar texture (same as WoW default bars); backdrop bgColor matches fill + -- color so the flat corners blend seamlessly with the glossy center + local repBarTex = repFill:CreateTexture(nil, "ARTWORK") + repBarTex:SetPoint("TOPLEFT", repFill, "TOPLEFT", 3, -3) + repBarTex:SetPoint("BOTTOMRIGHT", repFill, "BOTTOMRIGHT", -3, 3) + repBarTex:SetTexture("Interface\\TargetingFrame\\UI-StatusBar") + resultRow.repBarTex = repBarTex + + -- Text overlay above everything (not clipped) + local repTextOverlay = CreateFrame("Frame", nil, repBar) + repTextOverlay:SetAllPoints() + repTextOverlay:SetFrameLevel(repFill:GetFrameLevel() + 3) + local repBarText = repTextOverlay:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + repBarText:SetPoint("CENTER", repBar, "CENTER", 0, 0) + repBarText:SetTextColor(1.0, 1.0, 1.0, 1.0) + repBarText:SetShadowOffset(1, -1) + resultRow.repBarText = repBarText + + local text = resultRow:CreateFontString(nil, "OVERLAY", "GameFontNormal") + 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 + -- 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 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") + + -- 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) + elseif PickupSpell then + PickupSpell(spellID) + end + end + local function PickupRowAction(d) + 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 + 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 + 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) + resultRow:SetScript("PreClick", function(self, mouseButton) + if mouseButton ~= "LeftButton" then return end + 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 - local LayoutSpecPopup -- forward declaration for closures below + -- 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 - -- Helper: create a radio-style row - local function CreateRadioRow(parent, label, filterVal, width) - local btn = CreateFrame("Button", nil, parent) - btn:SetSize(width - 16, FLYOUT_ROW_H) - btn:SetFrameLevel(parent:GetFrameLevel() + 10) - local radio, setChecked = CreateRadioTexture(btn) - radio:SetPoint("LEFT", 4, 0) - local lbl = btn:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - lbl:SetPoint("LEFT", radio, "RIGHT", 4, 0) - lbl:SetText(label) - local hl = btn:CreateTexture(nil, "HIGHLIGHT") - hl:SetAllPoints() - hl:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) - btn._setRadioChecked = setChecked - btn._filterVal = filterVal - return btn - end + -- 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 - ------------------------------------------------------------------- - -- Build class flyout rows (right panel): All Classes + each class - ------------------------------------------------------------------- - local classFlyoutRows = {} - -- "All Classes" - local allClassRow = CreateRadioRow(classFlyout, "All Classes", "all", CLASSFLYOUT_WIDTH) - allClassRow:SetScript("OnClick", function() - EasyFind.db.lootFilter = "all" - UpdateSpecLabel() - classFlyout:Hide() - if not classFlyout._keyboardParent then specPopup:Hide() end - ApplyFilterSelection() - 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 - local ccl = CLASS_COLORS[cls.classFile] - local csStr = ccl and string.format("|cff%02x%02x%02x", ccl.r * 255, ccl.g * 255, ccl.b * 255) or "" - local clsLabel = clsRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - clsLabel:SetPoint("LEFT", 22, 0) - clsLabel:SetText(csStr .. cls.className .. "|r") - clsRow:SetScript("OnClick", function() - EasyFind.db.lootFilter = { classID = cls.classID } - UpdateSpecLabel() - classFlyout:Hide() - if not classFlyout._keyboardParent then specPopup:Hide() end - ApplyFilterSelection() - if specPopup:IsShown() then LayoutSpecPopup() end - end) - classFlyoutRows[#classFlyoutRows + 1] = clsRow + -- 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 + if outfitCdStart > 0 and outfitCdDuration - (GetTime() - outfitCdStart) > 0 then + return + end + local tempSlot = ns.Database and ns.Database:FindEmptyActionSlot() + if not tempSlot then + self._outfitSlot = nil + self:SetAttribute("type", nil) + self:SetAttribute("action", nil) + return + end + self._outfitSlot = tempSlot + self._outfitID = outfitID + self:SetAttribute("action", tempSlot) + if C_TransmogOutfitInfo and C_TransmogOutfitInfo.PickupOutfit then + C_TransmogOutfitInfo.PickupOutfit(outfitID) + PlaceAction(tempSlot) + ClearCursor() + if not HasAction(tempSlot) then + self._outfitSlot = nil + self._outfitID = nil + self:SetAttribute("type", nil) + self:SetAttribute("action", nil) end - - local function LayoutClassFlyout() - local fy = -6 - local lvl = classFlyout:GetFrameLevel() + 10 - for _, r in ipairs(classFlyoutRows) do - r:ClearAllPoints() - r:SetPoint("TOPLEFT", classFlyout, "TOPLEFT", 8, fy) - r:SetFrameLevel(lvl) - r:Show() - if r._setRadioChecked then - local lf = EasyFind.db.lootFilter - local match = false - if r._filterVal == "all" and lf == "all" then - match = true - elseif type(r._filterVal) == "table" then - if type(lf) == "table" and r._filterVal.classID == lf.classID then - match = true - elseif not lf then - -- nil = current spec; dot the player's class - local _, _, cid = UnitClass("player") - match = r._filterVal.classID == cid - end - end - r._setRadioChecked(match) - end - fy = fy - FLYOUT_ROW_H - end - classFlyout:SetSize(CLASSFLYOUT_WIDTH, -fy + 6) + end + end) + resultRow:SetScript("PostClick", function(self, mouseButton, down) + -- 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 - ------------------------------------------------------------------- - -- Build spec popup rows (main dropdown below bar) - ------------------------------------------------------------------- - -- Row 1: "Class" with arrow (opens class flyout to the right) - local classSelectBtn = CreateFrame("Button", nil, specPopup) - classSelectBtn:SetSize(POPUP_WIDTH - 16, FLYOUT_ROW_H) - classSelectBtn:SetFrameLevel(specPopup:GetFrameLevel() + 10) - local csLabel = classSelectBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - csLabel:SetPoint("LEFT", 8, 0) - csLabel:SetText("Class") - local csArrow = classSelectBtn:CreateTexture(nil, "ARTWORK") - csArrow:SetSize(16, 16) - csArrow:SetPoint("RIGHT", -4, 0) - csArrow:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") - local csHL = classSelectBtn:CreateTexture(nil, "HIGHLIGHT") - csHL:SetAllPoints() - 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:ClearAllPoints() - classFlyout:SetPoint("TOPLEFT", classSelectBtn, "TOPRIGHT", 2, 6) - classFlyout:Show() + -- Clean up temp action slot after outfit equip. + if self._outfitSlot then + local slot = self._outfitSlot + self._outfitSlot = nil + -- Record equip immediately so green tint and cooldown + -- are correct when results re-render (API lags behind). + if self._outfitID then + lastEquippedOutfitID = self._outfitID + outfitCdStart = GetTime() + outfitCdDuration = 4 + self._outfitID = nil end - classSelectBtn:SetScript("OnEnter", function() OpenClassFlyout() end) - classSelectBtn:SetScript("OnClick", function() OpenClassFlyout() end) - - -- Spec rows (rebuilt each time popup opens based on selected class) - local specRadioRows = {} - local MAX_SPECS = 5 -- druid has 4 + "All Specializations" = 5 - for si = 1, MAX_SPECS do - local sRow = CreateRadioRow(specPopup, "", nil, POPUP_WIDTH) - sRow:Hide() - specRadioRows[si] = sRow + -- Delay slot cleanup one frame so UseAction fully completes + C_Timer.After(0, function() + -- Read actual cooldown duration if available + local start, dur = GetActionCooldown(slot) + if start and dur and dur > 0 then + outfitCdStart, outfitCdDuration = start, dur + end + PickupAction(slot) + ClearCursor() + end) + end + -- Right-click: show pin/unpin popup (plus Guide row if entry has a guide path) + if mouseButton == "RightButton" and self.data then + 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 + 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) + end or nil + local canAlias = ns.Aliases and ns.Aliases:GetEntryKey(pinData) ~= nil + 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: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:KeepPinnedResultsOpenBriefly() + UI:ToggleCurrencyBackpack(cid) + end, + } + if UI:IsCurrencyTransferable(cid) then + 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:KeepPinnedResultsOpenBriefly() + UI:ToggleTransmogSetFavorite(sid) + end, + } + elseif pinData.petID then + local pid = pinData.petID + local cageable = UI:IsPetCageable(pid) + extra = { + onSummon = function() + UI:KeepPinnedResultsOpenBriefly() + UI:SummonPet(pid) + end, + onRename = function() + UI:KeepPinnedResultsOpenBriefly() + UI:RenamePet(pid) + end, + isFavorite = UI:IsPetFavorite(pid), + 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, + } end + ShowPinPopup(self, isPinned, function() + if isPinned then + UnpinUIItem(pinData) + else + PinUIItem(pinData) + end + local editBox = searchFrame and searchFrame.editBox + local text = editBox and editBox:GetText() or "" + if text == "" then + local pinsRemain = UI:KeepPinnedResultsOpenBriefly() + 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 + end, onGuide, onAddAlias, extra) + return + end - -- Class header (non-clickable, shows selected class name) - local classHeader = CreateFrame("Frame", nil, specPopup) - classHeader:SetSize(POPUP_WIDTH - 16, FLYOUT_ROW_H) - classHeader:SetFrameLevel(specPopup:GetFrameLevel() + 10) - local classHeaderLabel = classHeader:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - classHeaderLabel:SetPoint("LEFT", 8, 0) - - LayoutSpecPopup = function() - local selCls = GetSelectedClass() - local py = -6 - local lvl = specPopup:GetFrameLevel() + 10 - - -- Row 1: "Class >" - classSelectBtn:ClearAllPoints() - classSelectBtn:SetPoint("TOPLEFT", specPopup, "TOPLEFT", 8, py) - classSelectBtn:SetFrameLevel(lvl) - classSelectBtn:Show() - py = py - FLYOUT_ROW_H + -- Don't allow clicking unearned currencies + if self.isUnearnedCurrency then + return + end - if selCls then - -- Class header - local cc = CLASS_COLORS[selCls.classFile] - local colorStr = cc and string.format("|cff%02x%02x%02x", cc.r * 255, cc.g * 255, cc.b * 255) or "" - classHeaderLabel:SetText(colorStr .. selCls.className .. "|r") - classHeader:ClearAllPoints() - classHeader:SetPoint("TOPLEFT", specPopup, "TOPLEFT", 8, py) - classHeader:SetFrameLevel(lvl) - classHeader:Show() - py = py - FLYOUT_ROW_H + -- 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 - -- Spec rows - local ri = 1 - for _, spec in ipairs(selCls.specs) do - local sRow = specRadioRows[ri] - if sRow then - -- Update label and filter value - local children = { sRow:GetRegions() } - for _, child in ipairs(children) do - if child:GetObjectType() == "FontString" and child:GetText() ~= "" then - if child:GetPoint() then - local _, rel = child:GetPoint() - if rel and rel:GetObjectType() == "Texture" then - child:SetText(spec.specName) - end - end - end - end - sRow._filterVal = { classID = selCls.classID, specID = spec.specID } - sRow._setRadioChecked(IsFilterMatch(sRow._filterVal)) - sRow:SetScript("OnClick", function() - EasyFind.db.lootFilter = { classID = selCls.classID, specID = spec.specID } - UpdateSpecLabel() - classFlyout:Hide() - specPopup:Hide() - ApplyFilterSelection() - end) - sRow:SetScript("OnEnter", function() - classFlyout:Hide() - end) - sRow:ClearAllPoints() - sRow:SetPoint("TOPLEFT", specPopup, "TOPLEFT", 8, py) - sRow:SetFrameLevel(lvl) - sRow:Show() - py = py - FLYOUT_ROW_H - ri = ri + 1 - end + if self.isPinHeader then + return + end + + if self.isPathNode then + -- Retail theme: headerTab and toggleBtn handle clicks directly + local isRetailHeader = self.headerTab and self.headerTab:IsShown() + if isRetailHeader then + if self.data then + UI:SelectResult(self.data) + end + else + local cursorX = GetCursorPosition() + local scale = self:GetEffectiveScale() + local btnLeft = self:GetLeft() * scale + local depth = self.pathNodeDepth or 0 + local iconLeft = btnLeft + depth * 20 * scale -- INDENT_PX = 20 + local isToggleClick = cursorX <= (iconLeft + 35 * scale) + + if isToggleClick then + local key = (self.pathNodeName or "") .. "_" .. (self.pathNodeDepth or 0) + collapsedNodes[key] = not collapsedNodes[key] + if cachedHierarchical then + UI:ShowHierarchicalResults(cachedHierarchical, true) end + elseif self.data then + UI:SelectResult(self.data) + end + end + elseif self.data then + UI:SelectResult(self.data) + end + end) - -- "All Specializations" row - local allRow = specRadioRows[ri] - if allRow then - local children = { allRow:GetRegions() } - for _, child in ipairs(children) do - if child:GetObjectType() == "FontString" and child:GetText() ~= "" then - if child:GetPoint() then - local _, rel = child:GetPoint() - if rel and rel:GetObjectType() == "Texture" then - child:SetText("All Specializations") - end - end + -- Tooltip for unearned currencies, mounts, and toys + 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 + AnchorTooltipAtCursor(GameTooltip, self) + if GameTooltip.SetSpellByID then + GameTooltip:SetSpellByID(self.data.spellID) + else + GameTooltip:SetHyperlink("spell:" .. self.data.spellID) + end + 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 + AnchorTooltipAtCursor(GameTooltip, self) + 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 + AnchorTooltipAtCursor(GameTooltip, self) + 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(" ") + 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 - allRow._filterVal = { classID = selCls.classID } - allRow._setRadioChecked(IsFilterMatch(allRow._filterVal)) - allRow:SetScript("OnClick", function() - EasyFind.db.lootFilter = { classID = selCls.classID } - UpdateSpecLabel() - classFlyout:Hide() - specPopup:Hide() - ApplyFilterSelection() - end) - allRow:SetScript("OnEnter", function() - classFlyout:Hide() - end) - allRow:ClearAllPoints() - allRow:SetPoint("TOPLEFT", specPopup, "TOPLEFT", 8, py) - allRow:SetFrameLevel(lvl) - allRow:Show() - py = py - FLYOUT_ROW_H - ri = ri + 1 end + 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), + tostring(self.data.settingMax)), 0.7, 0.7, 0.7) + end + end + GameTooltip:Show() + return + end - -- Hide unused rows - for hi = ri, MAX_SPECS do - specRadioRows[hi]:Hide() + 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" + 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 + elseif self.data and self.data.mapSearchResult then + -- Map result: preview pin on world map if it happens to be open + if ns.MapSearch and ns.MapSearch.PreviewUIResult then + 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 + AnchorTooltipAtCursor(GameTooltip, self) + GameTooltip:SetMountBySpellID(self.icon.spellID) + GameTooltip:Show() + elseif self.icon.toyItemID then + local toyItemID = self.icon.toyItemID + AnchorTooltipAtCursor(GameTooltip, self) + GameTooltip:SetToyByItemID(toyItemID) + GameTooltip:Show() + self.toyTooltipTicker = C_Timer.NewTicker(1, function() + if GameTooltip:IsOwned(self) then + GameTooltip:SetToyByItemID(toyItemID) + end + end) + -- Pet tooltip (use BattlePetToolTip via the link, since GameTooltip + -- only renders battle pet links as raw escape codes) + elseif self.icon.petID then + local link = C_PetJournal and C_PetJournal.GetBattlePetLink + and C_PetJournal.GetBattlePetLink(self.icon.petID) + if link and BattlePetToolTip_ShowLink then + AnchorTooltipAtCursor(GameTooltip, self) + BattlePetToolTip_ShowLink(link) + elseif link then + AnchorTooltipAtCursor(GameTooltip, self) + GameTooltip:SetHyperlink(link) + GameTooltip:Show() + end + elseif self.icon.outfitID then + 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) + local activeID = lastEquippedOutfitID + or (C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetActiveOutfitID + and C_TransmogOutfitInfo.GetActiveOutfitID()) + if activeID and activeID == self.icon.outfitID then + GameTooltip:AddLine(" ") + GameTooltip:AddLine("Currently equipped", 0.3, 1, 0.3) + else + GameTooltip:AddLine(" ") + GameTooltip:AddLine("Click to equip", 1, 0.82, 0) + end + if C_TransmogOutfitInfo and C_TransmogOutfitInfo.IsLockedOutfit then + GameTooltip:AddLine(" ") + GameTooltip:AddLine("Lock Appearance:", 1, 1, 1) + GameTooltip:AddLine("Prevent this appearance from being\nreplaced by a Situation", 1, 0.82, 0) + if C_TransmogOutfitInfo.IsLockedOutfit(self.icon.outfitID) then + GameTooltip:AddLine(" ") + GameTooltip:AddLine("Currently locked", 0.3, 1, 0.3) end + GameTooltip:AddLine(" ") + GameTooltip:AddLine("", 0.5, 0.5, 0.5) + end + GameTooltip:Show() + elseif self.icon.lootItemID then + AnchorGearTooltip(GameTooltip, self) + local itemLink = self.data and ns.Database and ns.Database:GetLootItemLink(self.data) + if itemLink then + GameTooltip:SetHyperlink(itemLink) else - classHeader:Hide() - for _, sr in ipairs(specRadioRows) do sr:Hide() end + GameTooltip:SetItemByID(self.icon.lootItemID) end + GameTooltip:Show() + elseif self.icon.heirloomItemID then + 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 + 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. 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 + 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) + else + GameTooltip:SetItemByID(self.icon.bagItemID) + end + GameTooltip:Show() + end + end + end) - specPopup:SetSize(POPUP_WIDTH, -py + 6) + resultRow:SetScript("OnLeave", function(self) + if unearnedTooltip then + unearnedTooltip:Hide() + end + if self.toyTooltipTicker then + self.toyTooltipTicker:Cancel() + self.toyTooltipTicker = nil + end + 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 + if self.data and self.data.mapSearchResult and ns.MapSearch and ns.MapSearch.ClearUIPreview then + ns.MapSearch:ClearUIPreview() + end + 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) - ------------------------------------------------------------------- - -- Spec selector dropdown bar - ------------------------------------------------------------------- - local specSelectRow = CreateFrame("Button", nil, dropdown) - specSelectRow:SetSize(120, 27) - local specBg = specSelectRow:CreateTexture(nil, "BACKGROUND") - specBg:SetAtlas("common-dropdown-textholder") - specBg:SetAllPoints() - local specSelectArrow = specSelectRow:CreateTexture(nil, "OVERLAY") - specSelectArrow:SetAtlas("common-dropdown-a-button-hover") - specSelectArrow:SetSize(20, 20) - specSelectArrow:SetPoint("RIGHT", -2, -1) - specSelectArrow:SetVertexColor(0.7, 0.7, 0.7) - local specSelectLabel = specSelectRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - specSelectLabel:SetPoint("LEFT", 8, 0) - specSelectLabel:SetPoint("RIGHT", specSelectArrow, "LEFT", -2, 0) - specSelectLabel:SetJustifyH("LEFT") - specSelectLabel:SetWordWrap(false) + ApplyResultRowFonts(resultRow) + resultRow:Hide() + return resultRow +end - specSelectRow:SetScript("OnEnter", function() - specSelectArrow:SetVertexColor(1, 1, 1) - end) - specSelectRow:SetScript("OnLeave", function() - specSelectArrow:SetVertexColor(0.7, 0.7, 0.7) - end) - specSelectRow:SetScript("OnClick", function() - if specPopup:IsShown() then - specPopup:Hide() - else - LayoutSpecPopup() - specPopup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) - specPopup:ClearAllPoints() - specPopup:SetPoint("TOPLEFT", specSelectRow, "BOTTOMLEFT", 0, 2) - specPopup:Show() - end - end) +-- 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 - row.specSelectRow = specSelectRow - row.specSelectLabel = specSelectLabel +-- 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) + local eb = self.editBox or self.EditBox + if eb then + eb:SetText("") + eb:SetFocus() + end + end, + OnAccept = function(self, data) + 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 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 + 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, +} + +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" + 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, +-- -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, 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 + ClearResultTooltips() + local quickFilter = self:GetQuickFilter() + if not text or text == "" then + if ns.Database and ns.Database.CancelDynamicWarmup then + ns.Database:CancelDynamicWarmup() + end + -- 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() + end + return + end + + local commandEntries = (not quickFilter) and self:GetSearchBarCommandSuggestionEntries(text) + if commandEntries then + self:ShowHierarchicalResults(commandEntries) + return + end - local function GetSpecPopupNavRows() - local rows = { classSelectBtn } - for _, sr in ipairs(specRadioRows) do - if sr:IsShown() then rows[#rows + 1] = sr end - end - return rows - end + wipe(collapsedNodes) + local calculatorData = (not quickFilter) and self:EvaluateCalculatorExpression(text) or nil + 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) + ) + if not force and not needsHeavy and ns.Database and ns.Database.CancelDynamicWarmup then + ns.Database:CancelDynamicWarmup() + end + MaybeLoadHeavySearchData(text, needsHeavy) - -- Close on outside click - specPopup:SetScript("OnShow", function(self) - self:RegisterEvent("GLOBAL_MOUSE_DOWN") - end) - specPopup:SetScript("OnHide", function(self) - self:UnregisterEvent("GLOBAL_MOUSE_DOWN") - classFlyout:Hide() - end) - specPopup:SetScript("OnEvent", function(self, event) - if event == "GLOBAL_MOUSE_DOWN" then - if not self:IsMouseOver() - and not classFlyout:IsMouseOver() - and not specSelectRow:IsMouseOver() then - self:Hide() + -- 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 = quickFilter and nil or 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 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 = 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 + 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 + 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 + if calculatorData or calculatorLauncher 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 + -- 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 + 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] + if not seen[hit.data] then + 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) + end + wipe(seen) + 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 - if not self._leaveTimer then - self._leaveTimer = C_Timer.NewTimer(0.2, function() - self._leaveTimer = nil - if not self:IsMouseOver() and not classSelectBtn:IsMouseOver() then - self:Hide() - end - end) - end - else - if self._leaveTimer then - self._leaveTimer:Cancel() - self._leaveTimer = nil - 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 + -- 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.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 + or filters.options == false + or filters.gameOptions == false or filters.addonOptions == false + or filters.titles == false or filters.gearSets == false + or filters.talents == false + or hidePassives) then + wipe(SCRATCH.filteredResults) + local filtered = SCRATCH.filteredResults + local fi = 0 + for ri = 1, #results do + local r = results[ri] + if r.isAlias then + fi = fi + 1 + filtered[fi] = r + else + local d = r.data + local bucket = GetUIBucket(d) + local bucketOff = bucket and filters[bucket] == false + local parentOff = optionsOff + and (bucket == "gameOptions" or bucket == "addonOptions") + 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 - end) + end + end + for i = fi + 1, #filtered do filtered[i] = nil end + results = filtered + end - -- Close flyouts when dropdown hides - dropdown:HookScript("OnHide", function() - classFlyout:Hide() - specPopup:Hide() - 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. - -- Keyboard nav MUST be added AFTER SetScript calls above - AddPopupKeyboardNav(specPopup, GetSpecPopupNavRows) - AddPopupKeyboardNav(classFlyout, function() return classFlyoutRows end) + local mapResults + 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) + end - -- Keep EasyFindSpecFlyout/EasyFindSpecSubFlyout names for dropdown close guard - local specFlyout = classFlyout - row.specFlyout = specFlyout - row.allClassSpecs = allClassSpecs - row.lootSubRows = lootSubRows - row.updateLootToggle = function() - local lootChecked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.loot ~= false - 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() - end - end - UpdateSpecLabel() - if not lootChecked then - local sp = _G["EasyFindSpecPopup"] - if sp then sp:Hide() end - specFlyout:Hide() + wipe(flatCombined) + 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 + for ri = 1, #mapResults do combined[#combined + 1] = mapResults[ri] end + 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 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) + 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. 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) + if score and score > 0 then + combined[#combined + 1] = { data = entry, score = score } end end end + end - local highlight = row:CreateTexture(nil, "HIGHLIGHT") - highlight:SetAllPoints() - highlight:SetColorTexture(1, 1, 1, 0.1) + 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 + e.name = d.name + e.depth = 0 + e.isPathNode = false + e.isMatch = true + e.isFlat = true + e.flatCatKey = nil + e.isPinned = (not d.noPin and IsUIItemPinned(d)) and true or false + e.data = d + end + end + for i = n + 1, #flatEntries do + 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 kbHighlight = row:CreateTexture(nil, "BACKGROUND") - kbHighlight:SetAllPoints() - kbHighlight:SetColorTexture(1, 1, 1, 0.1) - kbHighlight:Hide() - row.kbHighlight = kbHighlight + local hierarchical = flatEntries + wipe(pinnedSearchEntries) + 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 - row:SetChecked(true) +local function GetButtonIcon(frameName) + local frame = _G[frameName] + if not frame then return nil end - 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 - LayoutDropdown() - if searchEditBox:GetText() ~= "" then - UI:OnSearchTextChanged(searchEditBox:GetText()) - end - end) + -- For MicroButtons - use the textureName property to build atlas + -- Atlas format: "UI-HUD-MicroMenu--Up" + if frame.textureName then + local atlas = "UI-HUD-MicroMenu-" .. frame.textureName .. "-Up" + return atlas, true -- true means it's an atlas + end - checkRows[opt.key] = row - checkRowsByIndex[i] = row + -- MicroButtons without textureName (e.g. CharacterMicroButton) use a portrait + -- render texture that produces garbage when captured. Skip region scanning for these. + if frame.IsMicroButton or (frameName and frameName:find("MicroButton")) then + return nil end - -- Layout: positions all rows including map sub-rows, adjusts dropdown height - local SUB_INDENT = 24 - local dropdownNavRows = {} -- ordered list of navigable rows (rebuilt on layout) - local dropdownFocus = 0 - local dropdownKbHighlight = dropdown:CreateTexture(nil, "BACKGROUND") - dropdownKbHighlight:SetColorTexture(1, 1, 1, 0.1) - dropdownKbHighlight:Hide() + local iconRegions = {"Icon", "icon", "NormalTexture", "normalTexture"} + for _, regionName in ipairs(iconRegions) do + local region = frame[regionName] + if region and region.GetTexture then + local texture = region:GetTexture() + if texture then + return texture + end + end + end - local function SetDropdownFocus(idx) - dropdownFocus = idx - local target = dropdownNavRows[idx] - if target then - dropdownKbHighlight:SetParent(target) - dropdownKbHighlight:ClearAllPoints() - dropdownKbHighlight:SetAllPoints(target) - dropdownKbHighlight:Show() - else - dropdownKbHighlight:Hide() + -- Fallback: iterate through regions + for i = 1, select("#", frame:GetRegions()) do + local region = select(i, frame:GetRegions()) + if region and region:GetObjectType() == "Texture" then + local texture = region:GetTexture() + if texture and type(texture) == "number" then + return texture + end end end - local function ClearDropdownFocus() - dropdownFocus = 0 - dropdownKbHighlight:Hide() + return nil +end + +function UI:ShowHierarchicalResults(hierarchical, preserveScroll) + if not hierarchical or #hierarchical == 0 then + self:HideResults() + return end + if not resultsFrame then return end - function LayoutDropdown() - local savedFocus = dropdownFocus - wipe(dropdownNavRows) - dropdownKbHighlight:Hide() - local y = -PADDING_TOP - -- Toggle All row - uncheckRow:ClearAllPoints() - uncheckRow:SetPoint("TOPLEFT", 8, y) - dropdownNavRows[#dropdownNavRows + 1] = uncheckRow - y = y - ROW_HEIGHT - -- Filter rows - for i, opt in ipairs(UI_FILTER_OPTIONS) do - local row = checkRowsByIndex[i] - row:ClearAllPoints() - row:SetPoint("TOPLEFT", 8, y) - dropdownNavRows[#dropdownNavRows + 1] = row - y = y - ROW_HEIGHT - -- Map sub-rows - if row.mapSubRows then - 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 - 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() - 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() - 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() - 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() - end + 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, 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 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 quickFilterHelp = self._quickFilterSuggestionsActive and 1 or 0 + 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 last.quickFilterHelp == quickFilterHelp + and resultsFrame:IsShown() + if same then + for hi = 1, n do + local e = hierarchical[hi] + 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 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) + 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.above = above + last.collapsedKey = collapsedKey + last.fontScale = fontScale + 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 + 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 * 3 + 1, #last do last[i] = nil end + end + + ClearResultTooltips() + + local theme = GetActiveTheme() + 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 = 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 + 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 - -- Keyboard navigation for the dropdown - Utils.SafeCallMethod(dropdown, "EnableKeyboard", false) - Utils.SafeCallMethod(dropdown, "SetPropagateKeyboardInput", false) + -- 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.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 * fontScale * iconScale + 0.5) + if rowIconSize < 12 then rowIconSize = 12 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 - dropdown:SetScript("OnKeyDown", function(self, key) - Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) - if key == "DOWN" then - searchFrame.StartKeyRepeat(key, function() - local next = dropdownFocus + 1 - if next > #dropdownNavRows then next = 1 end - SetDropdownFocus(next) - end) - elseif key == "UP" then - if dropdownFocus <= 1 then - self._escapedViaKeyboard = true - self:Hide() - return - end - searchFrame.StartKeyRepeat(key, function() - local prev = dropdownFocus - 1 - if prev < 1 then prev = 1 end - SetDropdownFocus(prev) - end) - elseif key == "ENTER" then - local target = dropdownNavRows[dropdownFocus] - if target and target.Click then - target:Click() + resultsFrame:SetBackdrop(theme.resultsBackdrop) + if theme.resultsBackdropColor then + resultsFrame:SetBackdropColor(unpack(theme.resultsBackdropColor)) + end + if theme.resultsBackdropBorderColor then + resultsFrame:SetBackdropBorderColor(unpack(theme.resultsBackdropBorderColor)) + end + -- 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 + local customW = EasyFind.db.uiResultsWidth + resultsFrame:SetWidth((customW and customW > 1) and customW or theme.resultsWidth) + end + + if theme.resultsBgAtlas then + resultsFrame.bgAtlasTex:SetAtlas(theme.resultsBgAtlas, false) + resultsFrame.bgAtlasTex:Show() + resultsFrame:SetClipsChildren(true) + else + resultsFrame.bgAtlasTex:Hide() + resultsFrame:SetClipsChildren(false) + end + + wipe(SCRATCH.visible) + local visible = SCRATCH.visible + local visibleN = 0 + local skipBelowDepth = nil + local skipPins = false + + for hi = 1, #hierarchical do + local entry = hierarchical[hi] + local d = entry.depth or 0 + + if skipBelowDepth then + if d <= skipBelowDepth then + skipBelowDepth = nil end - elseif key == "ESCAPE" then - self._escapedViaKeyboard = true - self:Hide() - else - Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) end - end) - dropdown:SetScript("OnKeyUp", function(_, key) - if searchFrame.IsRepeatKey(key) then searchFrame.StopKeyRepeat() end - end) - -- (keyboard OnShow/OnHide hooks moved after SetScript calls below) + if not (skipPins and entry.isPinned) and not skipBelowDepth then + if skipPins and not entry.isPinned then + skipPins = false + end + visibleN = visibleN + 1 + visible[visibleN] = entry - -- Uncheck All: toggles all checkboxes off, or all back on if already all unchecked - 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 + if entry.isPathNode then + local key = entry.name .. "_" .. d + if collapsedNodes[key] then + skipBelowDepth = d + end end end - local newState = allUnchecked - for _, opt in ipairs(UI_FILTER_OPTIONS) do - filters[opt.key] = newState - checkRows[opt.key]:SetChecked(newState) - end - local mapRow = checkRows["map"] - if mapRow and mapRow.updateMapToggle then mapRow.updateMapToggle() end - local lootRow = checkRows["loot"] - if lootRow and lootRow.updateLootToggle then lootRow.updateLootToggle() end - LayoutDropdown() - if searchEditBox:GetText() ~= "" then - UI:OnSearchTextChanged(searchEditBox:GetText()) - end - end) - - LayoutDropdown() + end - dropdown:SetScript("OnShow", function(self) - 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 + local pinSlots = 0 + for vi = 1, visibleN do + local entry = visible[vi] + if entry.isPinHeader or entry.isPinned then + pinSlots = pinSlots + 1 end - LayoutDropdown() - end) + end - dropdown:SetScript("OnHide", function() end) + local count = mmin(visibleN, MAX_BUTTON_POOL) + local bypassSearchRowCap = self._quickFilterSuggestionsActive + if not bypassSearchRowCap and pinSlots < visibleN then + count = mmin(count, pinSlots + MAX_SEARCH_RESULT_ROWS) + end - -- Keyboard: enable when opened via Enter on filter button - dropdown:HookScript("OnShow", function(self) - if searchFrame.filterBtn and searchFrame.filterBtn.keyboardFocused then - dropdownKeyboardMode = true - Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) - Utils.SafeCallMethod(self, "EnableKeyboard", true) - SetDropdownFocus(1) - end - end) + local maxVisibleHeight = EasyFind.db.uiResultsHeight or 280 + local scrollInset = 0 - -- Keyboard: cleanup on hide - dropdown:HookScript("OnHide", function(self) - ClearDropdownFocus() - Utils.SafeCallMethod(self, "EnableKeyboard", false) - if self._escapedViaKeyboard then - self._escapedViaKeyboard = nil - dropdownKeyboardMode = false - Utils.SafeCallMethod(navFrame, "EnableKeyboard", true) - else - dropdownKeyboardMode = false - if searchFrame.ClearToolbarFocus then searchFrame.ClearToolbarFocus() end - Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) - if searchFrame.filterBtn then - searchFrame.filterBtn.keyboardFocused = nil - if searchFrame.filterBtn.btnBg then searchFrame.filterBtn.btnBg:Hide() end - if searchFrame.filterBtn.UnlockHighlight then searchFrame.filterBtn:UnlockHighlight() end - end - if searchFrame.editBox and not searchFrame.editBox:IsMouseOver() then - searchFrame.editBox:ClearFocus() + wipe(SCRATCH.isLastChild) + local isLastChild = SCRATCH.isLastChild + for i = 1, count do + local d = visible[i].depth or 0 + if d > 0 then + local foundSibling = false + for j = i + 1, count do + local dj = visible[j].depth or 0 + if dj < d then break end + if dj == d then foundSibling = true; break end end + isLastChild[i] = not foundSibling end - end) + end - -- Close when clicking outside (but not when interacting with spec/class flyouts) - dropdown:SetScript("OnUpdate", function(self) - if self:IsShown() and IsMouseButtonDown("LeftButton") then - 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 - end - self:Hide() - end + -- Determine pin separator placement + local PIN_SEP_HEIGHT = 9 -- 4px gap + 1px line + 4px gap + local lastPinIndex = 0 + local hasResultsAfterPins = false + for i = 1, count do + if visible[i].isPinHeader or visible[i].isPinned then + lastPinIndex = i end - end) + end + if lastPinIndex > 0 and lastPinIndex < count then + hasResultsAfterPins = true + end - -- Toggle on filter button click - toggleBtn:SetScript("OnClick", function() - if dropdown:IsShown() then - dropdown:Hide() - else - local barScale = (EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0) - dropdown:SetScale(barScale) - local scale = anchorFrame:GetEffectiveScale() / (UIParent:GetEffectiveScale() * barScale) - local right = anchorFrame:GetRight() * scale - local bottom = anchorFrame:GetBottom() * scale - dropdown:ClearAllPoints() - dropdown:SetPoint("TOPRIGHT", UIParent, "BOTTOMLEFT", right, bottom) - dropdown:Show() - end - end) + local yOffset = 0 + local pinEndYOffset = 0 + local showShortcutHints = ShouldShowResultShortcutHints() + wipe(SCRATCH.catSepYPositions) + local catSepYPositions = SCRATCH.catSepYPositions + local hasSideBySideRepBar = false + for i = 1, MAX_BUTTON_POOL do + 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 - searchFrame.filterDropdown = dropdown -end + -- Pin separator gap: add once at the transition row + if hasResultsAfterPins and i == lastPinIndex + 1 then + pinEndYOffset = yOffset + yOffset = yOffset + PIN_SEP_HEIGHT + end -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:SetFrameLevel(searchFrame:GetFrameLevel() + 1) + -- 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 + end - 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 } - }) + -- 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 + 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 + 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) + resultRow:ClearAllPoints() + resultRow:SetPoint("TOPLEFT", resultsFrame.scrollChild, "TOPLEFT", padL, -yOffset) - resultsFrame:Hide() + -- 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 + 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.calcActionBar then resultRow.calcActionBar:Hide() end - local resizeTimer - resultsFrame:SetScript("OnSizeChanged", function() - if not resultsFrame:IsShown() or not cachedHierarchical then return end -- luacheck: ignore 113 - if resizeTimer then resizeTimer:Cancel() end - resizeTimer = C_Timer.NewTimer(0.02, function() - resizeTimer = nil - UI:ShowHierarchicalResults(cachedHierarchical, true) -- luacheck: ignore 113 - end) - 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 + -- 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 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]" + elseif data and data.outfitID then + newType, newKey, newVal = "action", "action", 0 + 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" + 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 + 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 + 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 + resultRow.isSectionHeader = entry.isSectionHeader or false + resultRow.isPinHeader = entry.isPinHeader or false + resultRow.isPinned = entry.isPinned or false + resultRow.pathNodeName = entry.isPathNode and entry.name or nil + resultRow.pathNodeDepth = entry.isPathNode and depth 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 + 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) - -- 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 + for d = 1, MAX_DEPTH do + resultRow.treeVert[d]:Hide() + resultRow.treeElbow[d]:Hide() + resultRow.treeBranch[d]:Hide() + end - local scrollChild = CreateFrame("Frame", nil, scrollFrame) - scrollFrame:SetScrollChild(scrollChild) - resultsFrame.scrollChild = scrollChild + if theme.showTreeLines and depth > 0 then + local halfRow = rowH * 0.5 + local lineColor = theme.indentColors[depth] or theme.indentColors[1] or INDENT_COLORS[depth] + local xCenter = (depth - 1) * INDENT_PX + LINE_X_OFF - -- Minimal retail-style scrollbar (overlays right edge, no content squish) - resultsFrame.scrollBar = ns.Utils.CreateMinimalScrollBar(scrollFrame, resultsFrame) + resultRow.treeElbow[depth]:SetColorTexture(lineColor[1], lineColor[2], lineColor[3], 1) + resultRow.treeElbow[depth]:ClearAllPoints() + resultRow.treeElbow[depth]:SetPoint("TOP", resultRow, "TOPLEFT", xCenter, 3) + resultRow.treeElbow[depth]:SetHeight(halfRow + 2) + resultRow.treeElbow[depth]:Show() - for i = 1, MAX_BUTTON_POOL do - local resultRow = self:CreateResultButton(i) - resultButtons[i] = resultRow - end + resultRow.treeBranch[depth]:SetColorTexture(lineColor[1], lineColor[2], lineColor[3], 1) + resultRow.treeBranch[depth]:ClearAllPoints() + resultRow.treeBranch[depth]:SetPoint("LEFT", resultRow, "TOPLEFT", xCenter - 1, -halfRow) + resultRow.treeBranch[depth]:SetPoint("RIGHT", resultRow, "TOPLEFT", xCenter + INDENT_PX - LINE_X_OFF, -halfRow) + resultRow.treeBranch[depth]:Show() - -- 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) - pinSeparator:Hide() - resultsFrame.pinSeparator = pinSeparator + if not isLastChild[i] then + resultRow.treeVert[depth]:SetColorTexture(lineColor[1], lineColor[2], lineColor[3], 1) + resultRow.treeVert[depth]:ClearAllPoints() + resultRow.treeVert[depth]:SetPoint("TOP", resultRow, "TOPLEFT", xCenter, 3) + resultRow.treeVert[depth]:SetPoint("BOTTOM", resultRow, "BOTTOMLEFT", xCenter, -1) + resultRow.treeVert[depth]:Show() + end + + for d = 1, depth - 1 do + local stillActive = false + for j = i + 1, count do + local siblingDepth = visible[j].depth or 0 + if siblingDepth < d then break end + if siblingDepth == d then stillActive = true; break end + end + if stillActive then + local ancestorColor = theme.indentColors[d] or theme.indentColors[1] or INDENT_COLORS[d] + local ancestorX = (d - 1) * INDENT_PX + LINE_X_OFF + resultRow.treeVert[d]:SetColorTexture(ancestorColor[1], ancestorColor[2], ancestorColor[3], 1) + resultRow.treeVert[d]:ClearAllPoints() + resultRow.treeVert[d]:SetPoint("TOP", resultRow, "TOPLEFT", ancestorX, 3) + resultRow.treeVert[d]:SetPoint("BOTTOM", resultRow, "BOTTOMLEFT", ancestorX, -1) + resultRow.treeVert[d]:Show() + end + end + end + + resultRow._isMatch = entry.isMatch and entry.isPathNode + if entry.isPinHeader then + -- Pin header: plain text + toggle icon + underline (no tab/gradient) + 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() + resultRow.pinHeaderLine:Show() + resultRow.text:ClearAllPoints() + resultRow.text:SetPoint("LEFT", resultRow, "LEFT", 2, 0) + resultRow.text:SetPoint("RIGHT", resultRow.pinToggle, "LEFT", -4, 0) + 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 + -- 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. + 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() + 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 and resultRow.headerTab then + local tabInset = depth * indPx + resultRow.headerTab:ClearAllPoints() + resultRow.headerTab:SetPoint("TOPLEFT", resultRow, "TOPLEFT", tabInset, 0) + resultRow.headerTab:SetPoint("BOTTOMRIGHT", resultRow, "BOTTOMRIGHT", 0, 0) + resultRow.headerTab:Show() + -- Set +/- atlas and header name on the tab + local key = entry.name .. "_" .. depth + local isCollapsed = collapsedNodes[key] + local expandAtlas = theme.expandAtlas or "QuestLog-icon-expand" + local collapseAtlas = theme.collapseAtlas or "QuestLog-icon-shrink" + local toggleAtlas = isCollapsed and expandAtlas or collapseAtlas + resultRow.toggleIcon:SetAtlas(toggleAtlas) + resultRow.tabText:ClearAllPoints() + resultRow.tabText:SetPoint("LEFT", resultRow.headerTab, "LEFT", 10, 0) + resultRow.tabText:SetPoint("RIGHT", resultRow.toggleBtn, "LEFT", -4, 0) + resultRow.tabText:SetText(entry.name) + -- Matched path nodes get gold text; non-matches stay muted gray + if resultRow._isMatch then + resultRow.tabText:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) -- gold + else + resultRow.tabText:SetTextColor(0.60, 0.58, 0.55, 1.0) -- muted gray + end + -- Normal icon/text hidden - SetRowIcon("hidden") handles icon below + resultRow.text:SetText("") + if resultRow.headerGrad then resultRow.headerGrad:Hide() end + else + if resultRow.headerTab then resultRow.headerTab:Hide() end + -- Gradient header (Classic fallback) + local showGrad = theme.showHeaderBar and entry.isPathNode + 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 + if resultRow.headerGrad then resultRow.headerGrad:SetShown(showGrad) end + end - -- Category separator lines (between result category groups) - local categorySeps = {} - for sepIdx = 1, 6 do - local sep = scrollChild:CreateTexture(nil, "ARTWORK") - sep:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.3) - sep:SetHeight(1.5) - sep:Hide() - categorySeps[sepIdx] = sep - end - resultsFrame.categorySeps = categorySeps -end + -- Separator line between rows (skip for pin header which has its own underline) + -- Separator is anchored at BOTTOM of the row (line below this row). + if not entry.isPinHeader and theme.showSeparators then + local sc = theme.separatorColor + resultRow.separator:SetColorTexture(sc[1], sc[2], sc[3], sc[4]) + resultRow.separator:Show() + else + resultRow.separator:Hide() + end --- Vibrant indent line colors for each depth level (used by Classic theme) -local INDENT_COLORS = THEMES["Classic"].indentColors + -- Check if this is a currency that hasn't been discovered yet + -- (not just quantity == 0, but truly never earned/discovered) + -- Runs for ALL currency nodes regardless of theme + local isUnearnedCurrency = false + if data and data.category == "Currency" then + if entry.isPathNode then + -- For parent currency nodes, check if ALL children are unearned + -- Look ahead in the visible list to find children + local hasAnyEarnedChild = false + local hasAnyChild = false + for j = i + 1, count do + local childEntry = visible[j] + local childDepth = childEntry.depth or 0 + -- Stop when we leave this parent's subtree + if childDepth <= depth then + break + end + -- Only check immediate children at depth + 1 + if childDepth == depth + 1 and childEntry.data and childEntry.data.steps then + hasAnyChild = true + for _, step in ipairs(childEntry.data.steps) do + if step.currencyID then + local currencyInfo = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo(step.currencyID) + if currencyInfo and (currencyInfo.quantity > 0 or + (currencyInfo.totalEarned and currencyInfo.totalEarned > 0) or + currencyInfo.useTotalEarnedForMaxQty or + currencyInfo.discovered == true) then + hasAnyEarnedChild = true + break + end + end + end + if hasAnyEarnedChild then break end + end + end + -- If we found children but NONE are earned, mark parent as unearned + if hasAnyChild and not hasAnyEarnedChild then + isUnearnedCurrency = true + end + elseif data.steps then + -- For leaf currency nodes, check the currency itself + for _, step in ipairs(data.steps) do + if step.currencyID then + local currencyInfo = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo(step.currencyID) + if currencyInfo and currencyInfo.quantity == 0 then + -- Only mark as unearned if it's never been discovered + local isDiscovered = (currencyInfo.totalEarned and currencyInfo.totalEarned > 0) or + (currencyInfo.useTotalEarnedForMaxQty) or + (currencyInfo.discovered == true) + if not isDiscovered then + isUnearnedCurrency = true + end + end + break + end + end + end + end + resultRow.isUnearnedCurrency = isUnearnedCurrency + resultRow.isPathNode = entry.isPathNode -- Store for tooltip text -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 + if not entry.isPinHeader and not entry.isSectionHeader and not (theme.showHeaderTab and entry.isPathNode) then + local indentPixels = depth * indPx + resultRow.icon:ClearAllPoints() --- 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 + 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 - 16 + 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() + 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 --- Reusable tables for grouping results (wiped each search to avoid per-keystroke allocations) -local groupUI, groupMounts, groupToys, groupPets, groupOutfits, groupLoot, groupMap = {}, {}, {}, {}, {}, {}, {} -local uiSectionHeader = { - name = "UI", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} -local mountSectionHeader = { - name = "Mounts", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} -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 lootSectionHeader = { - name = "Loot", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} -local mapSectionHeader = { - name = "Map Search", depth = 0, isPathNode = true, - isMatch = false, isSectionHeader = true, -} + resultRow.text:ClearAllPoints() + resultRow.text:SetPoint("BOTTOMLEFT", leftAnchor, "RIGHT", 6, stackHalfGap) + resultRow.text:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) + 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) + resultRow.pathSubtext:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) + resultRow.pathSubtext:SetText(GetFlatSubtext(data)) + SetScaledFont(resultRow.pathSubtext, theme.leafFont) + resultRow.pathSubtext:SetTextColor(0.55, 0.55, 0.55, 1.0) + resultRow.pathSubtext:Show() + else + resultRow.icon:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) + if resultRow.flatCatIcon then resultRow.flatCatIcon:Hide() 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 + resultRow.text:ClearAllPoints() + resultRow.text:SetPoint("LEFT", resultRow.icon, "RIGHT", 4, 0) + resultRow.text:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) - local children = ns.Database:GetContainerChildren(entry.data) - if #children == 0 then return end + if resultRow.pathSubtext then + resultRow.pathSubtext:Hide() + 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 + -- Style: path nodes vs leaf results, themed + if entry.isPathNode then + 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 + resultRow.text:SetTextColor(unpack(theme.pathColor)) + end + elseif isUnearnedCurrency then + -- Gray out unearned currencies + SetScaledFont(resultRow.text, theme.leafFont) + resultRow.text:SetTextColor(0.5, 0.5, 0.5, 1.0) + elseif entry.isMatch then + SetScaledFont(resultRow.text, theme.leafFont) + resultRow.text:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) -- gold for matches + else + SetScaledFont(resultRow.text, theme.leafFont) + resultRow.text:SetTextColor(unpack(theme.leafColor)) end + SetClippedText(resultRow.text, entry.name) end - if match and #dbItem.path >= #fp then - childIsContainer = true; break - end + else + if resultRow.pathSubtext then resultRow.pathSubtext:Hide() end + if resultRow.flatCatIcon then resultRow.flatCatIcon:Hide() 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 + 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 - expandedContainers[key] = true - entry.isContainer = nil -- no longer needs lazy expansion -end + 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 -function UI:CreateResultButton(index) - local scrollChild = resultsFrame.scrollChild - local resultRow = CreateFrame("Button", "EasyFindResultButton"..index, scrollChild, "SecureActionButtonTemplate") - resultRow:SetSize(360, 22) - resultRow:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 10, -8 - (index - 1) * 22) + elseif entry.isPinHeader then + -- Pin header: no row icon (toggle is handled by pinToggle) + SetRowIcon(resultRow, "hidden", nil, rowIconSize) + iconSet = true - 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 - - -- 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 + elseif theme.showHeaderTab and entry.isPathNode then + SetRowIcon(resultRow, "hidden", nil, rowIconSize) + iconSet = true - -- 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) - separator:SetHeight(1) - separator:SetPoint("BOTTOMLEFT", resultRow, "BOTTOMLEFT", 4, 0) - separator:SetPoint("BOTTOMRIGHT", resultRow, "BOTTOMRIGHT", -4, 0) - separator:Hide() - resultRow.separator = separator + elseif entry.isPathNode then + local key = entry.name .. "_" .. depth + local nodeCollapsed = collapsedNodes[key] + local iconPath = nodeCollapsed and theme.expandIcon or theme.collapseIcon + SetRowIcon(resultRow, "path", iconPath, theme.pathIconSize) + iconSet = true + end - -- 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 - EasyFind.db.pinsCollapsed = not EasyFind.db.pinsCollapsed - if cachedHierarchical then - UI:ShowHierarchicalResults(cachedHierarchical, true) - end - 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) + -- Resolve currency icon on the fly if not cached + if not iconSet and isCurrencyItem and data and not data.icon and data.steps then + for si = #data.steps, 1, -1 do + local cid = data.steps[si].currencyID + if cid then + if C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo then + local ok, ci = pcall(C_CurrencyInfo.GetCurrencyInfo, cid) + if ok and ci and ci.iconFileID and ci.iconFileID ~= 0 then + data.icon = ci.iconFileID + end + end 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) + -- Currency leaves: icon goes right of amount, not left of name + if isCurrencyLeaf and data and data.steps then + local quantity, iconFileID + for si = #data.steps, 1, -1 do + local cid = data.steps[si].currencyID + if cid and C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo then + local ok, ci = pcall(C_CurrencyInfo.GetCurrencyInfo, cid) + if ok and ci then + quantity = ci.quantity + iconFileID = data.icon or (ci.iconFileID ~= 0 and ci.iconFileID) or nil + end + break + end 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 - resultRow.treeElbow = {} -- vertical half-line for └ / ├ + if quantity then + resultRow.amountText:SetText(tostring(quantity)) + if isUnearnedCurrency then + resultRow.amountText:SetTextColor(0.5, 0.5, 0.5, 1.0) + else + resultRow.amountText:SetTextColor(0.9, 0.82, 0.65, 1.0) + end + resultRow.amountText:Show() + else + resultRow.amountText:Hide() + end - for d = 1, MAX_DEPTH do - local c = INDENT_COLORS[d] - local xCenter = (d - 1) * INDENT_PX + LINE_X_OFF + -- Move icon to right side (right of amount text) + if iconFileID then + resultRow.icon:SetTexture(nil) + resultRow.icon:SetTexCoord(0, 1, 0, 1) + resultRow.icon:SetTexture(iconFileID) + resultRow.icon:SetSize(rowIconSize, rowIconSize) + resultRow.icon:ClearAllPoints() + resultRow.icon:SetPoint("RIGHT", resultRow, "RIGHT", -5, 0) + resultRow.icon:Show() + resultRow.amountText:ClearAllPoints() + resultRow.amountText:SetPoint("RIGHT", resultRow.icon, "LEFT", -3, 0) + else + SetRowIcon(resultRow, "hidden", nil, rowIconSize) + resultRow.amountText:ClearAllPoints() + resultRow.amountText:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) + end - local vert = resultRow:CreateTexture(nil, "BACKGROUND") - vert:SetColorTexture(c[1], c[2], c[3], 1) - vert:SetWidth(LINE_W) - vert:SetPoint("TOP", resultRow, "TOPLEFT", xCenter, 3) - vert:SetPoint("BOTTOM", resultRow, "BOTTOMLEFT", xCenter, -1) - vert:Hide() - resultRow.treeVert[d] = vert + -- 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 - 16) or rowIconSize + if catIconDef.atlas then + resultRow.flatCatIcon:SetAtlas(catIconDef.atlas) + 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) + resultRow.flatCatIcon:Show() + leftAnchor = resultRow.flatCatIcon + end - local elbow = resultRow:CreateTexture(nil, "BACKGROUND") - elbow:SetColorTexture(c[1], c[2], c[3], 1) - elbow:SetWidth(LINE_W) - elbow:SetPoint("TOP", resultRow, "TOPLEFT", xCenter, 3) - elbow:SetHeight(13) - elbow:Hide() - resultRow.treeElbow[d] = elbow + 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 - local branch = resultRow:CreateTexture(nil, "BACKGROUND") - branch:SetColorTexture(c[1], c[2], c[3], 1) - branch:SetHeight(LINE_W) - branch:SetPoint("LEFT", resultRow, "TOPLEFT", xCenter - 1, -11) - branch:SetPoint("RIGHT", resultRow, "TOPLEFT", xCenter + INDENT_PX - LINE_X_OFF, -11) - branch:Hide() - resultRow.treeBranch[d] = branch - end + -- 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 icon = resultRow:CreateTexture(nil, "ARTWORK") - icon:SetSize(16, 16) - icon:SetPoint("LEFT", 0, 0) - resultRow.icon = icon + 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 - -- Cooldown sweep overlay for toy icons - local iconCooldown = CreateFrame("Cooldown", nil, resultRow, "CooldownFrameTemplate") - iconCooldown:SetDrawEdge(true) - iconCooldown:SetHideCountdownNumbers(true) - iconCooldown:Hide() - resultRow.iconCooldown = iconCooldown + -- 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 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.calcActionBar, "TOPRIGHT", 0, 4) + 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) - -- Pin indicator (small map pin badge on the icon) - 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 + iconSet = true - -- Pin header toggle icon (expand/collapse, right-aligned on the button itself) - local pinToggle = resultRow:CreateTexture(nil, "ARTWORK") - pinToggle:SetSize(14, 14) - pinToggle:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) - pinToggle:SetAtlas("QuestLog-icon-shrink") - pinToggle:Hide() - resultRow.pinToggle = pinToggle + 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) - -- Pin header underline (thin golden line below the header text) - local pinHeaderLine = resultRow:CreateTexture(nil, "ARTWORK") - pinHeaderLine:SetHeight(1) - pinHeaderLine:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.4) - pinHeaderLine:SetPoint("BOTTOMLEFT", resultRow, "BOTTOMLEFT", 0, 0) - pinHeaderLine:SetPoint("BOTTOMRIGHT", resultRow, "BOTTOMRIGHT", 0, 0) - pinHeaderLine:Hide() - resultRow.pinHeaderLine = pinHeaderLine + resultRow.calcActionBar:ClearAllPoints() + resultRow.calcActionBar:SetPoint("TOPLEFT", resultRow, "TOPLEFT", 4, -3) + resultRow.calcActionBar:SetPoint("BOTTOMRIGHT", resultRow, "BOTTOMRIGHT", -4, 3) + resultRow.calcActionBar:Show() - -- Right-aligned currency amount label - local amountText = resultRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - amountText:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) - amountText:SetJustifyH("RIGHT") - amountText:SetTextColor(0.9, 0.82, 0.65, 1.0) - amountText:Hide() - resultRow.amountText = amountText + iconSet = true - -- Right-aligned reputation standing bar - -- Structure: repBar (dark bg + border) → repClip (clips fill) → repFillFrame (colored, same shape) - -- repBar → repTextOverlay (text on top of everything) - local repBarBackdrop = { - bgFile = "Interface\\BUTTONS\\WHITE8X8", - edgeFile = TOOLTIP_BORDER, - tile = true, tileSize = 8, edgeSize = 14, - insets = { left = 3, right = 3, top = 3, bottom = 3 }, - } + -- 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 + local rightOffset = -5 - local repBar = CreateFrame("Frame", nil, resultRow, BackdropTemplateMixin and "BackdropTemplate") - repBar:SetSize(REP_BAR_WIDTH, 19) - repBar:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) - if repBar.SetBackdrop then - repBar:SetBackdrop(repBarBackdrop) - repBar:SetBackdropColor(0.06, 0.06, 0.06, 1.0) - repBar:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.8) - end - repBar:Hide() - resultRow.repBar = repBar + if iconFileID then + 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) + resultRow.icon:Show() + resultRow.icon.mountID = data.mountID + resultRow.icon.toyItemID = data.toyItemID + 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 + resultRow.icon:SetVertexColor(1, 0.3, 0.3, 1) + elseif data.outfitID then + local activeID = lastEquippedOutfitID + or (C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetActiveOutfitID + and C_TransmogOutfitInfo.GetActiveOutfitID()) + if activeID and activeID == data.outfitID then + resultRow.icon:SetVertexColor(0.3, 1, 0.3, 1) + else + 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 + else + SetRowIcon(resultRow, "hidden", nil, rowIconSize) + end - -- Clip frame controls how much of the fill is visible (left→right) - local repClip = CreateFrame("Frame", nil, repBar) - repClip:SetPoint("TOPLEFT", repBar, "TOPLEFT", 0, 0) - repClip:SetPoint("BOTTOMLEFT", repBar, "BOTTOMLEFT", 0, 0) - repClip:SetWidth(REP_BAR_WIDTH) - repClip:SetClipsChildren(true) - resultRow.repClip = repClip + -- Outfit lock overlay (dashed border when locked) + if data.outfitID and C_TransmogOutfitInfo and C_TransmogOutfitInfo.IsLockedOutfit then + UI:UpdateOutfitLockOverlay(resultRow, C_TransmogOutfitInfo.IsLockedOutfit(data.outfitID)) + elseif resultRow._lockOverlay then + resultRow._lockOverlay:Hide() + end - -- 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) - if repFill.SetBackdrop then - repFill:SetBackdrop(repBarBackdrop) - repFill:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.8) - end - resultRow.repFill = repFill + -- Cooldown sweep overlay (toys, abilities, outfits) + resultRow.amountText:Hide() + if data.toyItemID and iconFileID and GetItemCooldown then + local startTime, duration = GetItemCooldown(data.toyItemID) + if startTime and duration and duration > 0 then + resultRow.iconCooldown:SetAllPoints(resultRow.icon) + resultRow.iconCooldown:SetCooldown(startTime, duration) + resultRow.iconCooldown:Show() + 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 + resultRow.iconCooldown:SetAllPoints(resultRow.icon) + resultRow.iconCooldown:SetCooldown(outfitCdStart, outfitCdDuration) + resultRow.iconCooldown:Show() + else + resultRow.iconCooldown:Hide() + end + else + resultRow.iconCooldown:Hide() + end - -- Glossy bar texture (same as WoW default bars); backdrop bgColor matches fill - -- color so the flat corners blend seamlessly with the glossy center - local repBarTex = repFill:CreateTexture(nil, "ARTWORK") - repBarTex:SetPoint("TOPLEFT", repFill, "TOPLEFT", 3, -3) - repBarTex:SetPoint("BOTTOMRIGHT", repFill, "BOTTOMRIGHT", -3, 3) - repBarTex:SetTexture("Interface\\TargetingFrame\\UI-StatusBar") - resultRow.repBarTex = repBarTex + local indentPixels = depth * indPx + 4 + resultRow.text:ClearAllPoints() + resultRow.text:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) + resultRow.text:SetPoint("RIGHT", resultRow.icon, "LEFT", -4, 0) + iconSet = true - -- Text overlay above everything (not clipped) - local repTextOverlay = CreateFrame("Frame", nil, repBar) - repTextOverlay:SetAllPoints() - repTextOverlay:SetFrameLevel(repFill:GetFrameLevel() + 3) - local repBarText = repTextOverlay:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - repBarText:SetPoint("CENTER", repBar, "CENTER", 0, 0) - repBarText:SetTextColor(1.0, 1.0, 1.0, 1.0) - repBarText:SetShadowOffset(1, -1) - resultRow.repBarText = repBarText + -- Loot items: icon on right with source name inline + elseif not iconSet and data and data.itemID and data.category == "Loot" then + local iconFileID = data.icon + if iconFileID then + resultRow.icon:SetTexture(nil) + resultRow.icon:SetTexCoord(0, 1, 0, 1) + resultRow.icon:SetTexture(iconFileID) + resultRow.icon:SetSize(rowIconSize, rowIconSize) + resultRow.icon:ClearAllPoints() + 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, rowIconSize) + end + resultRow.amountText:Hide() + resultRow.iconCooldown:Hide() + if data.lootSourceName then + resultRow.text:SetText(data.name .. " |cff888888" .. data.lootSourceName .. "|r") + end + local indentPixels = depth * indPx + 4 + resultRow.text:ClearAllPoints() + resultRow.text:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) + resultRow.text:SetPoint("RIGHT", resultRow.icon, "LEFT", -4, 0) + iconSet = true - local text = resultRow:CreateFontString(nil, "OVERLAY", "GameFontNormal") - text:SetPoint("LEFT", icon, "RIGHT", 4, 0) - text:SetPoint("RIGHT", amountText, "LEFT", -4, 0) - text:SetJustifyH("LEFT") - resultRow.text = text + -- 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 + if mapIcon then + resultRow.icon:SetTexture(nil) + resultRow.icon:SetTexCoord(0, 1, 0, 1) + resultRow.icon:SetVertexColor(1, 1, 1, 1) + if type(mapIcon) == "table" then + resultRow.icon:SetTexture(mapIcon.file) + local c = mapIcon.coords + resultRow.icon:SetTexCoord(c[1], c[2], c[3], c[4]) + elseif type(mapIcon) == "string" and sfind(mapIcon, "^atlas:") then + resultRow.icon:SetAtlas(mapIcon:sub(7)) + else + resultRow.icon:SetTexture(mapIcon) + end + resultRow.icon:SetSize(rowIconSize, rowIconSize) + resultRow.icon:ClearAllPoints() + resultRow.icon:SetPoint("RIGHT", resultRow, "RIGHT", -5, 0) + resultRow.icon:Show() + else + 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 + iconSet = true - resultRow:RegisterForClicks("LeftButtonDown", "RightButtonUp") - -- PreClick: find an empty action slot, place the outfit on it, - -- so the secure handler's UseAction can equip it. - resultRow:SetScript("PreClick", function(self, mouseButton) - if mouseButton ~= "LeftButton" then return end - local outfitID = self.data and self.data.outfitID - if not outfitID then return end - -- 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 - return - end - self._outfitSlot = tempSlot - self._outfitID = outfitID - if not InCombatLockdown() then - self:SetAttribute("action", tempSlot) - end - if C_TransmogOutfitInfo and C_TransmogOutfitInfo.PickupOutfit then - C_TransmogOutfitInfo.PickupOutfit(outfitID) - PlaceAction(tempSlot) - ClearCursor() - -- Verify placement succeeded (some slots reject non-class actions) - if not HasAction(tempSlot) then - self._outfitSlot = nil - self._outfitID = nil - if not InCombatLockdown() then - self:SetAttribute("type", nil) - self:SetAttribute("action", nil) + -- 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() + resultRow.amountText:ClearAllPoints() + resultRow.amountText:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) 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:SetFocus() + + -- Reputation bar: show on leaves and on path nodes with actual rep bars + -- (hasRepBar is false for pure grouping headers like Horde, Alliance) + local showRepBar = data and data.factionID and + (isReputationLeaf or (entry.isPathNode and data.category == "Reputation" and data.hasRepBar ~= false)) + if showRepBar then + local fill, standingText, barR, barG, barB + local fid = data.factionID + + -- Priority 1: Renown factions (TWW, Dragonflight, Shadowlands) + if C_MajorFactions and C_MajorFactions.GetMajorFactionData then + local ok, md = pcall(C_MajorFactions.GetMajorFactionData, fid) + if ok and md and md.renownLevel then + local level = md.renownLevel or 0 + standingText = "Renown " .. level + local atMax = C_MajorFactions.HasMaximumRenown + and C_MajorFactions.HasMaximumRenown(fid) + if atMax then + fill = 1.0 + else + local earned = md.renownReputationEarned or 0 + local threshold = md.renownLevelThreshold or 1 + fill = (threshold > 0) and (earned / threshold) or 1.0 + end + barR, barG, barB = 0.0, 0.55, 0.78 + end end - return - end - end - -- Clean up temp action slot after outfit equip. - if self._outfitSlot then - local slot = self._outfitSlot - self._outfitSlot = nil - -- Record equip immediately so green tint and cooldown - -- are correct when results re-render (API lags behind). - if self._outfitID then - lastEquippedOutfitID = self._outfitID - outfitCdStart = GetTime() - outfitCdDuration = 4 - self._outfitID = nil - end - -- Delay slot cleanup one frame so UseAction fully completes - C_Timer.After(0, function() - -- Read actual cooldown duration if available - local start, dur = GetActionCooldown(slot) - if start and dur and dur > 0 then - outfitCdStart, outfitCdDuration = start, dur + -- Priority 2: Friendship factions (Sabellian, Wrathion, etc.) + if not standingText and C_GossipInfo and C_GossipInfo.GetFriendshipReputation then + local ok, fd = pcall(C_GossipInfo.GetFriendshipReputation, fid) + if ok and fd and fd.friendshipFactionID and fd.friendshipFactionID > 0 then + standingText = fd.reaction or "" + local cur = fd.standing or 0 + local minR = fd.reactionThreshold or 0 + local maxR = fd.nextThreshold or 0 + if maxR > minR then + fill = (cur - minR) / (maxR - minR) + elseif cur > 0 then + fill = 1.0 + else + fill = 0.0 + end + barR, barG, barB = 0.0, 0.60, 0.0 + end end - PickupAction(slot) - ClearCursor() - end) - end - -- Right-click: show pin/unpin popup - if mouseButton == "RightButton" and self.data then - local pinData = self.data - local isPinned = IsUIItemPinned(pinData) - ShowPinPopup(self, isPinned, function() - if isPinned then - UnpinUIItem(pinData) - else - PinUIItem(pinData) + + -- Priority 3: Traditional factions (Friendly, Honored, etc.) + if not standingText and C_Reputation and C_Reputation.GetFactionDataByID then + local ok, rd = pcall(C_Reputation.GetFactionDataByID, fid) + if ok and rd and rd.reaction then + local standing = rd.reaction + standingText = _G["FACTION_STANDING_LABEL" .. standing] or "" + local cur = rd.currentStanding or 0 + local minR = rd.currentReactionThreshold or 0 + local maxR = rd.nextReactionThreshold or 0 + if maxR > minR then + fill = (cur - minR) / (maxR - minR) + else + fill = 1.0 + end + local barColor = FACTION_BAR_COLORS and FACTION_BAR_COLORS[standing] + if barColor then + barR, barG, barB = barColor.r, barColor.g, barColor.b + else + barR, barG, barB = 0.5, 0.5, 0.5 + end + end end - local editBox = searchFrame and searchFrame.editBox - local text = editBox and editBox:GetText() or "" - if text == "" and editBox and editBox:HasFocus() then - UI:ShowPinnedItems() + + if standingText then + if fill < 0 then fill = 0 end + if fill > 1 then fill = 1 end + resultRow.repBarTex:SetVertexColor(barR, barG, barB, 1.0) + if resultRow.repFill.SetBackdropColor then + resultRow.repFill:SetBackdropColor(barR, barG, barB, 1.0) + end + resultRow.repClip:SetWidth(mmax(fill * REP_BAR_WIDTH, 0.1)) + resultRow.repBarText:SetText(standingText) + + if entry.isPathNode and theme.showHeaderTab then + -- Tab theme: place rep bar left of the toggle icon + resultRow.repBar:ClearAllPoints() + resultRow.repBar:SetPoint("RIGHT", resultRow.toggleBtn, "LEFT", -4, 0) + resultRow.tabText:ClearAllPoints() + resultRow.tabText:SetPoint("LEFT", resultRow.headerTab, "LEFT", 10, 0) + resultRow.tabText:SetPoint("RIGHT", resultRow.repBar, "LEFT", -4, 0) + elseif entry.isPathNode then + -- Side-by-side by default; IsTruncated() reflects the previous frame's + -- layout, which is accurate for stable results. A deferred re-render + -- corrects it after first render or scale/width changes. + local indentPixels = depth * indPx + 4 + resultRow.repBar:ClearAllPoints() + resultRow.repBar:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) + resultRow.text:ClearAllPoints() + resultRow.text:SetPoint("LEFT", resultRow.icon, "RIGHT", 4, 0) + resultRow.text:SetPoint("RIGHT", resultRow.repBar, "LEFT", -4, 0) + if resultRow.text:IsTruncated() then + resultRow.text:ClearAllPoints() + resultRow.text:SetPoint("TOPLEFT", resultRow, "TOPLEFT", indentPixels, -3) + resultRow.text:SetPoint("TOPRIGHT", resultRow, "TOPRIGHT", -6, -3) + resultRow.repBar:ClearAllPoints() + resultRow.repBar:SetPoint("BOTTOM", resultRow, "BOTTOM", 0, 5) + resultRow:SetHeight(rowH + 25) + else + hasSideBySideRepBar = true + end + else + -- Leaf: side-by-side by default, stack only if text truncates + SetRowIcon(resultRow, "hidden", nil, rowIconSize) + local indentPixels = depth * indPx + 4 + resultRow.repBar:ClearAllPoints() + resultRow.repBar:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) + resultRow.text:ClearAllPoints() + resultRow.text:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) + resultRow.text:SetPoint("RIGHT", resultRow.repBar, "LEFT", -4, 0) + if resultRow.text:IsTruncated() then + resultRow.text:ClearAllPoints() + resultRow.text:SetPoint("TOPLEFT", resultRow, "TOPLEFT", indentPixels, -3) + resultRow.text:SetPoint("TOPRIGHT", resultRow, "TOPRIGHT", -6, -3) + resultRow.repBar:ClearAllPoints() + resultRow.repBar:SetPoint("BOTTOM", resultRow, "BOTTOM", 0, 5) + resultRow:SetHeight(rowH + 25) + else + hasSideBySideRepBar = true + end + iconSet = true + end + resultRow.repBar:Show() else - UI:OnSearchTextChanged(text) + resultRow.repBar:Hide() end - end) - return - end - -- Don't allow clicking unearned currencies - if self.isUnearnedCurrency then - return - end + if not entry.isPathNode then iconSet = true end + else + 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 - -- Pin header: toggle collapse - if self.isPinHeader then - EasyFind.db.pinsCollapsed = not EasyFind.db.pinsCollapsed - if cachedHierarchical then - UI:ShowHierarchicalResults(cachedHierarchical, true) + if not iconSet and data and data.iconAtlas then + SetRowIcon(resultRow, "atlas", data.iconAtlas, rowIconSize) + iconSet = true end - return - end - if self.isPathNode then - -- Retail theme: headerTab and toggleBtn handle clicks directly - local isRetailHeader = self.headerTab and self.headerTab:IsShown() - if isRetailHeader then - if self.data then - UI:SelectResult(self.data) + if not iconSet and data and data.icon then + SetRowIcon(resultRow, "file", data.icon, rowIconSize) + iconSet = true + end + + -- Portrait menu items: use the player portrait as the icon + if not iconSet and data and data.steps then + for _, step in ipairs(data.steps) do + if step.portraitMenu or step.portraitMenuOption then + SetPortraitTexture(resultRow.icon, "player") + resultRow.icon:SetTexCoord(0, 1, 0, 1) + resultRow.icon:SetSize(rowIconSize, rowIconSize) + resultRow.icon:Show() + iconSet = true + break + end end - else - -- Classic: +/- icon on left side - 35px zone from icon start - local cursorX = GetCursorPosition() - local scale = self:GetEffectiveScale() - local btnLeft = self:GetLeft() * scale - local depth = self.pathNodeDepth or 0 - local iconLeft = btnLeft + depth * 20 * scale -- INDENT_PX = 20 - local isToggleClick = cursorX <= (iconLeft + 35 * scale) + end - 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 + -- Resolve sidebar tab icons at runtime (e.g. Equipment Manager, Titles) + -- The tab textures are sprite sheets - copy the ARTWORK-layer texture + -- along with its tex coords so only the icon portion is shown. + if not iconSet and data and data.steps then + for _, step in ipairs(data.steps) do + if step.sidebarIndex then + local tab = _G["PaperDollSidebarTab" .. step.sidebarIndex] + if tab then + 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() + -- Skip render targets (e.g. RTPortrait1 for the player model) + if tex and type(tex) == "string" and tex:find("^RT") then + break + end + if tex then + 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(rowIconSize, rowIconSize) + resultRow.icon:Show() + iconSet = true + end + break + end + end + -- Fallback for render target tabs: use player portrait + if not iconSet then + SetPortraitTexture(resultRow.icon, "player") + resultRow.icon:SetTexCoord(0, 1, 0, 1) + resultRow.icon:SetSize(rowIconSize, rowIconSize) + resultRow.icon:Show() + iconSet = true end end + break end - if cachedHierarchical then - UI:ShowHierarchicalResults(cachedHierarchical, true) - end - elseif self.data then - UI:SelectResult(self.data) end end - elseif self.data then - UI:SelectResult(self.data) - end - end) - -- Tooltip for unearned currencies, mounts, and toys - resultRow:SetScript("OnEnter", function(self) - 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" - 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 - elseif self.data and self.data.mapSearchResult then - -- Map result: preview pin on world map if it happens to be open - if ns.MapSearch and ns.MapSearch.PreviewUIResult then - ns.MapSearch:PreviewUIResult(self.data) + -- Skip buttonFrame fallback for currency items - their inherited + -- "CharacterMicroButton" produces a wrong MicroMenu atlas icon. + if not iconSet and not isCurrencyItem and data and data.buttonFrame then + local texture, isAtlas = GetButtonIcon(data.buttonFrame) + if texture then + local kind = isAtlas and "atlas" or "file" + SetRowIcon(resultRow, kind, texture, rowIconSize) + iconSet = true + end end - elseif self.data and self.icon and self.icon:IsShown() then - -- Mount tooltip (show on icon hover) - if self.icon.mountID and self.icon.spellID then - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - 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") - GameTooltip:SetToyByItemID(toyItemID) - GameTooltip:Show() - self.toyTooltipTicker = C_Timer.NewTicker(1, function() - if GameTooltip:IsOwned(self) then - GameTooltip:SetToyByItemID(toyItemID) + + if not iconSet then + 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(AbbrevBinding(k1)) + kb2:SetText(AbbrevBinding(k2)) + 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 + if resultRow.settingDropdownGroup then resultRow.settingDropdownGroup: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 == "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 - end) - -- Pet tooltip - 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() + 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 - end - -- Outfit tooltip - elseif self.icon.outfitID then - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - GameTooltip:SetText(self.data and self.data.name or "Outfit") - GameTooltip:AddLine("Instant", 1, 1, 1) - GameTooltip:AddLine("Transmogrify the appearance of your\nweapons and armor", 0, 1, 0) - local activeID = lastEquippedOutfitID - or (C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetActiveOutfitID - and C_TransmogOutfitInfo.GetActiveOutfitID()) - if activeID and activeID == self.icon.outfitID then - GameTooltip:AddLine(" ") - GameTooltip:AddLine("Currently equipped", 0.3, 1, 0.3) - else - GameTooltip:AddLine(" ") - GameTooltip:AddLine("Click to equip", 1, 0.82, 0) - end - if C_TransmogOutfitInfo and C_TransmogOutfitInfo.IsLockedOutfit then - GameTooltip:AddLine(" ") - GameTooltip:AddLine("Lock Appearance:", 1, 1, 1) - GameTooltip:AddLine("Prevent this appearance from being\nreplaced by a Situation", 1, 0.82, 0) - if C_TransmogOutfitInfo.IsLockedOutfit(self.icon.outfitID) then - GameTooltip:AddLine(" ") - GameTooltip:AddLine("Currently locked", 0.3, 1, 0.3) + 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 - GameTooltip:AddLine(" ") - GameTooltip:AddLine("", 0.5, 0.5, 0.5) - end - GameTooltip:Show() - -- Loot item tooltip - elseif self.icon.lootItemID then - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - local itemLink = self.data and ns.Database and ns.Database:GetLootItemLink(self.data) - if itemLink then - GameTooltip:SetHyperlink(itemLink) - else - GameTooltip:SetItemByID(self.icon.lootItemID) - end - GameTooltip:Show() - end - end - end) - - resultRow:SetScript("OnLeave", function(self) - if unearnedTooltip then - unearnedTooltip:Hide() - end - if self.toyTooltipTicker then - 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 - GameTooltip: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 - end) - - resultRow:Hide() - return resultRow -end - -function UI:OnSearchTextChanged(text) - -- Suppress re-renders while SelectResult is clearing text/focus - if selectingResult 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 - -- 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 - self:ShowPinnedItems() - else - self:HideResults() - end - return - end - - -- Clear collapse state so every new search starts fully expanded - collapsedNodes = {} - 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. - 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 - skipCategories = {} - if filters.mounts == false then skipCategories["Mount"] = true end - if filters.toys == false then skipCategories["Toy"] = true end - if filters.pets == false then skipCategories["Pet"] = true end - if filters.outfits == false then skipCategories["Outfit"] = true end - if filters.loot == false then skipCategories["Loot"] = true end - end - end - local results = ns.Database:SearchUI(text, skipCategories) - - -- "UI Search" filter: hide results that aren't collection items or map results - if filters and filters.ui == 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.mapSearchResult) then - filtered[#filtered + 1] = r - end - end - results = filtered - end - - -- 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 - mapResults = ns.MapSearch:SearchForUI(text) - end - - -- Compute best score per category from flat results (before hierarchy loses scores) - local bestCatScore = {} - for _, r in ipairs(results) do - local d = r.data - local s = r.score or 0 - local cat - if d.mountID then cat = "mounts" - elseif d.toyItemID then cat = "toys" - elseif d.petID then cat = "pets" - elseif d.outfitID then cat = "outfits" - elseif d.itemID and d.category == "Loot" then cat = "loot" - else cat = "ui" - end - if s > (bestCatScore[cat] or 0) then bestCatScore[cat] = s end - end - if mapResults then - for _, r in ipairs(mapResults) do - local s = r.score or 0 - if s > (bestCatScore.map or 0) then bestCatScore.map = s end - end - end - -- Boost loot category when the query exactly matches a slot name (e.g., "legs", "ring") - if bestCatScore.loot and ns.lootSlotNames then - local queryLower = slower(text) - if ns.lootSlotNames[queryLower] then - bestCatScore.loot = mmax(bestCatScore.loot, 200) - end - end - - local hierarchical = ns.Database:BuildHierarchicalResults(results) - -- Container nodes (search results that have database children which didn't - -- match the query) start collapsed - user can expand to browse children. - 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(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 - else - groupUI[#groupUI + 1] = entry - end - end - -- Map results come from a separate search, wrap them as hierarchical entries - 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, - } - end - end - -- Sort categories by best match score so the most relevant category appears first - local catGroups = {} - if #groupUI > 0 then catGroups[#catGroups + 1] = { key = "ui", score = bestCatScore.ui or 0 } end - if #groupMounts > 0 then catGroups[#catGroups + 1] = { key = "mounts", score = bestCatScore.mounts or 0 } end - if #groupToys > 0 then catGroups[#catGroups + 1] = { key = "toys", score = bestCatScore.toys or 0 } end - if #groupPets > 0 then catGroups[#catGroups + 1] = { key = "pets", score = bestCatScore.pets or 0 } end - if #groupOutfits > 0 then catGroups[#catGroups + 1] = { key = "outfits", score = bestCatScore.outfits or 0 } end - if #groupLoot > 0 then catGroups[#catGroups + 1] = { key = "loot", score = bestCatScore.loot or 0 } end - if #groupMap > 0 then catGroups[#catGroups + 1] = { key = "map", score = bestCatScore.map or 0 } end - tsort(catGroups, function(a, b) - if a.score ~= b.score then return a.score > b.score end - return a.key < b.key - end) + 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) + 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 + 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) + elseif settingType == "slider" and data.settingMin and data.settingMax then + 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._settingFormatter = data.settingFormatter + slider._updating = true + slider:SetMinMaxValues(sMin, sMax) + slider:SetValueStep(stepVal) + slider:SetValue(numVal) + slider._updating = false + resultRow.settingSliderGroup:ClearAllPoints() + resultRow.settingSliderGroup:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) + 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 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) - 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 == "mounts" then - hierarchical[#hierarchical + 1] = mountSectionHeader - for _, e in ipairs(groupMounts) do e.depth = 1; hierarchical[#hierarchical + 1] = e end - elseif cat.key == "toys" then - hierarchical[#hierarchical + 1] = toySectionHeader - for _, e in ipairs(groupToys) do e.depth = 1; hierarchical[#hierarchical + 1] = e end - elseif cat.key == "pets" then - hierarchical[#hierarchical + 1] = petSectionHeader - for _, e in ipairs(groupPets) do e.depth = 1; hierarchical[#hierarchical + 1] = e end - elseif cat.key == "outfits" then - hierarchical[#hierarchical + 1] = outfitSectionHeader - for _, e in ipairs(groupOutfits) do e.depth = 1; hierarchical[#hierarchical + 1] = e end - elseif cat.key == "loot" then - hierarchical[#hierarchical + 1] = lootSectionHeader - local slotGroups = {} - local slotOrder = {} - for _, e in ipairs(groupLoot) do - local slot = (e.data and e.data.lootSlotName) or "Other" - if not slotGroups[slot] then - slotGroups[slot] = {} - slotOrder[#slotOrder + 1] = slot - end - 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 + 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 + 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 not data.settingOptions + and ns.BlizzOptionsSearch and ns.BlizzOptionsSearch.GetOptionsForVariable then + local opts = ns.BlizzOptionsSearch.GetOptionsForVariable(data.settingVariable) + if opts then data.settingOptions = opts end + end + 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 optList and #optList > 0 then + -- Inline dropdown widget: paddle arrows + center + -- button styled like the in-game Settings dropdown. + 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) + 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 - instGroups[inst][#instGroups[inst] + 1] = e + resultRow.settingState:Hide() + resultRow.settingCheck:Hide() + if resultRow.settingSlider then resultRow.settingSliderGroup:Hide() end 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 + else + resultRow.settingState:Hide() + 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) + -- 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.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 - 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 + resultRow.icon:SetSize(flatIconSize, flatIconSize) end end - elseif cat.key == "map" then - hierarchical[#hierarchical + 1] = mapSectionHeader - for _, e in ipairs(groupMap) do hierarchical[#hierarchical + 1] = e end - end - end - -- Prepend pinned items at the top (always visible regardless of query) - local pins = GetAllPins() - if #pins > 0 then - local pinnedEntries = { - -- "Pinned Paths" collapsible header - { - isPinHeader = true, - name = "Pinned Paths", - depth = 0, - isPathNode = true, - isMatch = false, - }, - } - for _, pin in ipairs(pins) do - tinsert(pinnedEntries, { - name = pin.name, - depth = 0, - isPathNode = false, - isMatch = true, - isPinned = true, - data = pin, - }) - 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) - end - hierarchical = pinnedEntries - 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 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 + 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 + if catShown then + leftAnchor = resultRow.flatCatIcon + elseif not mainIconOnRight and resultRow.icon:IsShown() then + leftAnchor = resultRow.icon + end - self:ShowHierarchicalResults(hierarchical) -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.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 + else + rightAnchor = resultRow + rightOffset = -8 + 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 + resultRow.text:ClearAllPoints() + if leftAnchor then + resultRow.text:SetPoint("BOTTOMLEFT", leftAnchor, "RIGHT", 6, stackHalfGap) + else + local flatIndent = depth * indPx + 4 + resultRow.text:SetPoint("BOTTOMLEFT", resultRow, "LEFT", flatIndent, stackHalfGap) + end + if rightAnchor == resultRow then + resultRow.text:SetPoint("RIGHT", resultRow, "RIGHT", rightOffset, 0) + else + resultRow.text:SetPoint("RIGHT", rightAnchor, "LEFT", rightOffset, 0) + end - -- For MicroButtons - use the textureName property to build atlas - -- Atlas format: "UI-HUD-MicroMenu--Up" - if frame.textureName then - local atlas = "UI-HUD-MicroMenu-" .. frame.textureName .. "-Up" - return atlas, true -- true means it's an atlas - end + resultRow.pathSubtext:ClearAllPoints() + resultRow.pathSubtext:SetPoint("TOPLEFT", resultRow.text, "BOTTOMLEFT", 0, -stackGap) + 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 - -- MicroButtons without textureName (e.g. CharacterMicroButton) use a portrait - -- render texture that produces garbage when captured. Skip region scanning for these. - if frame.IsMicroButton or (frameName and frameName:find("MicroButton")) then - return nil - end + -- Show pin indicator for pinned entries + if entry.isPinned and resultRow.pinIcon then + resultRow.pinIcon:ClearAllPoints() + resultRow.pinIcon:SetPoint("RIGHT", resultRow.text, "LEFT", 0, 0) + resultRow.pinIcon:Show() + -- Pinned entries during search: show path prefix in name + -- (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 + end - -- Try common icon region names - local iconRegions = {"Icon", "icon", "NormalTexture", "normalTexture"} - for _, regionName in ipairs(iconRegions) do - local region = frame[regionName] - if region and region.GetTexture then - local texture = region:GetTexture() - if texture then - return texture + -- 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) + 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) end - end - end - -- Fallback: iterate through regions - for i = 1, select("#", frame:GetRegions()) do - local region = select(i, frame:GetRegions()) - if region and region:GetObjectType() == "Texture" then - local texture = region:GetTexture() - if texture and type(texture) == "number" then - return texture + -- Measure text height and expand row if text wraps + -- Skip header tabs: they have SetMaxLines(1) and can't wrap. + local actualH = resultRow:GetHeight() + local textObj + if theme.showHeaderTab and entry.isPathNode and resultRow.headerTab and resultRow.headerTab:IsShown() then + textObj = nil + elseif not entry.isPinHeader then + textObj = resultRow.text + end + if textObj then + local textHeight = textObj:GetStringHeight() + local minH = textHeight / ns.SEARCHBAR_FILL + if minH > actualH then + actualH = minH + resultRow:SetHeight(actualH) + if resultRow.headerTab and resultRow.headerTab:IsShown() then + resultRow.headerTab:SetHeight(actualH) + end + if theme.showTreeLines and depth > 0 then + local halfRow = actualH * 0.5 + local xCenter = (depth - 1) * INDENT_PX + LINE_X_OFF + resultRow.treeElbow[depth]:ClearAllPoints() + resultRow.treeElbow[depth]:SetPoint("TOP", resultRow, "TOPLEFT", xCenter, 3) + resultRow.treeElbow[depth]:SetHeight(halfRow + 2) + resultRow.treeBranch[depth]:ClearAllPoints() + resultRow.treeBranch[depth]:SetPoint("LEFT", resultRow, "TOPLEFT", xCenter - 1, -halfRow) + resultRow.treeBranch[depth]:SetPoint("RIGHT", resultRow, "TOPLEFT", xCenter + INDENT_PX - LINE_X_OFF, -halfRow) + end + end end - end - end - return nil -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. 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 + 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 -function UI:ShowHierarchicalResults(hierarchical, preserveScroll) - if not hierarchical or #hierarchical == 0 then - self:HideResults() - return + -- 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 + 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) + resultRow:SetAttribute("action", nil) + resultRow:SetAttribute("spell", nil) + resultRow:SetAttribute("macro", nil) + resultRow:SetAttribute("macrotext", nil) + end + 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 + 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.calcActionBar then resultRow.calcActionBar:Hide() end + if resultRow.shortcutGroup then resultRow.shortcutGroup:Hide() end + for d = 1, MAX_DEPTH do + resultRow.treeVert[d]:Hide() + resultRow.treeElbow[d]:Hide() + resultRow.treeBranch[d]:Hide() + end + end end - if not resultsFrame then return end - - -- Cache the FULL (unfiltered) list so collapse toggles can re-render - cachedHierarchical = hierarchical - - local theme = GetActiveTheme() - local rowH = theme.rowHeight - local indPx = theme.indentPx - local padT = theme.resultsPadTop - -- Apply theme backdrop to results frame - resultsFrame:SetBackdrop(theme.resultsBackdrop) - if theme.resultsBackdropColor then - resultsFrame:SetBackdropColor(unpack(theme.resultsBackdropColor)) - end - if theme.resultsBackdropBorderColor then - resultsFrame:SetBackdropBorderColor(unpack(theme.resultsBackdropBorderColor)) + -- Show/hide pin separator between pinned items and search results + if resultsFrame.pinSeparator then + if hasResultsAfterPins then + resultsFrame.pinSeparator:ClearAllPoints() + resultsFrame.pinSeparator:SetPoint("TOPLEFT", resultsFrame.scrollChild, "TOPLEFT", 10, -pinEndYOffset - 4) + resultsFrame.pinSeparator:SetPoint("TOPRIGHT", resultsFrame.scrollChild, "TOPRIGHT", -10, -pinEndYOffset - 4) + resultsFrame.pinSeparator:Show() + else + resultsFrame.pinSeparator:Hide() + end end - local customW = EasyFind.db.uiResultsWidth - resultsFrame:SetWidth((customW and customW > 1) and customW or theme.resultsWidth) - -- 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() - resultsFrame:SetClipsChildren(true) - else - resultsFrame.bgAtlasTex:Hide() - resultsFrame:SetClipsChildren(false) + -- Show/hide category separator lines (between UI, Mount, Toy groups) + if resultsFrame.categorySeps then + for si = 1, #resultsFrame.categorySeps do + local sep = resultsFrame.categorySeps[si] + if catSepYPositions[si] then + sep:ClearAllPoints() + sep:SetPoint("TOPLEFT", resultsFrame.scrollChild, "TOPLEFT", 10, -catSepYPositions[si] - 4) + sep:SetPoint("TOPRIGHT", resultsFrame.scrollChild, "TOPRIGHT", -10, -catSepYPositions[si] - 4) + sep:Show() + else + sep:Hide() + end + end 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 + local totalContentHeight = yOffset + local hasScroll = totalContentHeight > maxVisibleHeight + local visibleHeight = hasScroll and maxVisibleHeight or totalContentHeight - for _, entry in ipairs(hierarchical) do - local d = entry.depth or 0 + resultsFrame:SetHeight(padT + padB + visibleHeight) + resultsFrame.scrollChild:SetWidth(resultsFrame:GetWidth() - scrollInset) + resultsFrame.scrollChild:SetHeight(totalContentHeight) - -- If we're skipping children of a collapsed node, check depth - if skipBelowDepth then - if d <= skipBelowDepth then - skipBelowDepth = nil - end - end + resultsFrame.scrollFrame:ClearAllPoints() + resultsFrame.scrollFrame:SetPoint("TOPLEFT", resultsFrame, "TOPLEFT", 0, -padT) + resultsFrame.scrollFrame:SetPoint("BOTTOMRIGHT", resultsFrame, "BOTTOMRIGHT", 0, padB) - if not (skipPins and entry.isPinned) and not skipBelowDepth then - if skipPins and not entry.isPinned then - skipPins = false -- past the pin section - end - tinsert(visible, entry) + if not preserveScroll then + resultsFrame.scrollFrame:SetVerticalScroll(0) + end - -- 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 - skipBelowDepth = d - end - end + if resultsFrame.scrollBar then + resultsFrame.scrollBar:SetShown(hasScroll) + if hasScroll then + resultsFrame.scrollBar:UpdateThumb(totalContentHeight, visibleHeight) end end - -- Count pin-related visible entries (header + pinned items) - local pinSlots = 0 - for _, entry in ipairs(visible) do - if entry.isPinHeader or entry.isPinned then - pinSlots = pinSlots + 1 + -- 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, 0) + end + + -- 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() + 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 - -- Show all results (scroll handles overflow) - local count = mmin(#visible, MAX_BUTTON_POOL) + resultsFrame:Show() + self:UpdateVisibleResultShortcuts() - -- 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 scrollInset = 0 - if willScroll and resultsFrame.scrollBar then - scrollInset = resultsFrame.scrollBar:GetWidth() + -- 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). + if hasSideBySideRepBar and not deferredRepRefreshPending then + deferredRepRefreshPending = true + local selfRef = self + ns.Utils.SafeAfter(0, function() + deferredRepRefreshPending = false + selfRef:RefreshResults() + end) end - -- Pre-compute last-child flags on the VISIBLE list - local isLastChild = {} - for i = 1, count do - local d = visible[i].depth or 0 - if d > 0 then - local foundSibling = false - for j = i + 1, count do - local dj = visible[j].depth or 0 - if dj < d then break end - if dj == d then foundSibling = true; break end - end - isLastChild[i] = not foundSibling - end - end + selectedIndex = 0 + toggleFocused = false + self:UpdateSelectionHighlight() +end - -- 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 - if visible[i].isPinHeader or visible[i].isPinned then - lastPinIndex = i - end - end - if lastPinIndex > 0 and lastPinIndex < count then - hasResultsAfterPins = true +function UI:ShowResults(results) + if not results or #results == 0 then + self:HideResults() + return end - -- Pre-compute section header separator positions - 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 + 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 - -- Render visible rows - local yOffset = 0 - local pinEndYOffset = 0 - local catSepYPositions = {} - local hasSideBySideRepBar = false - for i = 1, MAX_BUTTON_POOL do - local resultRow = resultButtons[i] - if i <= count then - local entry = visible[i] - local data = entry.data - local depth = entry.depth or 0 - - -- Pin separator gap: add once at the transition row - if hasResultsAfterPins and i == lastPinIndex + 1 then - pinEndYOffset = yOffset - 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 - end - - -- Reposition for theme row height - local padL = theme.resultsPadLeft or 10 - resultRow:SetSize(resultsFrame:GetWidth() - padL * 2 - scrollInset, rowH) - resultRow:ClearAllPoints() - resultRow:SetPoint("TOPLEFT", resultsFrame.scrollChild, "TOPLEFT", padL, -yOffset) - - -- Selection highlight color - resultRow.selectionHighlight:SetVertexColor(unpack(theme.selectionColor)) +-- 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 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 + -- 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 - resultRow.data = data - -- Set secure action attributes for toys, mounts, and outfits - if not InCombatLockdown() then - if data and data.toyItemID then - resultRow:SetAttribute("type", "toy") - resultRow:SetAttribute("toy", data.toyItemID) - resultRow:SetAttribute("macrotext", nil) - resultRow:SetAttribute("action", nil) - elseif data and data.mountID then - resultRow:SetAttribute("type", "macro") - resultRow:SetAttribute("macrotext", "/cancelform [form]") - resultRow:SetAttribute("toy", nil) - resultRow:SetAttribute("action", nil) - elseif data and data.outfitID then - -- type="action" is set here; PreClick sets the actual slot dynamically - resultRow:SetAttribute("type", "action") - resultRow:SetAttribute("action", 0) - resultRow:SetAttribute("toy", nil) - resultRow:SetAttribute("macrotext", nil) - else - resultRow:SetAttribute("type", nil) - resultRow:SetAttribute("toy", nil) - resultRow:SetAttribute("macrotext", nil) - resultRow:SetAttribute("action", nil) - end - end - resultRow.isPathNode = entry.isPathNode - resultRow.isSectionHeader = entry.isSectionHeader or false - resultRow.isPinHeader = entry.isPinHeader or false - 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 +-- 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). +-- 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 + 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 curVal = ReadSettingVariable(var) + + 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 n = #opts + local step = direction or 1 + local nextIdx = ((curIdx or 1) - 1 + step) % n + 1 + local nextVal = opts[nextIdx].value - -- Tree connector drawing - for d = 1, MAX_DEPTH do - resultRow.treeVert[d]:Hide() - resultRow.treeElbow[d]:Hide() - resultRow.treeBranch[d]:Hide() - end + if not WriteSettingVariable(var, nextVal) then return false end - if theme.showTreeLines and depth > 0 then - local halfRow = rowH * 0.5 - local lineColor = theme.indentColors[depth] or theme.indentColors[1] or INDENT_COLORS[depth] - local xCenter = (depth - 1) * INDENT_PX + LINE_X_OFF + 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 - resultRow.treeElbow[depth]:SetColorTexture(lineColor[1], lineColor[2], lineColor[3], 1) - resultRow.treeElbow[depth]:ClearAllPoints() - resultRow.treeElbow[depth]:SetPoint("TOP", resultRow, "TOPLEFT", xCenter, 3) - resultRow.treeElbow[depth]:SetHeight(halfRow + 2) - resultRow.treeElbow[depth]:Show() +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 - resultRow.treeBranch[depth]:SetColorTexture(lineColor[1], lineColor[2], lineColor[3], 1) - resultRow.treeBranch[depth]:ClearAllPoints() - resultRow.treeBranch[depth]:SetPoint("LEFT", resultRow, "TOPLEFT", xCenter - 1, -halfRow) - resultRow.treeBranch[depth]:SetPoint("RIGHT", resultRow, "TOPLEFT", xCenter + INDENT_PX - LINE_X_OFF, -halfRow) - resultRow.treeBranch[depth]:Show() +-- 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 - if not isLastChild[i] then - resultRow.treeVert[depth]:SetColorTexture(lineColor[1], lineColor[2], lineColor[3], 1) - resultRow.treeVert[depth]:ClearAllPoints() - resultRow.treeVert[depth]:SetPoint("TOP", resultRow, "TOPLEFT", xCenter, 3) - resultRow.treeVert[depth]:SetPoint("BOTTOM", resultRow, "BOTTOMLEFT", xCenter, -1) - resultRow.treeVert[depth]:Show() - 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. +-- 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", +} - for d = 1, depth - 1 do - local stillActive = false - for j = i + 1, count do - local siblingDepth = visible[j].depth or 0 - if siblingDepth < d then break end - if siblingDepth == d then stillActive = true; break end - end - if stillActive then - local ancestorColor = theme.indentColors[d] or theme.indentColors[1] or INDENT_COLORS[d] - local ancestorX = (d - 1) * INDENT_PX + LINE_X_OFF - resultRow.treeVert[d]:SetColorTexture(ancestorColor[1], ancestorColor[2], ancestorColor[3], 1) - resultRow.treeVert[d]:ClearAllPoints() - resultRow.treeVert[d]:SetPoint("TOP", resultRow, "TOPLEFT", ancestorX, 3) - resultRow.treeVert[d]:SetPoint("BOTTOM", resultRow, "BOTTOMLEFT", ancestorX, -1) - resultRow.treeVert[d]:Show() - end - end +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 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 + -- 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 - - -- Header styling - 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() - 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: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) - resultRow.text:SetText(entry.name) - resultRow.text:SetFontObject(theme.pathFont) - resultRow.text:SetTextColor(0.7, 0.7, 0.7, 1.0) - elseif theme.showHeaderTab and entry.isPathNode then - -- Quest-log raised tab header - local tabInset = depth * indPx - resultRow.headerTab:ClearAllPoints() - resultRow.headerTab:SetPoint("TOPLEFT", resultRow, "TOPLEFT", tabInset, 0) - resultRow.headerTab:SetPoint("BOTTOMRIGHT", resultRow, "BOTTOMRIGHT", 0, 0) - resultRow.headerTab:Show() - -- Set +/- atlas and header name on the tab - local key = entry.name .. "_" .. depth - local isCollapsed = collapsedNodes[key] - local expandAtlas = theme.expandAtlas or "QuestLog-icon-expand" - local collapseAtlas = theme.collapseAtlas or "QuestLog-icon-shrink" - local toggleAtlas = isCollapsed and expandAtlas or collapseAtlas - resultRow.toggleIcon:SetAtlas(toggleAtlas) - -- Reset tabText anchors (may have been re-anchored to repBar) - resultRow.tabText:ClearAllPoints() - resultRow.tabText:SetPoint("LEFT", resultRow.headerTab, "LEFT", 10, 0) - resultRow.tabText:SetPoint("RIGHT", resultRow.toggleBtn, "LEFT", -4, 0) - resultRow.tabText:SetText(entry.name) - -- Matched path nodes get gold text; non-matches stay muted gray - if resultRow._isMatch then - resultRow.tabText:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 1.0) -- gold - else - resultRow.tabText:SetTextColor(0.60, 0.58, 0.55, 1.0) -- muted gray - end - -- Normal icon/text hidden - SetRowIcon("hidden") handles icon below - resultRow.text:SetText("") - resultRow.headerGrad:Hide() - else - resultRow.headerTab:Hide() - -- Gradient header (Classic fallback) - local showGrad = theme.showHeaderBar and entry.isPathNode - if showGrad 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) + end + end + 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 - -- Separator line between rows (skip for pin header which has its own underline) - -- Separator is anchored at BOTTOM of the row (line below this row). - if not entry.isPinHeader and theme.showSeparators then - local sc = theme.separatorColor - resultRow.separator:SetColorTexture(sc[1], sc[2], sc[3], sc[4]) - resultRow.separator:Show() - elseif entry.isPinned and not entry.isPinHeader then - local nextEntry = visible[i + 1] - if nextEntry and nextEntry.isPinned and not nextEntry.isPinHeader then - resultRow.separator:SetColorTexture(0.4, 0.4, 0.4, 0.4) - resultRow.separator:Show() - else - resultRow.separator:Hide() - end - else - resultRow.separator:Hide() - end +-- 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. +-- 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 - -- Check if this is a currency that hasn't been discovered yet - -- (not just quantity == 0, but truly never earned/discovered) - -- Runs for ALL currency nodes regardless of theme - local isUnearnedCurrency = false - if data and data.category == "Currency" then - if entry.isPathNode then - -- For parent currency nodes, check if ALL children are unearned - -- Look ahead in the visible list to find children - local hasAnyEarnedChild = false - local hasAnyChild = false - for j = i + 1, count do - local childEntry = visible[j] - local childDepth = childEntry.depth or 0 - -- Stop when we leave this parent's subtree - if childDepth <= depth then - break - end - -- Only check immediate children at depth + 1 - if childDepth == depth + 1 and childEntry.data and childEntry.data.steps then - hasAnyChild = true - for _, step in ipairs(childEntry.data.steps) do - if step.currencyID then - local currencyInfo = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo(step.currencyID) - if currencyInfo and (currencyInfo.quantity > 0 or - (currencyInfo.totalEarned and currencyInfo.totalEarned > 0) or - currencyInfo.useTotalEarnedForMaxQty or - currencyInfo.discovered == true) then - hasAnyEarnedChild = true - break - end - end - end - if hasAnyEarnedChild then break end - end - end - -- If we found children but NONE are earned, mark parent as unearned - if hasAnyChild and not hasAnyEarnedChild then - isUnearnedCurrency = true - end - elseif data.steps then - -- For leaf currency nodes, check the currency itself - for _, step in ipairs(data.steps) do - if step.currencyID then - local currencyInfo = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo(step.currencyID) - if currencyInfo and currencyInfo.quantity == 0 then - -- Only mark as unearned if it's never been discovered - local isDiscovered = (currencyInfo.totalEarned and currencyInfo.totalEarned > 0) or - (currencyInfo.useTotalEarnedForMaxQty) or - (currencyInfo.discovered == true) - if not isDiscovered then - isUnearnedCurrency = true - end - end - break - end - 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") + 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 - resultRow.isUnearnedCurrency = isUnearnedCurrency - resultRow.isPathNode = entry.isPathNode -- Store for tooltip text + end) + end +end - -- Position icon & text (non-tab, non-pin-header rows) - if not entry.isPinHeader and not (theme.showHeaderTab and entry.isPathNode) then - local indentPixels = depth * indPx - resultRow.icon:ClearAllPoints() - resultRow.icon:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) +-- 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 - 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) +function UI:RequestHideResults() + if resultsFrame and resultsFrame:IsShown() and self:ShowUnappliedSettingsPopup() then + return + end + self:HideResults() +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 +function UI:HideResults() + if not searchFrame then return end + 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() + 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 resultsFrame.pinSeparator then + resultsFrame.pinSeparator:Hide() + end + if resultsFrame.categorySeps then + for _, sep in ipairs(resultsFrame.categorySeps) do sep:Hide() end + end + if resultsFrame.truncIndicator then + resultsFrame.truncIndicator:Hide() + end + 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._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 + 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) - -- Set icon - local iconSet = false - 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 - - if entry.isPinHeader then - -- Pin header: no row icon (toggle is handled by pinToggle) - SetRowIcon(resultRow, "hidden", nil, theme.iconSize) - iconSet = true - - elseif theme.showHeaderTab and entry.isPathNode then - SetRowIcon(resultRow, "hidden", nil, theme.iconSize) - iconSet = true + if ns.Database and ns.Database.CancelDynamicWarmup then + ns.Database:CancelDynamicWarmup() + end - elseif entry.isPathNode then - local key = entry.name .. "_" .. depth - local nodeCollapsed = collapsedNodes[key] - local iconPath = nodeCollapsed and theme.expandIcon or theme.collapseIcon - SetRowIcon(resultRow, "path", iconPath, theme.pathIconSize) - iconSet = true - end + idleTrimSerial = idleTrimSerial + 1 + local serial = idleTrimSerial + 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 - -- Resolve currency icon on the fly if not cached - if not iconSet and isCurrencyItem and data and not data.icon and data.steps then - for si = #data.steps, 1, -1 do - local cid = data.steps[si].currencyID - if cid then - if C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo then - local ok, ci = pcall(C_CurrencyInfo.GetCurrencyInfo, cid) - if ok and ci and ci.iconFileID and ci.iconFileID ~= 0 then - data.icon = ci.iconFileID - end - end - break - end - end - end +function UI:ShowPinnedItems() + if not resultsFrame then return end + local pins = GetAllPins() + if #pins == 0 then + self:HideResults() + return + end - -- Currency leaves: icon goes right of amount, not left of name - if isCurrencyLeaf and data and data.steps then - local quantity, iconFileID - for si = #data.steps, 1, -1 do - local cid = data.steps[si].currencyID - if cid and C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo then - local ok, ci = pcall(C_CurrencyInfo.GetCurrencyInfo, cid) - if ok and ci then - quantity = ci.quantity - iconFileID = data.icon or (ci.iconFileID ~= 0 and ci.iconFileID) or nil - end - break - end - end + wipe(collapsedNodes) + local entries = pinnedOnlyEntries + 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) +end - -- Amount text - if quantity then - resultRow.amountText:SetText(tostring(quantity)) - if isUnearnedCurrency then - resultRow.amountText:SetTextColor(0.5, 0.5, 0.5, 1.0) - else - resultRow.amountText:SetTextColor(0.9, 0.82, 0.65, 1.0) - end - resultRow.amountText:Show() - else - resultRow.amountText:Hide() - end +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 - -- Move icon to right side (right of amount text) - if iconFileID then - resultRow.icon:SetTexture(nil) - resultRow.icon:SetTexCoord(0, 1, 0, 1) - resultRow.icon:SetTexture(iconFileID) - resultRow.icon:SetSize(theme.iconSize or 16, theme.iconSize or 16) - resultRow.icon:ClearAllPoints() - resultRow.icon:SetPoint("RIGHT", resultRow, "RIGHT", -5, 0) - resultRow.icon:Show() - -- Anchor amount text to left of icon - resultRow.amountText:ClearAllPoints() - resultRow.amountText:SetPoint("RIGHT", resultRow.icon, "LEFT", -3, 0) - else - SetRowIcon(resultRow, "hidden", nil, theme.iconSize) - resultRow.amountText:ClearAllPoints() - resultRow.amountText:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) - end +function UI:CountVisibleResults() + local count = 0 + for i = 1, MAX_BUTTON_POOL do + local row = resultButtons[i] + if row and row:IsShown() then + count = i + else + break + end + end + return count +end - -- Anchor name text from indent to amount (no left icon, tiny buffer) - local indentPixels = depth * indPx + 4 - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) - resultRow.text:SetPoint("RIGHT", resultRow.amountText, "LEFT", -4, 0) - iconSet = true +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 + -- 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 - -- 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 - local iconFileID = data.icon - local rightOffset = -5 + local newIndex = selectedIndex + delta + if EasyFind.db.uiResultsAbove 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 - if iconFileID then - resultRow.icon:SetTexture(nil) - resultRow.icon:SetTexCoord(0, 1, 0, 1) - resultRow.icon:SetTexture(iconFileID) - resultRow.icon:SetSize(theme.iconSize or 16, theme.iconSize or 16) - resultRow.icon:ClearAllPoints() - resultRow.icon:SetPoint("RIGHT", resultRow, "RIGHT", rightOffset, 0) - resultRow.icon:Show() - resultRow.icon.mountID = data.mountID - resultRow.icon.toyItemID = data.toyItemID - resultRow.icon.petID = data.petID - resultRow.icon.spellID = data.spellID - resultRow.icon.outfitID = data.outfitID - -- Red tint on mount icons when in combat (can't mount) - if data.mountID and InCombatLockdown() then - resultRow.icon:SetVertexColor(1, 0.3, 0.3, 1) - -- Green tint on currently equipped outfit - elseif data.outfitID then - local activeID = lastEquippedOutfitID - or (C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetActiveOutfitID - and C_TransmogOutfitInfo.GetActiveOutfitID()) - if activeID and activeID == data.outfitID then - resultRow.icon:SetVertexColor(0.3, 1, 0.3, 1) - else - resultRow.icon:SetVertexColor(1, 1, 1, 1) - end - else - resultRow.icon:SetVertexColor(1, 1, 1, 1) - end - else - SetRowIcon(resultRow, "hidden", nil, theme.iconSize) - end + selectedIndex = newIndex + toggleFocused = false + self:UpdateSelectionHighlight() +end - -- Outfit lock overlay (dashed border when locked) - if data.outfitID and C_TransmogOutfitInfo and C_TransmogOutfitInfo.IsLockedOutfit then - UI:UpdateOutfitLockOverlay(resultRow, C_TransmogOutfitInfo.IsLockedOutfit(data.outfitID)) - elseif resultRow._lockOverlay then - resultRow._lockOverlay:Hide() - end +function UI:JumpToStart() + if self:CountVisibleResults() > 0 then + selectedIndex = 1 + toggleFocused = false + self:UpdateSelectionHighlight() + end +end - -- Cooldown sweep overlay (toys and outfits) - resultRow.amountText:Hide() - if data.toyItemID and iconFileID and GetItemCooldown then - local startTime, duration = GetItemCooldown(data.toyItemID) - if startTime and duration and duration > 0 then - resultRow.iconCooldown:SetAllPoints(resultRow.icon) - resultRow.iconCooldown:SetCooldown(startTime, 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 - resultRow.iconCooldown:SetAllPoints(resultRow.icon) - resultRow.iconCooldown:SetCooldown(outfitCdStart, outfitCdDuration) - resultRow.iconCooldown:Show() - else - resultRow.iconCooldown:Hide() - end - else - resultRow.iconCooldown:Hide() - end +function UI:JumpToEnd() + local visibleCount = self:CountVisibleResults() + if visibleCount > 0 then + selectedIndex = visibleCount + toggleFocused = false + self:UpdateSelectionHighlight() + end +end - local indentPixels = depth * indPx + 4 - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) - resultRow.text:SetPoint("RIGHT", resultRow.icon, "LEFT", -4, 0) - iconSet = true +function UI:JumpToNextSection(direction) + local visibleCount = self:CountVisibleResults() + if visibleCount == 0 then return end - -- Loot items: icon on right with source name inline - elseif not iconSet and data and data.itemID and data.category == "Loot" then - local iconFileID = data.icon - if iconFileID then - resultRow.icon:SetTexture(nil) - resultRow.icon:SetTexCoord(0, 1, 0, 1) - resultRow.icon:SetTexture(iconFileID) - resultRow.icon:SetSize(theme.iconSize or 16, theme.iconSize or 16) - resultRow.icon:ClearAllPoints() - resultRow.icon:SetPoint("RIGHT", resultRow, "RIGHT", -5, 0) - resultRow.icon:Show() - resultRow.icon.lootItemID = data.itemID - resultRow.icon:SetVertexColor(1, 1, 1, 1) - else - SetRowIcon(resultRow, "hidden", nil, theme.iconSize) - end - resultRow.amountText:Hide() - resultRow.iconCooldown:Hide() - -- Show source info after item name - if data.lootSourceName then - resultRow.text:SetText(data.name .. " |cff888888" .. data.lootSourceName .. "|r") - end - local indentPixels = depth * indPx + 4 - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) - resultRow.text:SetPoint("RIGHT", resultRow.icon, "LEFT", -4, 0) - iconSet = true + local startIdx = selectedIndex + if startIdx == 0 then + startIdx = direction > 0 and 0 or visibleCount + 1 + end - -- Map search results: left-side category icon + nav pin on right - elseif not iconSet and data and data.mapSearchResult then - resultRow.amountText:Hide() - local mapIcon = data.icon - if mapIcon then - resultRow.icon:SetTexture(nil) - resultRow.icon:SetTexCoord(0, 1, 0, 1) - resultRow.icon:SetVertexColor(1, 1, 1, 1) - if type(mapIcon) == "table" then - resultRow.icon:SetTexture(mapIcon.file) - local c = mapIcon.coords - resultRow.icon:SetTexCoord(c[1], c[2], c[3], c[4]) - elseif type(mapIcon) == "string" and sfind(mapIcon, "^atlas:") then - resultRow.icon:SetAtlas(mapIcon:sub(7)) - else - resultRow.icon:SetTexture(mapIcon) - end - resultRow.icon:SetSize(theme.iconSize or 16, theme.iconSize or 16) - 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) - else - local indentPixels = depth * indPx + 4 - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) - end - resultRow.text:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) - iconSet = true + local uiSectionStart = 0 + for i = 1, visibleCount do + local row = resultButtons[i] + if row and not row.isPinHeader and not row.isPinned then + uiSectionStart = i + break + end + end + + -- Find the next section boundary in the given direction. + -- Boundaries: first non-pinned row (UI search) + any isSectionHeader row. + local idx = startIdx + direction + while idx >= 1 and idx <= visibleCount do + local row = resultButtons[idx] + if row and (row.isSectionHeader or idx == uiSectionStart) then + selectedIndex = idx + toggleFocused = false + self:UpdateSelectionHighlight() + return + end + idx = idx + direction + end +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 + local isHeaderRow = resultRow.headerTab and resultRow.headerTab:IsShown() + if resultRow.LockHighlight then + if i == selectedIndex and not isHeaderRow then + resultRow:LockHighlight() else - resultRow.amountText:Hide() - -- Reset amount text anchor for non-currency rows - resultRow.amountText:ClearAllPoints() - resultRow.amountText:SetPoint("RIGHT", resultRow, "RIGHT", -8, 0) + resultRow:UnlockHighlight() end + end + end + if selectedIndex > 0 then + if resultButtons[selectedIndex] then + Utils.ScrollToButton(resultsFrame.scrollFrame, resultButtons[selectedIndex]) + end + if searchFrame.editBox:HasFocus() then + 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) + 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 - -- Reputation bar: show on leaves and on path nodes with actual rep bars - -- (hasRepBar is false for pure grouping headers like Horde, Alliance) - local showRepBar = data and data.factionID and - (isReputationLeaf or (entry.isPathNode and data.category == "Reputation" and data.hasRepBar ~= false)) - if showRepBar then - local fill, standingText, barR, barG, barB - local fid = data.factionID + -- 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 and not IsSpellbookOnlyAbility(rd)) + or rd.mountID or rd.macroIndex or rd.slashCommand + or (rd.itemID and rd.category == "Bag")) + if secureRow then + local btnName = selRow:GetName() + if btnName then + SetOverrideBindingClick(navFrame, true, "ENTER", btnName, "LeftButton") + end + else + ClearOverrideBindings(navFrame) + end + end +end - -- Priority 1: Renown factions (TWW, Dragonflight, Shadowlands) - if C_MajorFactions and C_MajorFactions.GetMajorFactionData then - local ok, md = pcall(C_MajorFactions.GetMajorFactionData, fid) - if ok and md and md.renownLevel then - local level = md.renownLevel or 0 - standingText = "Renown " .. level - local atMax = C_MajorFactions.HasMaximumRenown - and C_MajorFactions.HasMaximumRenown(fid) - if atMax then - fill = 1.0 - else - local earned = md.renownReputationEarned or 0 - local threshold = md.renownLevelThreshold or 1 - fill = (threshold > 0) and (earned / threshold) or 1.0 - end - barR, barG, barB = 0.0, 0.55, 0.78 - end - end +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 + end + if not resultRow.data then return false end - -- Priority 2: Friendship factions (Sabellian, Wrathion, etc.) - if not standingText and C_GossipInfo and C_GossipInfo.GetFriendshipReputation then - local ok, fd = pcall(C_GossipInfo.GetFriendshipReputation, fid) - if ok and fd and fd.friendshipFactionID and fd.friendshipFactionID > 0 then - standingText = fd.reaction or "" - local cur = fd.standing or 0 - local minR = fd.reactionThreshold or 0 - local maxR = fd.nextThreshold or 0 - if maxR > minR then - fill = (cur - minR) / (maxR - minR) - elseif cur > 0 then - fill = 1.0 - else - fill = 0.0 - end - barR, barG, barB = 0.0, 0.60, 0.0 - end - end + 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") + return true + end + self:SelectResult(resultRow.data) + return true +end - -- Priority 3: Traditional factions (Friendly, Honored, etc.) - if not standingText and C_Reputation and C_Reputation.GetFactionDataByID then - local ok, rd = pcall(C_Reputation.GetFactionDataByID, fid) - if ok and rd and rd.reaction then - local standing = rd.reaction - standingText = _G["FACTION_STANDING_LABEL" .. standing] or "" - local cur = rd.currentStanding or 0 - local minR = rd.currentReactionThreshold or 0 - local maxR = rd.nextReactionThreshold or 0 - if maxR > minR then - fill = (cur - minR) / (maxR - minR) - else - fill = 1.0 - end - local barColor = FACTION_BAR_COLORS and FACTION_BAR_COLORS[standing] - if barColor then - barR, barG, barB = barColor.r, barColor.g, barColor.b - else - barR, barG, barB = 0.5, 0.5, 0.5 - end - end +function UI:ActivateSelected(source) + if selectedIndex > 0 and selectedIndex <= MAX_BUTTON_POOL then + local resultRow = resultButtons[selectedIndex] + if resultRow and resultRow:IsShown() then + if resultRow.isPathNode and toggleFocused then + local key = (resultRow.pathNodeName or "") .. "_" .. (resultRow.pathNodeDepth or 0) + collapsedNodes[key] = not collapsedNodes[key] + if cachedHierarchical then + local savedIndex = selectedIndex + local savedToggle = toggleFocused + self:ShowHierarchicalResults(cachedHierarchical, true) + selectedIndex = savedIndex + toggleFocused = savedToggle + self:UpdateSelectionHighlight() end + else + self:ActivateResultRow(resultRow, source or "key") + end + return + end + end + -- Fallback: select first result if nothing is highlighted + self:SelectFirstResult() +end - if standingText then - if fill < 0 then fill = 0 end - if fill > 1 then fill = 1 end - resultRow.repBarTex:SetVertexColor(barR, barG, barB, 1.0) - if resultRow.repFill.SetBackdropColor then - resultRow.repFill:SetBackdropColor(barR, barG, barB, 1.0) - end - resultRow.repClip:SetWidth(mmax(fill * REP_BAR_WIDTH, 0.1)) - resultRow.repBarText:SetText(standingText) +function UI:UpdateOutfitLockOverlay(resultRow, isLocked) + if not resultRow.icon then return end + if not resultRow._lockOverlay then + local overlay = resultRow:CreateTexture(nil, "OVERLAY") + overlay:SetAtlas("transmog-outfit-spellFrame-active") + overlay:SetPoint("CENTER", resultRow.icon, "CENTER", 0, 0) + resultRow._lockOverlay = overlay - if entry.isPathNode and theme.showHeaderTab then - -- Tab theme: place rep bar left of the toggle icon - resultRow.repBar:ClearAllPoints() - resultRow.repBar:SetPoint("RIGHT", resultRow.toggleBtn, "LEFT", -4, 0) - resultRow.tabText:ClearAllPoints() - resultRow.tabText:SetPoint("LEFT", resultRow.headerTab, "LEFT", 10, 0) - resultRow.tabText:SetPoint("RIGHT", resultRow.repBar, "LEFT", -4, 0) - elseif entry.isPathNode then - -- Side-by-side by default; IsTruncated() reflects the previous frame's - -- layout, which is accurate for stable results. A deferred re-render - -- corrects it after first render or scale/width changes. - local indentPixels = depth * indPx + 4 - resultRow.repBar:ClearAllPoints() - resultRow.repBar:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("LEFT", resultRow.icon, "RIGHT", 4, 0) - resultRow.text:SetPoint("RIGHT", resultRow.repBar, "LEFT", -4, 0) - if resultRow.text:IsTruncated() then - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("TOPLEFT", resultRow, "TOPLEFT", indentPixels, -3) - resultRow.text:SetPoint("TOPRIGHT", resultRow, "TOPRIGHT", -6, -3) - resultRow.repBar:ClearAllPoints() - resultRow.repBar:SetPoint("BOTTOM", resultRow, "BOTTOM", 0, 5) - resultRow:SetHeight(rowH + 25) - else - hasSideBySideRepBar = true - end - else - -- Leaf: side-by-side by default, stack only if text truncates - SetRowIcon(resultRow, "hidden", nil, theme.iconSize) - local indentPixels = depth * indPx + 4 - resultRow.repBar:ClearAllPoints() - resultRow.repBar:SetPoint("RIGHT", resultRow, "RIGHT", -6, 0) - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) - resultRow.text:SetPoint("RIGHT", resultRow.repBar, "LEFT", -4, 0) - if resultRow.text:IsTruncated() then - resultRow.text:ClearAllPoints() - resultRow.text:SetPoint("TOPLEFT", resultRow, "TOPLEFT", indentPixels, -3) - resultRow.text:SetPoint("TOPRIGHT", resultRow, "TOPRIGHT", -6, -3) - resultRow.repBar:ClearAllPoints() - resultRow.repBar:SetPoint("BOTTOM", resultRow, "BOTTOM", 0, 5) - resultRow:SetHeight(rowH + 25) - else - hasSideBySideRepBar = true - end - iconSet = true - end - resultRow.repBar:Show() - else - resultRow.repBar:Hide() - end + end + local size = (resultRow.icon:GetWidth() or 16) + 6 + resultRow._lockOverlay:SetSize(size, size) + resultRow._lockOverlay:SetShown(isLocked) +end - if not entry.isPathNode then iconSet = true end - else - resultRow.repBar:Hide() - end +function UI:ApplyTransmogBrowseMode() + if not TransmogFrame then return end - if not iconSet and data and data.icon then - SetRowIcon(resultRow, "file", data.icon, theme.iconSize) - iconSet = true - end + local hidden = {} + local outfitCollection = TransmogFrame.OutfitCollection + if outfitCollection then + if outfitCollection.PurchaseOutfitButton then + outfitCollection.PurchaseOutfitButton:Hide() + hidden[#hidden + 1] = outfitCollection.PurchaseOutfitButton + end + if outfitCollection.SaveOutfitButton then + outfitCollection.SaveOutfitButton:Hide() + hidden[#hidden + 1] = outfitCollection.SaveOutfitButton + end + end + if outfitCollection and outfitCollection.MoneyFrame then + outfitCollection.MoneyFrame:Hide() + hidden[#hidden + 1] = outfitCollection.MoneyFrame + end - -- Portrait menu items: use the player portrait as the icon - if not iconSet and data and data.steps then - for _, step in ipairs(data.steps) do - 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:Show() - iconSet = true - break - end - end + local wardrobeCollection = TransmogFrame.WardrobeCollection + local tabHeaders = wardrobeCollection and wardrobeCollection.TabHeaders + if tabHeaders then + for _, tab in ipairs({ tabHeaders:GetChildren() }) do + if tab.GetText and tab:GetText() == "Situations" then + tab:Hide() + hidden[#hidden + 1] = tab + break end + end + end - -- Resolve sidebar tab icons at runtime (e.g. Equipment Manager, Titles) - -- The tab textures are sprite sheets - copy the ARTWORK-layer texture - -- along with its tex coords so only the icon portion is shown. - if not iconSet and data and data.steps then - for _, step in ipairs(data.steps) do - 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" - and region:GetDrawLayer() == "ARTWORK" then - local tex = region:GetTexture() - -- Skip render targets (e.g. RTPortrait1 for the player model) - if tex and type(tex) == "string" and tex:find("^RT") then - break - end - if tex then - 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:Show() - iconSet = true - end - break - end - end - -- Fallback for render target tabs: use player portrait - 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:Show() - iconSet = true - end - end - break - end + -- Disable right-click on outfit name buttons (shows "Change Name/Icon" + -- which doesn't work without a vendor). ScrollBox items are recycled, + -- so re-register on each visible frame and hook the ScrollBox update. + local outfitScrollBox = outfitCollection and outfitCollection.OutfitList + and outfitCollection.OutfitList.ScrollBox + if outfitScrollBox and outfitScrollBox.EnumerateFrames then + local function disableOutfitRightClick() + for _, itemFrame in outfitScrollBox:EnumerateFrames() do + local outfitBtn = itemFrame.OutfitButton + if outfitBtn and outfitBtn.RegisterForClicks then + outfitBtn:RegisterForClicks("LeftButtonUp") end end - - -- Skip buttonFrame fallback for currency items - their inherited - -- "CharacterMicroButton" produces a wrong MicroMenu atlas icon. - if not iconSet and not isCurrencyItem and data and data.buttonFrame then - local texture, isAtlas = GetButtonIcon(data.buttonFrame) - if texture then - local kind = isAtlas and "atlas" or "file" - SetRowIcon(resultRow, kind, texture, theme.iconSize) - iconSet = true + end + disableOutfitRightClick() + -- Re-apply when ScrollBox recycles frames (scroll, resize) + if not outfitScrollBox._efBrowseHooked then + outfitScrollBox._efBrowseHooked = true + hooksecurefunc(outfitScrollBox, "Update", function() + if TransmogFrame._efBrowseMode then + disableOutfitRightClick() end - end + end) + end + end + TransmogFrame._efBrowseMode = true - if not iconSet then - SetRowIcon(resultRow, "file", 134400, theme.iconSize) - end + TransmogFrame._efHiddenFrames = hidden - -- 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() - -- Pinned entries during search: show path prefix in name - if data and data.path and #data.path > 0 then - local prefix = tconcat(data.path, " > ") - resultRow.text:SetText("|cff888888" .. prefix .. " >|r " .. (data.name or "")) + -- Browse-mode message (left panel, where vendor buttons were) + if not TransmogFrame._efBrowseMsg then + local msg = TransmogFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + msg:SetText("Visit a transmogrification vendor for full functionality.") + msg:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) + msg:SetJustifyH("CENTER") + TransmogFrame._efBrowseMsg = msg + end + local msg = TransmogFrame._efBrowseMsg + msg:ClearAllPoints() + local anchor = outfitCollection and outfitCollection.PurchaseOutfitButton + if anchor then + msg:SetPoint("TOP", anchor, "TOP", 0, 0) + elseif outfitCollection then + msg:SetPoint("BOTTOM", outfitCollection, "BOTTOM", 0, 20) + else + msg:SetPoint("BOTTOM", TransmogFrame, "BOTTOM", 0, 30) + end + msg:SetWidth((outfitCollection and outfitCollection:GetWidth() - 20) or 280) + msg:Show() + + -- Situations message (top right, near the hidden tab) + if not TransmogFrame._efSituationsMsg then + local sitMsg = TransmogFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + sitMsg:SetText("See transmogrification vendor\nto adjust Situations settings.") + sitMsg:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) + sitMsg:SetJustifyH("RIGHT") + TransmogFrame._efSituationsMsg = sitMsg + end + local sitMsg = TransmogFrame._efSituationsMsg + sitMsg:ClearAllPoints() + if tabHeaders then + sitMsg:SetPoint("LEFT", tabHeaders, "RIGHT", 8, 6) + else + sitMsg:SetPoint("TOPRIGHT", TransmogFrame, "TOPRIGHT", -40, -55) + end + sitMsg:Show() + + -- Restore on hide (one-shot hook, reads _efHiddenFrames at fire time) + if not TransmogFrame._efBrowseHooked then + TransmogFrame._efBrowseHooked = true + TransmogFrame:HookScript("OnHide", function(self) + self._efBrowseMode = nil + if self._efHiddenFrames then + for _, frame in ipairs(self._efHiddenFrames) do + frame:Show() end + self._efHiddenFrames = nil end - - -- Measure text height and expand row if text wraps - -- Skip header tabs: they have SetMaxLines(1) and can't wrap. - local actualH = resultRow:GetHeight() - local textObj - if theme.showHeaderTab and entry.isPathNode and resultRow.headerTab:IsShown() then - textObj = nil - elseif not entry.isPinHeader then - textObj = resultRow.text + if self._efBrowseMsg then + self._efBrowseMsg:Hide() end - if textObj then - local textHeight = textObj:GetStringHeight() - local minH = textHeight / ns.SEARCHBAR_FILL - if minH > actualH then - actualH = minH - resultRow:SetHeight(actualH) - if 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 - resultRow.treeElbow[depth]:ClearAllPoints() - resultRow.treeElbow[depth]:SetPoint("TOP", resultRow, "TOPLEFT", xCenter, 3) - resultRow.treeElbow[depth]:SetHeight(halfRow + 2) - resultRow.treeBranch[depth]:ClearAllPoints() - resultRow.treeBranch[depth]:SetPoint("LEFT", resultRow, "TOPLEFT", xCenter - 1, -halfRow) - resultRow.treeBranch[depth]:SetPoint("RIGHT", resultRow, "TOPLEFT", xCenter + INDENT_PX - LINE_X_OFF, -halfRow) + if self._efSituationsMsg then + self._efSituationsMsg:Hide() + end + local oc = self.OutfitCollection + local sb = oc and oc.OutfitList and oc.OutfitList.ScrollBox + if sb and sb.EnumerateFrames then + for _, itemFrame in sb:EnumerateFrames() do + local outfitBtn = itemFrame.OutfitButton + if outfitBtn and outfitBtn.RegisterForClicks then + outfitBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp") end end end + end) + end +end - yOffset = yOffset + actualH - resultRow:Show() - else - resultRow:Hide() - resultRow.isPinHeader = false - if not InCombatLockdown() then - resultRow:SetAttribute("type", nil) - resultRow:SetAttribute("toy", nil) - resultRow:SetAttribute("action", nil) - end - resultRow.headerGrad:Hide() - resultRow.headerTab:Hide() - resultRow.separator:Hide() - resultRow.repBar:Hide() - for d = 1, MAX_DEPTH do - resultRow.treeVert[d]:Hide() - resultRow.treeElbow[d]:Hide() - resultRow.treeBranch[d]:Hide() - 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 - -- Show/hide pin separator between pinned items and search results - if resultsFrame.pinSeparator then - if hasResultsAfterPins then - resultsFrame.pinSeparator:ClearAllPoints() - resultsFrame.pinSeparator:SetPoint("TOPLEFT", resultsFrame.scrollChild, "TOPLEFT", 10, -pinEndYOffset - 4) - resultsFrame.pinSeparator:SetPoint("TOPRIGHT", resultsFrame.scrollChild, "TOPRIGHT", -10, -pinEndYOffset - 4) - resultsFrame.pinSeparator:Show() - else - resultsFrame.pinSeparator:Hide() + -- 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 - -- Show/hide category separator lines (between UI, Mount, Toy groups) - if resultsFrame.categorySeps then - for si = 1, #resultsFrame.categorySeps do - local sep = resultsFrame.categorySeps[si] - if catSepYPositions[si] then - sep:ClearAllPoints() - sep:SetPoint("TOPLEFT", resultsFrame.scrollChild, "TOPLEFT", 10, -catSepYPositions[si] - 4) - sep:SetPoint("TOPRIGHT", resultsFrame.scrollChild, "TOPRIGHT", -10, -catSepYPositions[si] - 4) - sep:Show() - else - sep:Hide() - 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 - -- Calculate total content height vs max visible height - local totalContentHeight = yOffset - 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) + 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 - -- Size the results frame and scroll child - resultsFrame:SetHeight(padT + theme.resultsPadBot + 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) + 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 - -- Reset scroll position on new search (preserve on expand/collapse toggle) - if not preserveScroll then - resultsFrame.scrollFrame:SetVerticalScroll(0) - end +local function SpellFrameMatchesSelf(frame, target) + if not frame then return false end - 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) + 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 - -- Anchor results above or below based on setting - resultsFrame:ClearAllPoints() - if EasyFind.db.uiResultsAbove then - resultsFrame:SetPoint("BOTTOM", searchFrame, "TOP", 0, -5) - else - resultsFrame:SetPoint("TOP", searchFrame, "BOTTOM", 0, 2) + if frame.GetElementData then + local ok, data = pcall(frame.GetElementData, frame) + if ok and SpellRecordMatches(data, target) then return true end end - - resultsFrame:Show() - - -- 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). - if hasSideBySideRepBar and not deferredRepRefreshPending then - deferredRepRefreshPending = true - local selfRef = self - ns.Utils.SafeAfter(0, function() - deferredRepRefreshPending = false - selfRef:RefreshResults() - 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 - -- Reset keyboard selection whenever results change - selectedIndex = 0 - toggleFocused = false - self:UpdateSelectionHighlight() +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 -function UI:ShowResults(results) - -- Legacy function, redirects to hierarchical - local hierarchical = ns.Database:BuildHierarchicalResults(results) - self:ShowHierarchicalResults(hierarchical) +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 -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 +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 -function UI:HideResults() - if not searchFrame then return end - if searchFrame.StopKeyRepeat then searchFrame.StopKeyRepeat() end - if searchFrame.ClearToolbarFocus then searchFrame.ClearToolbarFocus() end - if not resultsFrame then return end - resultsFrame:Hide() - if escCatcher then escCatcher:Hide() end - if resultsFrame.pinSeparator then - resultsFrame.pinSeparator:Hide() +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 resultsFrame.categorySeps then - for _, sep in ipairs(resultsFrame.categorySeps) do sep:Hide() end + if tabSystem.tabs then + for _, tab in ipairs(tabSystem.tabs) do add(tab) end end - if resultsFrame.truncIndicator then - resultsFrame.truncIndicator:Hide() + local children = { tabSystem:GetChildren() } + for i = 1, #children do + local child = children[i] + if child and child:IsShown() then add(child) end end - if resultsFrame.truncSeparator then - resultsFrame.truncSeparator:Hide() + + 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 - selectedIndex = 0 - toggleFocused = false - self:UpdateSelectionHighlight(true) -end -function UI:ShowPinnedItems() - if not resultsFrame then return end - local pins = GetAllPins() - if #pins == 0 then - self:HideResults() - return + 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 - -- Build synthetic hierarchical entries and delegate to the same renderer - -- used during search, so pinned items look identical in both cases. - collapsedNodes = {} - expandedContainers = {} - local entries = { - -- "Pinned Paths" collapsible header - { - isPinHeader = true, - name = "Pinned Paths", - depth = 0, - isPathNode = true, - isMatch = false, - }, - } - for _, pin in ipairs(pins) do - tinsert(entries, { - name = pin.name, - depth = 0, - isPathNode = false, - isMatch = true, - isPinned = true, - data = pin, - }) + 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 - self:ShowHierarchicalResults(entries) + return btn.Click ~= nil 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 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 -function UI:CountVisibleResults() - local count = 0 - for i = 1, MAX_BUTTON_POOL do - if resultButtons[i]:IsShown() then - count = i - else - break +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 count + return nil, nil end -function UI:MoveSelection(delta) - local visibleCount = self:CountVisibleResults() - if visibleCount == 0 then return 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 - local newIndex = selectedIndex + delta - if EasyFind.db.uiResultsAbove then - -- Above: exit to editbox past last result, clamp at first - if newIndex > visibleCount then newIndex = 0 - elseif newIndex < 1 then newIndex = 1 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 - -- Below: exit to editbox past first result, clamp at last - if newIndex < 0 then newIndex = 0 - elseif newIndex > visibleCount then newIndex = visibleCount end + 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 - - selectedIndex = newIndex - toggleFocused = false - self:UpdateSelectionHighlight() + return fallback end -function UI:JumpToStart() - if self:CountVisibleResults() > 0 then - selectedIndex = 1 - toggleFocused = false - self:UpdateSelectionHighlight() +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 -function UI:JumpToEnd() - local visibleCount = self:CountVisibleResults() - if visibleCount > 0 then - selectedIndex = visibleCount - toggleFocused = false - self:UpdateSelectionHighlight() +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 -function UI:JumpToNextSection(direction) - local visibleCount = self:CountVisibleResults() - if visibleCount == 0 then return end - - local startIdx = selectedIndex - if startIdx == 0 then - startIdx = direction > 0 and 0 or visibleCount + 1 +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 - -- Find the first non-pinned row index (UI search section start) - local uiSectionStart = 0 - for i = 1, visibleCount do - local row = resultButtons[i] - if row and not row.isPinHeader and not row.isPinned then - uiSectionStart = i - break +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 - -- Find the next section boundary in the given direction. - -- Boundaries: first non-pinned row (UI search) + any isSectionHeader row. - local idx = startIdx + direction - while idx >= 1 and idx <= visibleCount do - local row = resultButtons[idx] - if row and (row.isSectionHeader or idx == uiSectionStart) then - selectedIndex = idx - toggleFocused = false - self:UpdateSelectionHighlight() +-- 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 - idx = idx + direction + 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 -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) - end - -- Tab selection highlight (Retail theme) - if resultRow.tabSelectionHighlight then - resultRow.tabSelectionHighlight:SetShown(i == selectedIndex and resultRow.headerTab:IsShown() and not toggleFocused) - 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 +-- 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 + + local function ensureFrameOnTab(attempt) + local frame = _G["PlayerSpellsFrame"] + if not (frame and frame:IsShown()) then + OpenPlayerSpellsFrame(TALENTS_TAB) + if attempt < 30 then + C_Timer.After(0.05, function() ensureFrameOnTab(attempt + 1) end) end - if resultRow.tabHoverOverlay then - resultRow.tabHoverOverlay:SetShown(showToggle and not isPinToggle) + 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 - if resultRow.tabText then - if showToggle and not isPinToggle 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 + return + end + -- 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 - end - if selectedIndex > 0 then - if resultButtons[selectedIndex] then - Utils.ScrollToButton(resultsFrame.scrollFrame, resultButtons[selectedIndex]) - end - if searchFrame.editBox:HasFocus() then - searchFrame.editBox:ClearFocus() - end - Utils.SafeCallMethod(navFrame, "EnableKeyboard", true) - else - local wasNavigating = navFrame:IsKeyboardEnabled() - 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:SetFocus() + + 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 - -- Bind Enter to the selected result button when it's an outfit or toy, - -- so the secure action handler fires on keypress (same as mouse click). - if not InCombatLockdown() then - local selRow = selectedIndex > 0 and resultButtons[selectedIndex] - local rd = selRow and selRow.data - if rd and (rd.outfitID or rd.toyItemID) then - local btnName = selRow:GetName() - if btnName then - SetOverrideBindingClick(navFrame, true, "ENTER", btnName, "LeftButton") + ensureFrameOnTab(1) +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 + + OpenPlayerSpellsFrame(3) + 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 - else - ClearOverrideBindings(navFrame) + 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 - end -end -function UI:ActivateSelected() - if selectedIndex > 0 and selectedIndex <= MAX_BUTTON_POOL then - local resultRow = resultButtons[selectedIndex] - if resultRow:IsShown() then - -- Don't allow activating unearned currencies - if resultRow.isUnearnedCurrency then + 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 - -- 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 + 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 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 - self:ShowHierarchicalResults(cachedHierarchical, true) - selectedIndex = savedIndex - toggleFocused = savedToggle - self:UpdateSelectionHighlight() + -- 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 + if highlight.HighlightSpellbookSpell then + highlight:HighlightSpellbookSpell(elementBtn, stillRepresentsTarget) + else + highlight:HighlightFrame(elementBtn, nil, stillRepresentsTarget) + HideHighlightOnHover(elementBtn) end - elseif resultRow.data then - self:SelectResult(resultRow.data) + return end + end + + local btn = FindSpellbookButton(paged, data, false) + if btn and highlight then + if highlight.HighlightSpellbookSpell then + highlight:HighlightSpellbookSpell(btn, stillRepresentsTarget) + else + highlight:HighlightFrame(btn, nil, stillRepresentsTarget) + HideHighlightOnHover(btn) + end + 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 - -- Fallback: select first result if nothing is highlighted - self:SelectFirstResult() + + C_Timer.After(0.05, function() reveal(1) end) end --- Hide vendor-only transmog controls when opened via search (not at an NPC). --- Shows a message explaining full functionality requires a transmogrifier. --- Restores everything when the frame closes. --- Show or hide the lock overlay on a result row's outfit icon. -function UI:UpdateOutfitLockOverlay(resultRow, isLocked) - if not resultRow.icon then return end - if not resultRow._lockOverlay then - local overlay = resultRow:CreateTexture(nil, "OVERLAY") - overlay:SetAtlas("transmog-outfit-spellFrame-active") - overlay:SetPoint("CENTER", resultRow.icon, "CENTER", 0, 0) - resultRow._lockOverlay = overlay +local function GetPetJournalFrame() + return _G["PetJournal"] + or (CollectionsJournal and CollectionsJournal.PetJournal) + or (CollectionsJournal and CollectionsJournal.PetJournalFrame) +end - end - local size = (resultRow.icon:GetWidth() or 16) + 6 - resultRow._lockOverlay:SetSize(size, size) - resultRow._lockOverlay:SetShown(isLocked) +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:ApplyTransmogBrowseMode() - if not TransmogFrame then return 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 - -- Collect vendor-only frames to hide - local hidden = {} - local outfitCollection = TransmogFrame.OutfitCollection - if outfitCollection then - if outfitCollection.PurchaseOutfitButton then - outfitCollection.PurchaseOutfitButton:Hide() - hidden[#hidden + 1] = outfitCollection.PurchaseOutfitButton - end - if outfitCollection.SaveOutfitButton then - outfitCollection.SaveOutfitButton:Hide() - hidden[#hidden + 1] = outfitCollection.SaveOutfitButton + 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 - if outfitCollection and outfitCollection.MoneyFrame then - outfitCollection.MoneyFrame:Hide() - hidden[#hidden + 1] = outfitCollection.MoneyFrame + 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 - -- Hide the Situations tab (vendor-only feature) - local wardrobeCollection = TransmogFrame.WardrobeCollection - local tabHeaders = wardrobeCollection and wardrobeCollection.TabHeaders - if tabHeaders then - for _, tab in ipairs({ tabHeaders:GetChildren() }) do - if tab.GetText and tab:GetText() == "Situations" then - tab:Hide() - hidden[#hidden + 1] = tab - break - end - 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 - -- Disable right-click on outfit name buttons (shows "Change Name/Icon" - -- which doesn't work without a vendor). ScrollBox items are recycled, - -- so re-register on each visible frame and hook the ScrollBox update. - local outfitScrollBox = outfitCollection and outfitCollection.OutfitList - and outfitCollection.OutfitList.ScrollBox - if outfitScrollBox and outfitScrollBox.EnumerateFrames then - local function disableOutfitRightClick() - for _, itemFrame in outfitScrollBox:EnumerateFrames() do - local outfitBtn = itemFrame.OutfitButton - if outfitBtn and outfitBtn.RegisterForClicks then - outfitBtn:RegisterForClicks("LeftButtonUp") - end - end +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 - disableOutfitRightClick() - -- Re-apply when ScrollBox recycles frames (scroll, resize) - if not outfitScrollBox._efBrowseHooked then - outfitScrollBox._efBrowseHooked = true - hooksecurefunc(outfitScrollBox, "Update", function() - if TransmogFrame._efBrowseMode then - disableOutfitRightClick() - end - end) + 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 - TransmogFrame._efBrowseMode = true - TransmogFrame._efHiddenFrames = hidden + local petID = self:ResolvePetGUID(data) + if petID and petID ~= data.petID then data.petID = petID end - -- Browse-mode message (left panel, where vendor buttons were) - if not TransmogFrame._efBrowseMsg then - local msg = TransmogFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal") - msg:SetText("Visit a transmogrification vendor for full functionality.") - msg:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) - msg:SetJustifyH("CENTER") - TransmogFrame._efBrowseMsg = msg + 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 - local msg = TransmogFrame._efBrowseMsg - msg:ClearAllPoints() - local anchor = outfitCollection and outfitCollection.PurchaseOutfitButton - if anchor then - msg:SetPoint("TOP", anchor, "TOP", 0, 0) - elseif outfitCollection then - msg:SetPoint("BOTTOM", outfitCollection, "BOTTOM", 0, 20) - else - msg:SetPoint("BOTTOM", TransmogFrame, "BOTTOM", 0, 30) + if C_PetJournal.SetSelectedPet and petID then + pcall(C_PetJournal.SetSelectedPet, petID) end - msg:SetWidth((outfitCollection and outfitCollection:GetWidth() - 20) or 280) - msg:Show() - -- Situations message (top right, near the hidden tab) - if not TransmogFrame._efSituationsMsg then - local sitMsg = TransmogFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal") - sitMsg:SetText("See transmogrification vendor\nto adjust Situations settings.") - sitMsg:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) - sitMsg:SetJustifyH("RIGHT") - TransmogFrame._efSituationsMsg = sitMsg - end - local sitMsg = TransmogFrame._efSituationsMsg - sitMsg:ClearAllPoints() - if tabHeaders then - sitMsg:SetPoint("LEFT", tabHeaders, "RIGHT", 8, 6) - else - sitMsg:SetPoint("TOPRIGHT", TransmogFrame, "TOPRIGHT", -40, -55) + 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 - sitMsg:Show() - -- Restore on hide (one-shot hook, reads _efHiddenFrames at fire time) - if not TransmogFrame._efBrowseHooked then - TransmogFrame._efBrowseHooked = true - TransmogFrame:HookScript("OnHide", function(self) - self._efBrowseMode = nil - if self._efHiddenFrames then - for _, frame in ipairs(self._efHiddenFrames) do - frame:Show() - end - self._efHiddenFrames = nil - end - if self._efBrowseMsg then - self._efBrowseMsg:Hide() - end - if self._efSituationsMsg then - self._efSituationsMsg:Hide() - end - -- Restore right-click on outfit buttons - local oc = self.OutfitCollection - local sb = oc and oc.OutfitList and oc.OutfitList.ScrollBox - if sb and sb.EnumerateFrames then - for _, itemFrame in sb:EnumerateFrames() do - local outfitBtn = itemFrame.OutfitButton - if outfitBtn and outfitBtn.RegisterForClicks then - outfitBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp") - end - end - end - end) + if petJournal and petName then + return SearchFrameTreeFuzzy(petJournal, slower(petName)) end + return nil end -function UI:SelectResult(data) +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.searchCommand then + self:RunSearchBarCommand("/" .. data.searchCommand) + return + end + + if data.calculatorLauncher then + self:OpenCalculator("") + return + end + + if data.calculatorResult then + self:ArmCalculatorResultForData(data) + return + end selectingResult = true searchFrame.editBox:SetText("") searchFrame.editBox:ClearFocus() searchFrame.editBox.placeholder:Show() selectingResult = false - self:HideResults() + if EasyFind.db.autoHide then + self:Hide() + else + 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 @@ -5039,21 +13624,32 @@ function UI:SelectResult(data) Transmog_LoadUI() end if TransmogFrame then - ShowUIPanel(TransmogFrame) + SecureShowUIPanel(TransmogFrame) self:ApplyTransmogBrowseMode() end 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 - -- 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 @@ -5066,14 +13662,49 @@ function UI:SelectResult(data) 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 }, }, } - if EasyFind.db.directOpen then + if useFast then + self:DirectOpen(guideData) + else + EasyFind:StartGuide(guideData) + end + 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 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 = 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 useFast then self:DirectOpen(guideData) else EasyFind:StartGuide(guideData) @@ -5089,22 +13720,125 @@ function UI:SelectResult(data) return end - -- Toy: handled by SecureActionButton on mousedown (UseToyByItemID is protected) - if data.toyItemID then 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: 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 + -- 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) + end + return + end + + -- 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 forceGuide or (IsControlKeyDown and IsControlKeyDown()) then + local guideData = BuildPetJournalGuideData(data) + if useFast then + self:DirectOpen(guideData) + else + EasyFind:StartGuide(guideData) + end + return + end - -- Pet: summon/dismiss - if data.petID then - if C_PetJournal and C_PetJournal.SummonPetByGUID then - C_PetJournal.SummonPetByGUID(data.petID) + 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 + end end 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) + 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 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 + -- (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. + if not showInBags and self:GetBagItemActionKind(data) ~= "show" then + return + end + self:OpenBagItemLocation(data) + return + end + if not data.steps or #data.steps == 0 then return end + end + + -- 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 + if IsControlKeyDown() then + UI:OpenMacroFrameAt(data.macroIndex, data.macroIsChar) + end + return end + if data.steps then EasyFind:StartGuide(data) end return end @@ -5113,7 +13847,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 @@ -5129,11 +13863,63 @@ function UI:SelectResult(data) self:DirectOpen(data) end elseif data.steps then - -- Step-by-step guide mode EasyFind:StartGuide(data) 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() + Utils.SafeAfter(0.05, reveal) +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 @@ -5199,6 +13985,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 @@ -5214,6 +14001,10 @@ 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 + 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 @@ -5248,6 +14039,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] @@ -5257,12 +14050,23 @@ function UI:DirectOpen(data) Transmog_LoadUI() end if TransmogFrame then - ShowUIPanel(TransmogFrame) + SecureShowUIPanel(TransmogFrame) UI:ApplyTransmogBrowseMode() end 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) @@ -5271,16 +14075,33 @@ 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 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 + SecureShowUIPanel(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 + OpenButtonFrame(step.buttonFrame, steps[i + 1]) end if step.waitForFrame and step.tabIndex then @@ -5298,18 +14119,46 @@ 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 + + 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 @@ -5326,33 +14175,106 @@ 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 + 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. + 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: find by name in ScrollBox and click + -- 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) + 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) + 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 + -- 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 @@ -5405,6 +14327,62 @@ function UI:DirectOpen(data) end end + -- 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) + 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 via SetScrollPercentage + select + if step.transmogSetID and i == executeCount then + local scf = _G["WardrobeCollectionFrame"] + and _G["WardrobeCollectionFrame"].SetsCollectionFrame + if scf then + C_Timer.After(0.1, function() + 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 + end + end + if lc and lc.SelectElementDataMatchingSetID then + pcall(lc.SelectElementDataMatchingSetID, lc, step.transmogSetID) + end + end) + end + end + -- Currency/faction headers pre-expanded via API, nothing to execute if step.currencyID then @@ -5460,130 +14438,665 @@ function UI:DirectOpen(data) end end - -- Hand off remaining steps to the guided highlight - if not finalStepNavigable and Highlight then - Highlight:StartGuideAtStep(data, executeCount + 1) - end + -- 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 + + executeFrom(1) +end + +function UI:ClickCharacterSidebar(sidebarIndex) + -- The sidebar buttons are PaperDollSidebarTab1/2/3 inside PaperDollSidebarTabs + -- (confirmed via Frame Inspector) + + if not CharacterFrame or not CharacterFrame:IsShown() then + return false + end + + -- Switch to the Character tab (tab 1) first + if PanelTemplates_GetSelectedTab and PanelTemplates_GetSelectedTab(CharacterFrame) ~= 1 then + OpenCharacterFrame(1) + end + + -- Method 1: Try PaperDollSidebarTab buttons directly (Frame Inspector confirmed names) + local sidebarTab = _G["PaperDollSidebarTab" .. sidebarIndex] + if sidebarTab then + if sidebarTab:IsShown() then + return ClickButton(sidebarTab) + else + -- Tab exists but isn't shown yet - try after a brief delay + C_Timer.After(0.2, function() + if sidebarTab:IsShown() then ClickButton(sidebarTab) end + end) + return true + end + end + + -- Method 2: Search PaperDollSidebarTabs container children by index + local sidebarTabs = _G["PaperDollSidebarTabs"] + if not sidebarTabs and PaperDollFrame then + sidebarTabs = PaperDollFrame.SidebarTabs + end + if sidebarTabs then + local nTabs = select("#", sidebarTabs:GetChildren()) + if sidebarIndex <= nTabs then + return ClickButton(select(sidebarIndex, sidebarTabs:GetChildren())) + end + end + + -- Method 3: Try the ToggleSidebarTab function if available + if PaperDollFrame and PaperDollFrame.ToggleSidebarTab then + PaperDollFrame:ToggleSidebarTab(sidebarIndex) + return true + end + + return false +end + +function UI:ClickAchievementCategory(categoryName, categoryID) + if not AchievementFrame or not AchievementFrame:IsShown() then + return false + end + + 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, MatchesCategory) + if elementData then + 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) + elementData = finder and finder(dataProvider, MatchesCategory) + if not elementData then return false end + end + -- Try Blizzard's official selection function + if AchievementFrameCategories_SelectElementData then + 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 + if scrollBox.ScrollToElementData then + pcall(scrollBox.ScrollToElementData, scrollBox, elementData) + end + local frame = scrollBox.FindFrame and scrollBox:FindFrame(elementData) + 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 +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 + +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, _, _, _, _, _, _, isFavorite = pcall(C_PetJournal.GetPetInfoByPetID, petID) + if ok and isFavorite ~= nil then return NormalizePetFavorite(isFavorite) end + end + 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) + 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 - executeFrom(1) +-- 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 --- 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) +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 - if not CharacterFrame or not CharacterFrame:IsShown() then - return false +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 + local popup = StaticPopup_Show("EASYFIND_PET_RENAME", nil, nil, petID) + LiftPopupStrata(popup or FindPopupSlot("EASYFIND_PET_RENAME")) +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 - -- Switch to the Character tab (tab 1) first - if PanelTemplates_GetSelectedTab and PanelTemplates_GetSelectedTab(CharacterFrame) ~= 1 then - ClickButton(_G["CharacterFrameTab1"]) +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 - -- Method 1: Try PaperDollSidebarTab buttons directly (Frame Inspector confirmed names) - local sidebarTab = _G["PaperDollSidebarTab" .. sidebarIndex] - if sidebarTab then - if sidebarTab:IsShown() then - return ClickButton(sidebarTab) - else - -- Tab exists but isn't shown yet - try after a brief delay - C_Timer.After(0.2, function() - if sidebarTab:IsShown() then ClickButton(sidebarTab) end - end) - return true +-- 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 - -- Method 2: Search PaperDollSidebarTabs container children by index - local sidebarTabs = _G["PaperDollSidebarTabs"] - if not sidebarTabs and PaperDollFrame then - sidebarTabs = PaperDollFrame.SidebarTabs - end - if sidebarTabs then - local nTabs = select("#", sidebarTabs:GetChildren()) - if sidebarIndex <= nTabs then - return ClickButton(select(sidebarIndex, sidebarTabs:GetChildren())) - end +-- 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. API: C_CurrencyInfo.SetCurrencyFilter(filterType). +-- DiscoveredAndAllAccountTransferable = "warband"; DiscoveredOnly = "all". +function UI:ApplyTokenFrameFilter(mode) + 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 + RefreshDropdownLabel(TokenFrame.filterDropdown) end +end - -- Method 3: Try the ToggleSidebarTab function if available - if PaperDollFrame and PaperDollFrame.ToggleSidebarTab then - PaperDollFrame:ToggleSidebarTab(sidebarIndex) - return true +-- Reputation sort type. API: C_Reputation.SetReputationSortType(sortType). +-- None = "all"; Account = "warband"; Character = "char". +function UI:ApplyReputationFilter(mode) + 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 + sortType = Enum.ReputationSortType.Account + elseif mode == "char" then + sortType = Enum.ReputationSortType.Character + else + sortType = Enum.ReputationSortType.None end + 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 + RefreshDropdownLabel(ReputationFrame.filterDropdown) + end +end - return false +-- Show Legacy Reputations. API: C_Reputation.SetLegacyReputationsShown(bool). +function UI:ApplyReputationShowLegacy(show) + 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 --- Helper function to click an achievement or statistics category button -function UI:ClickAchievementCategory(categoryName) - if not AchievementFrame or not AchievementFrame:IsShown() then - return false +-- Hide Passives. CVar-backed: spellBookHidePassives ("0" / "1"). +-- Mirrors SpellBookFrameMixin:SetupSettingsDropdown's SetSelected logic. +function UI:ApplySpellBookHidePassives(hide) + hide = hide and true or false + 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 - local categoryNameLower = slower(categoryName) +-- 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 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 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 + -- 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 - -- 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 - 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) - 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 - -- 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) - if not elementData then return false end - end - -- Try Blizzard's official selection function - if AchievementFrameCategories_SelectElementData then - AchievementFrameCategories_SelectElementData(elementData) - return true - end - -- Fallback: scroll to it and click the visible button - scrollBox:ScrollToElementData(elementData) - local frame = scrollBox.FindFrame and scrollBox:FindFrame(elementData) - if frame and ClickButton(frame) then return true end - 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 - return false + -- 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 --- Helper function to click a side tab (PvE Group Finder tabs) --- Helper to extract text from various button types +-- 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 + SecureShowUIPanel(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 + + function UI:GetButtonText(frame) return GetButtonText(frame) end @@ -5598,6 +15111,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) @@ -5607,11 +15121,23 @@ 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 + -- 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 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 @@ -5619,11 +15145,10 @@ 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 searchFrame.editBox:SetFocus() end end) @@ -5632,17 +15157,85 @@ end function UI:Hide() if not searchFrame then return end + 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() + if escCatcher then escCatcher:Hide() end 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) end --- Helper function to expand a currency header by name +-- 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 + -- 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() ~= "") 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 + -- replaced by pinned rows, or hidden when there are no pins. + self:OnSearchTextChanged("", true) + return + end + self:Hide() +end + 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 @@ -5667,7 +15260,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 @@ -5686,7 +15278,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 @@ -5720,7 +15311,6 @@ function UI:OpenPortraitMenu() end end --- Helper function to click a portrait menu option by name function UI:ClickPortraitMenuOption(optionName) local optionNameLower = slower(optionName) @@ -5732,7 +15322,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 @@ -5755,7 +15344,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 @@ -5795,6 +15383,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) @@ -5837,66 +15426,93 @@ 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 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 + +-- 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) - else - searchFrame:SetBackdropColor(0, 0, 0, alpha) + 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 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) - ns.SetSearchBorderShown(searchFrame, true) - ns.ScaleSearchBorder(searchFrame, scale) - ns.SetSearchBorderBgAlpha(searchFrame, alpha) - 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) + 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 +function UI:UpdateSearchBarHeight() + self:UpdateFontSize() +end + 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) 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.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) @@ -5908,11 +15524,29 @@ 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 +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) @@ -5936,24 +15570,17 @@ 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) - -- 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) @@ -5985,14 +15612,68 @@ 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 +-- 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 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 @@ -6007,11 +15688,12 @@ 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) - -- Golden glow overlay + -- 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) @@ -6032,7 +15714,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 @@ -6043,107 +15725,187 @@ function UI:ShowFirstTimeSetup() self:SetBackdropColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], pulseAlpha) end) - -- "EasyFind" label overlaid on the glow (like edit-mode frame labels) + -- 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("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) + 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) + 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() + resizer:SetSize(16, 16) + end + + -- Dragging the bottom-left corner adjusts the search bar preview in two axes: + -- horizontal -> uiSearchWidth (symmetric growth around a locked top-center) + -- vertical -> uiSearchBarHeight + -- + -- 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.startBarHeight = UI:GetSearchBarHeight() 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.startBarHeight = 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) + + Utils.SafeOnUpdate(resizer, 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.startBarHeight) 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 bar strip. + -- d(uiSearchWidth) = dxScreen / (effScale * 125) + -- d(barHeight) = -dyScreen / effScale + local newWidth = self.startWidth + dxScreen / (effScale * 125) + local newBarH = self.startBarHeight - dyScreen / effScale + + newWidth = mmax(0.5, mmin(2.5, newWidth)) + newBarH = mmax(24, mmin(56, newBarH)) + + EasyFind.db.uiSearchWidth = newWidth + EasyFind.db.uiSearchBarHeight = newBarH + UI:UpdateWidth() + UI:UpdateSearchBarHeight() + 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. + scaleResizerVisual() + + -- 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) + 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") panel:SetBackdrop({ - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + bgFile = "Interface\\Buttons\\WHITE8x8", edgeFile = TOOLTIP_BORDER, - tile = true, tileSize = 32, edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 } + edgeSize = 14, + insets = { left = 3, right = 3, top = 3, bottom = 3 } }) - panel:SetBackdropColor(DARK_PANEL_BG[1], DARK_PANEL_BG[2], DARK_PANEL_BG[3], DARK_PANEL_BG[4]) - - -- Top header lines (centered) - local header = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight") - header:SetPoint("TOP", panel, "TOP", 0, -12) - header:SetWidth(310) - header:SetJustifyH("CENTER") - header:SetText( - "|cffffffffDrag the search bar to position it.|r\n" .. - "|cffffffffUse the corner handle to resize.|r" - ) + panel:SetBackdropColor(0.05, 0.05, 0.05, 0.95) + panel:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.7) - -- 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" - ) + -- "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 tip and Smart Show section + -- Horizontal separator immediately under the header. 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("LEFT", panel, "LEFT", 12, 0) + sep:SetPoint("RIGHT", panel, "RIGHT", -12, 0) + 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) + -- 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", 0, -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) @@ -6152,78 +15914,188 @@ 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") - -- 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) + -- 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 + + -- 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", smartDesc, "BOTTOM", 0, -16) + + 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", keybindDivider, "BOTTOM", 0, -10) + + 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 + -- 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 + 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) + 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() + 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") - local fadeDesc = fadeCheckbox:CreateFontString(nil, "OVERLAY") - fadeDesc:SetFontObject(fadeCheckbox.Text:GetFontObject()) - fadeDesc:SetPoint("TOPLEFT", fadeCheckbox.Text, "BOTTOMLEFT", 0, -2) - fadeDesc:SetWidth(284) - fadeDesc:SetJustifyH("LEFT") - fadeDesc:SetText("|cff999999Reduces bar opacity while your character is moving.|r") - - -- Footer note - local footer = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight") - footer:SetPoint("BOTTOM", panel, "BOTTOM", 0, 36) - footer:SetWidth(310) - 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") + local gotItBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + gotItBtn:SetSize(100, 22) + gotItBtn:SetPoint("TOP", panel, "BOTTOM", 0, -8) + gotItBtn:SetText("Got it") -- 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() + 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 searchFrame.setupMode = nil searchFrame.editBox:EnableMouse(true) UI:UpdateSearchBarTheme() -- restore proper backdrop colors glow:SetScript("OnUpdate", nil) - resizer:SetScript("OnUpdate", nil) glow:Hide() + resizer:SetScript("OnUpdate", nil) + 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() - EasyFind.db.staticOpacity = not fadeCheckbox:GetChecked() UI:UpdateSmartShow() -- 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 + + gotItBtn:SetScript("OnClick", FinishSetup) + + -- Escape closes the whole tutorial from the positioning panel. + Utils.SafeCallMethod(panel, "EnableKeyboard", true) + Utils.SafeCallMethod(panel, "SetPropagateKeyboardInput", true) + panel:SetScript("OnKeyDown", function(self, key) + if key == "ESCAPE" then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + FinishSetup() + else + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) + end end) + end -- Flash a label on the search frame (used for Currency hint) @@ -6234,11 +16106,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() @@ -6262,25 +16132,14 @@ function UI:FlashLabel(labelText) 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) 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 - local clearSz = ns.CLEAR_BTN_SIZE * scale searchFrame:SetHeight(barH) searchFrame.editBox:SetHeight(contentSz) searchFrame.searchIcon:SetSize(iconSz, iconSz) @@ -6290,37 +16149,35 @@ 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" - local alpha = EasyFind.db.searchBarOpacity or DEFAULT_OPACITY + local alpha = ns.SEARCH_WINDOW_ALPHA or 0.95 if theme.searchBarRounded then searchFrame:SetBackdrop(nil) - ns.ScaleSearchBorder(searchFrame, scale) - ns.SetSearchBorderBgAlpha(searchFrame, alpha) + 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). + if resultsFrame and resultsFrame:IsShown() then + ns.SetRoundedRectDivider(containerFrame, barH, true) + end + 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 } + 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 - local row = resultButtons[i] - ScaleFont(row.text, theme.leafFont) - ScaleFont(row.tabText, theme.pathFont) - 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..7e2c9bc --- /dev/null +++ b/UIPins.lua @@ -0,0 +1,194 @@ +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", + -- 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", +} + +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) + local list = GetPinList(data) + if not list then return end + local clean = CleanForStorage(data) + clean.isPinned = true + 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 + +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 aa05a70..dc720d1 100644 --- a/Utils.lua +++ b/Utils.lua @@ -46,25 +46,19 @@ 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 local fn = obj[method] if not fn then return false end 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) @@ -79,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) @@ -90,8 +82,339 @@ function Utils.SafeAfter(delay, fn) 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.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 + +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 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 + 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 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 + + -- 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 + 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 + + programmatic = true + 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 + if smoothExtendDone then + smoothExtendDone = false + return + end + local candidate = findCandidate(typedText) + 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 + local notify = restoreBackspaceNotify + restoreBackspaceText, restoreBackspaceCursor = nil, nil + restoreBackspaceNotify = false + 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) + currentCandidate = nil + smoothExtendDone = false + if notify and onBackspaceAutocompleteRestored then + onBackspaceAutocompleteRestored(self, typedText) + end + return + end + local cursorPos = self:GetCursorPosition() or #current + if not userInput and cursorPos == 0 and current ~= "" then + cursorPos = #current + end + local typed = ssub(current, 1, cursorPos) + if charDispatchedTyped and typed == charDispatchedTyped then + charDispatchedTyped = nil + return + end + if typed == typedText then return end + local prevText = typedText + local prevLen = #typedText + typedText = typed + local grew = #typedText > prevLen + -- 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 + 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("OnEditFocusLost", StripAutocomplete) + + 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:SetText(candidate) + self:SetCursorPosition(cursorPos or #candidate) + self:HighlightText(0, 0) + programmatic = false + typedText = candidate + currentCandidate = nil + 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, "tab") + end) + + 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 = 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 + 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) + + editBox.UpdateAutocomplete = ApplyAutocomplete + editBox.StripAutocomplete = StripAutocomplete + 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 + function Utils.ScrollToButton(scrollFrame, button) if not scrollFrame or not button then return end local _, _, _, _, btnOffsetY = button:GetPoint(1) @@ -109,31 +432,34 @@ 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 -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" -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} +-- 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. +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} 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 +470,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 * CAP_DISPLAY_RATIO + 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 +506,158 @@ function ns.CreateSearchBorder(frame) fillLeft = fillLeft, fillMid = fillMid, fillRight = fillRight, borderLeft = borderLeft, borderMid = borderMid, borderRight = borderRight, } + frame:HookScript("OnSizeChanged", ApplyCapWidths) + ApplyCapWidths(frame) 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) +-- 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 + +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) + 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) + 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") + 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") + 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) + frame:HookScript("OnSizeChanged", ApplyContainerCornerSize) +end + +-- 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) +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 + +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 + +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). +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 DisablePixelSnap(t) 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 DisablePixelSnap(t) 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") + d:SetColorTexture(BORDER_R, BORDER_G, BORDER_B, 1) + d:SetHeight(1) + d:Hide() + frame.combinedDivider = d + return d +end + +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) @@ -212,12 +690,14 @@ 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() @@ -225,13 +705,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 @@ -243,13 +721,26 @@ function Utils.GetButtonText(btn) return nil end --- Collects all text from a frame into a single string for fuzzy matching. +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 + 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() @@ -276,36 +767,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) @@ -328,7 +812,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) @@ -362,14 +845,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) } @@ -384,151 +865,170 @@ 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 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) + track:SetAllPoints(bar) - 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") - - 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) + 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 + 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 - local range = scrollFrame:GetVerticalScrollRange() - if range <= 0 then return end - - local _, cursorY = GetCursorPosition() - local scale = track:GetEffectiveScale() - cursorY = cursorY / scale + 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 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 @@ -544,17 +1044,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 @@ -584,22 +1081,22 @@ 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) + + 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) @@ -609,13 +1106,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 @@ -637,10 +1127,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 @@ -651,11 +1137,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" @@ -671,8 +1152,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) @@ -694,3 +1173,369 @@ function Utils.CreateClearButton(parent, globalName) return btn end + +-- 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 + +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.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 {} + + HideOtherMenus(globalName, nil) + + local menu = FindFreeMenu(globalName) + if not menu then + cursorMenuCounter = cursorMenuCounter + 1 + local frameName = globalName .. "_" .. cursorMenuCounter + menu = CreateFrame("Frame", frameName, 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 + 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 + 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 + for i = 1, #self.rows do + local row = self.rows[i] + if row then row:Hide() end + end + end + end) + menu:SetScript("OnUpdate", function(self) + if MenuHasMouse(self) 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 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 + if not _G[globalName] then _G[globalName] = menu end + end + + menu:SetFrameStrata(opts.strata or "TOOLTIP") + menu:SetFrameLevel(opts.level or 10000) + 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:EnableMouse(true) + row:SetHeight(rowH) + 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) + 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 + 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 + 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 + local row = menu.rows[i] + if row then + row:Hide() + row:SetScript("OnClick", nil) + row:SetScript("OnMouseDown", nil) + end + end + + 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("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, extra) + local rows = {} + 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 + + 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 + +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 + +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..e58e174 --- /dev/null +++ b/Wizard.lua @@ -0,0 +1,857 @@ +local _, ns = ... + +local Wizard = {} +ns.Wizard = Wizard + +local Utils = ns.Utils +local SafeCallMethod = Utils.SafeCallMethod +local SafeAfter = Utils.SafeAfter + +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 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 + ns.RegisterAddonFont(fs, weight, sizeOverride, flags) + end +end + +local frame +local pages = {} +local pageIdx = 1 +local dots = {} +local backBtn, nextBtn + +local function MakeButton(parent, text, variant, w) + local b = CreateFrame("Button", nil, parent) + 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", 10) + b._label = fs + + if variant == "rounded" then + ns.CreateRoundedRectBorder(b) + ns.SetRoundedRectBarHeight(b, 8) + ns.SetRoundedRectBorderBgAlpha(b, 1) + -- Border ring hidden: 256px texture aliases badly at ~5px corners. + ns.SetRoundedRectBorderEdgeShown(b, false) + local function tintFill(rr, gg, bb) + ns.SetRoundedRectFill(b, rr, gg, bb, 1, true) + 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 ns.version then + EasyFind.db.lastSeenVersion = ns.version + if ns.version == ns.REVAMPED_TUTORIAL_VERSION then + EasyFind.db.revampedTutorialVersion = ns.REVAMPED_TUTORIAL_VERSION + end + 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") + 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 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) + + 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 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) + 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 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) + + return p +end + +local function FeatureTile(parent, atlas, file, coords, title, desc, onClick) + -- Tile anchored to holder CENTER so hover SetScale grows symmetrically. + 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") + + ns.CreateRoundedRectBorder(tile) + ns.SetRoundedRectBarHeight(tile, 10) + ns.SetRoundedRectBorderBgAlpha(tile, 0.95) + 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) + 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") + + tile:SetScript("OnEnter", function(self) + self:SetScale(1.04) + ns.SetRoundedRectBorderFillColor(self, 0.09, 0.09, 0.11, 1) + end) + tile:SetScript("OnLeave", function(self) + self:SetScale(1.00) + ns.SetRoundedRectBorderFillColor(self, 0.05, 0.05, 0.06, 1) + end) + if onClick then + tile:SetScript("OnClick", onClick) + end + + holder.tile = tile + return holder +end + +local function BuildPage2(parent) + local p = MakePage(parent) + + 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, detailText, opts) + opts = opts or {} + 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) + + 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 = CreateCarouselDetailView("Search", SEARCH_TUTORIAL_SLIDES) + local d2 = CreateDetailView("Map Search Tab", + "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, and more.", + function() ShowDetail(d1) end) + t1:SetPoint("TOPLEFT", grid, "TOPLEFT", 38, -96) + + local t2 = FeatureTile(grid, "Waypoint-MapPin-Untracked", nil, nil, + "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) + + -- 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.", + function() ShowDetail(d3) end) + t3:SetPoint("TOPLEFT", grid, "TOPLEFT", 38, -222) + + 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) + t4:SetPoint("TOPRIGHT", grid, "TOPRIGHT", -38, -222) + + p.OnEnter = ShowGrid + + return p +end + +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 + + ns.CreateRoundedRectBorder(btn) + ns.SetRoundedRectBarHeight(btn, 40) + ns.SetRoundedRectBorderBgAlpha(btn, 1) + ns.SetRoundedRectBorderEdgeShown(btn, false) + ns.SetRoundedRectBorderFillColor(btn, 0.18, 0.18, 0.20, 1) + + local fs = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + fs:SetPoint("CENTER") + ApplyInter(fs, "semibold") + btn._label = fs + + local function tintFill(rr, gg, bb) + ns.SetRoundedRectBorderFillColor(btn, rr, gg, bb, 1) + end + + local function setHover(hover) + 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) + 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, "") + 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 Tab") + kbWidgets = { uiKb, mapKb } + + uiKb.label:SetPoint("RIGHT", uiKb.btn, "LEFT", -14, 0) + 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) + + 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 + for i = 1, #kbWidgets do RefreshKbWidget(kbWidgets[i]) end + end + + return p +end + +local BANNER_H = 29 + +local function CreateFrameOnce() + if frame then return frame end + + local f = CreateFrame("Frame", "EasyFindWizard", UIParent) + f:SetSize(WIZ_W, WIZ_H) + 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 + + ns.CreateRoundedRectBorder(f) + ns.SetRoundedRectBarHeight(f, 16) + ns.SetRoundedRectBorderBgAlpha(f, PANEL_BG_ALPHA) + -- Border ring hidden: its corner cells band against the gradient fill. + 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. + 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 + + -- 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 + 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), + lerp(0.022, 0.20, t), + lerp(0.030, 0.22, t) + end + + -- 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 + 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: first color is bottom, second is 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) + + -- 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) + 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) + pageHost:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -8, 6 + BANNER_H + 4) + + pages = { + BuildPage1(pageHost), + BuildPage2(pageHost), + BuildPage3(pageHost), + } + + 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, 12) + ns.SetRoundedRectBorderBgAlpha(footer, 1) + ns.SetRoundedRectFill(footer, 0.075, 0.075, 0.085, 1, true) + ns.SetRoundedRectBorderEdgeShown(footer, false) + + 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", 12 + DOT_ACTIVE / 2 + (i - 1) * (DOT_SZ + DOT_GAP), 0) + dots[i] = d + end + + nextBtn = MakeButton(footer, "Continue", "rounded", 78) + nextBtn:SetPoint("RIGHT", footer, "RIGHT", -8, 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() + if pageIdx >= #pages then + FinishWizard(true) + else + ShowPage(pageIdx + 1) + end + end) + + f:SetScript("OnKeyDown", function(self, key) + if kbWaitingFor then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) + return + end + if key == "ESCAPE" then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + FinishWizard(false) + else + Utils.SafeCallMethod(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/demo-cog.tga b/demo-cog.tga new file mode 100644 index 0000000..1ea283e Binary files /dev/null and b/demo-cog.tga differ diff --git a/demo-pause.tga b/demo-pause.tga new file mode 100644 index 0000000..d4dd4df Binary files /dev/null and b/demo-pause.tga differ diff --git a/demo-replay.tga b/demo-replay.tga new file mode 100644 index 0000000..d81ee7b Binary files /dev/null and b/demo-replay.tga differ diff --git a/textures/CalcCardBorder.tga b/textures/CalcCardBorder.tga new file mode 100644 index 0000000..6285ca0 Binary files /dev/null and b/textures/CalcCardBorder.tga differ diff --git a/textures/CalcCardFill.tga b/textures/CalcCardFill.tga new file mode 100644 index 0000000..51fbbe4 Binary files /dev/null and b/textures/CalcCardFill.tga differ diff --git a/textures/CalcHalfLeft.tga b/textures/CalcHalfLeft.tga new file mode 100644 index 0000000..543bd05 Binary files /dev/null and b/textures/CalcHalfLeft.tga differ diff --git a/textures/CalcHalfRight.tga b/textures/CalcHalfRight.tga new file mode 100644 index 0000000..985ec59 Binary files /dev/null and b/textures/CalcHalfRight.tga differ diff --git a/textures/CombinedBorder.tga b/textures/CombinedBorder.tga new file mode 100644 index 0000000..357216e Binary files /dev/null and b/textures/CombinedBorder.tga differ diff --git a/textures/near-track-ring.tga b/textures/CombinedFill.tga similarity index 78% rename from textures/near-track-ring.tga rename to textures/CombinedFill.tga index 1b73106..4e960a7 100644 Binary files a/textures/near-track-ring.tga and b/textures/CombinedFill.tga differ diff --git a/textures/FilterButtonCircle.tga b/textures/FilterButtonCircle.tga new file mode 100644 index 0000000..468c91c Binary files /dev/null and b/textures/FilterButtonCircle.tga differ diff --git a/textures/SearchBarBorder.tga b/textures/SearchBarBorder.tga index d914ab0..a371346 100644 Binary files a/textures/SearchBarBorder.tga and b/textures/SearchBarBorder.tga differ diff --git a/textures/SearchBarFill.tga b/textures/SearchBarFill.tga index 1fa1a96..df16e12 100644 Binary files a/textures/SearchBarFill.tga and b/textures/SearchBarFill.tga differ diff --git a/textures/Spyglass.tga b/textures/Spyglass.tga new file mode 100644 index 0000000..8c55d8b Binary files /dev/null and b/textures/Spyglass.tga differ diff --git a/textures/SpyglassMinimap.tga b/textures/SpyglassMinimap.tga new file mode 100644 index 0000000..df1ada2 Binary files /dev/null and b/textures/SpyglassMinimap.tga differ diff --git a/textures/alt-key.tga b/textures/alt-key.tga new file mode 100644 index 0000000..459d210 Binary files /dev/null and b/textures/alt-key.tga differ diff --git a/textures/calculator-icon.tga b/textures/calculator-icon.tga new file mode 100644 index 0000000..10ba82e Binary files /dev/null and b/textures/calculator-icon.tga differ diff --git a/textures/chevron-glow.tga b/textures/chevron-glow.tga new file mode 100644 index 0000000..3b28ac7 Binary files /dev/null and b/textures/chevron-glow.tga differ diff --git a/textures/chevron.tga b/textures/chevron.tga new file mode 100644 index 0000000..a631a26 Binary files /dev/null and b/textures/chevron.tga differ diff --git a/textures/cursor-blank.tga b/textures/cursor-blank.tga new file mode 100644 index 0000000..3adc1f0 Binary files /dev/null and b/textures/cursor-blank.tga differ diff --git a/textures/eye.tga b/textures/eye.tga new file mode 100644 index 0000000..978f515 Binary files /dev/null and b/textures/eye.tga differ diff --git a/textures/glow-corner.tga b/textures/glow-corner.tga new file mode 100644 index 0000000..2d59357 Binary files /dev/null and b/textures/glow-corner.tga differ diff --git a/textures/glow-edge-h.tga b/textures/glow-edge-h.tga new file mode 100644 index 0000000..7d6a4d0 Binary files /dev/null and b/textures/glow-edge-h.tga differ diff --git a/textures/glow-edge-v.tga b/textures/glow-edge-v.tga new file mode 100644 index 0000000..a862c99 Binary files /dev/null and b/textures/glow-edge-v.tga differ diff --git a/textures/lightning-bolt.tga b/textures/lightning-bolt.tga deleted file mode 100644 index eb95745..0000000 Binary files a/textures/lightning-bolt.tga and /dev/null differ diff --git a/textures/text-cursor.tga b/textures/text-cursor.tga new file mode 100644 index 0000000..93b1ee1 Binary files /dev/null and b/textures/text-cursor.tga differ diff --git a/tutorial-calculator-visual.tga b/tutorial-calculator-visual.tga new file mode 100644 index 0000000..d6cf655 Binary files /dev/null and b/tutorial-calculator-visual.tga differ diff --git a/tutorial-map-search.tga b/tutorial-map-search.tga new file mode 100644 index 0000000..e678746 Binary files /dev/null and b/tutorial-map-search.tga differ diff --git a/tutorial-search-01.tga b/tutorial-search-01.tga new file mode 100644 index 0000000..b867cbd Binary files /dev/null and b/tutorial-search-01.tga differ diff --git a/tutorial-search-02.tga b/tutorial-search-02.tga new file mode 100644 index 0000000..892f52d Binary files /dev/null and b/tutorial-search-02.tga differ diff --git a/tutorial-search-03.tga b/tutorial-search-03.tga new file mode 100644 index 0000000..815a60a Binary files /dev/null and b/tutorial-search-03.tga differ diff --git a/tutorial-search-04.tga b/tutorial-search-04.tga new file mode 100644 index 0000000..9bb6fa1 Binary files /dev/null and b/tutorial-search-04.tga differ diff --git a/tutorial-search-05.tga b/tutorial-search-05.tga new file mode 100644 index 0000000..ce098a1 Binary files /dev/null and b/tutorial-search-05.tga differ diff --git a/tutorial-search-06.tga b/tutorial-search-06.tga new file mode 100644 index 0000000..515c5bb Binary files /dev/null and b/tutorial-search-06.tga differ diff --git a/tutorial-search-07.tga b/tutorial-search-07.tga new file mode 100644 index 0000000..99e86b5 Binary files /dev/null and b/tutorial-search-07.tga differ diff --git a/tutorial-search-08.tga b/tutorial-search-08.tga new file mode 100644 index 0000000..a4fd6f5 Binary files /dev/null and b/tutorial-search-08.tga differ diff --git a/tutorial-search-09.tga b/tutorial-search-09.tga new file mode 100644 index 0000000..251415c Binary files /dev/null and b/tutorial-search-09.tga differ diff --git a/tutorial-use-01.tga b/tutorial-use-01.tga new file mode 100644 index 0000000..2b04b42 Binary files /dev/null and b/tutorial-use-01.tga differ diff --git a/tutorial-use-02.tga b/tutorial-use-02.tga new file mode 100644 index 0000000..99e86b5 Binary files /dev/null and b/tutorial-use-02.tga differ diff --git a/tutorial-use-03.tga b/tutorial-use-03.tga new file mode 100644 index 0000000..918ee1b Binary files /dev/null and b/tutorial-use-03.tga differ