, 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 walks you through each step
-
-
-
-
-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
-
-
-
-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.
-
-
-
-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
-
-
-
-Search "rare" to see all active rares, or select a specific one to track it
-
+Search across:
-
-
-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 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 search finding a dungeon entrance
-
+### Map Search Tab
-
-
-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 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 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 (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 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 (left) and Fast mode (right) toggle button states
-
-
-
-
-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 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
-
+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 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 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