Files

1240 lines
44 KiB
Lua

QuestieOcto.Tooltips = QuestieOcto.Tooltips or {}
local T = QuestieOcto.Tooltips
-- World/target tooltip augmentation uses the already-resolved canonical node
-- graph instead of searching the database on every mouse movement. This is the
-- same separation used by pfQuest: map preparation establishes which quest
-- relationships exist, while GameTooltip hover only performs indexed lookups.
T.hoverIndex=T.hoverIndex or { unitByName={}, objectByID={}, itemByID={} }
T.hoverIndexReady=T.hoverIndexReady or false
T.hoverIndexPending=T.hoverIndexPending or false
T.worldTooltipState=T.worldTooltipState or { signature=nil, afterLines=0 }
T.initialized=T.initialized or false
local function Settings()
return QuestieOcto.MinimapSettings
end
-- Keep the stock Blizzard GameTooltip path for normal clients. Questie-Octo
-- historically appended its map/minimap information to that native tooltip,
-- which preserves the normal Vanilla frame sizing and appearance.
--
-- pfUI is the exception: it attaches child OnShow handlers to GameTooltip and
-- mutates/re-shows that frame as part of its tooltip lifecycle. For pfUI only,
-- use a private GameTooltipTemplate so pin hovers cannot recurse through pfUI.
-- That private tooltip is then skinned through pfUI's own backdrop helpers.
local function GetPfUI()
local ui=nil
if getglobal then ui=getglobal("pfUI") end
if not ui then ui=pfUI end
if type(ui)=="table" then return ui end
return nil
end
local function ApplyPfUISkin(tooltip)
if not tooltip or tooltip.questieOctoPfUISkinned then return end
local ui=GetPfUI()
if type(ui)~="table" or type(ui.api)~="table" then return end
if type(ui.api.CreateBackdrop)~="function" then return end
local config=nil
if getglobal then config=getglobal("pfUI_config") end
if not config then config=pfUI_config end
local alpha=nil
if type(config)=="table" and type(config.tooltip)=="table" then
alpha=tonumber(config.tooltip.alpha)
end
-- Match pfUI's own skins/blizzard/tooltips.lua path. pcall keeps pfUI an
-- optional compatibility integration: a pfUI fork/API mismatch must never
-- prevent Questie-Octo's tooltip from working with its default appearance.
local ok=pcall(ui.api.CreateBackdrop,tooltip,nil,nil,alpha)
if not ok then return end
if type(ui.api.CreateBackdropShadow)=="function" then
pcall(ui.api.CreateBackdropShadow,tooltip)
end
tooltip.questieOctoPfUISkinned=true
end
local function MapTooltip()
-- Non-pfUI users keep the exact native Blizzard tooltip frame. This also
-- means other ordinary tooltip skins/addons can style GameTooltip normally.
if not GetPfUI() then return GameTooltip end
if T.mapTooltip then
-- Re-check until successful so addon load order cannot leave an early-created
-- Questie-Octo tooltip permanently unskinned when pfUI becomes available.
ApplyPfUISkin(T.mapTooltip)
return T.mapTooltip
end
if CreateFrame then
local ok,tooltip=pcall(
CreateFrame,
"GameTooltip",
"QuestieOctoMapTooltip",
UIParent,
"GameTooltipTemplate"
)
if ok and tooltip then
if tooltip.SetClampedToScreen then tooltip:SetClampedToScreen(true) end
T.mapTooltip=tooltip
ApplyPfUISkin(tooltip)
return tooltip
end
end
-- Extremely defensive fallback for clients where GameTooltipTemplate cannot
-- be created. ClassicAPI/pfUI themselves create this template successfully.
return GameTooltip
end
function T:Hide(pin)
local tooltip=nil
if GetPfUI() then tooltip=self.mapTooltip end
if not tooltip then tooltip=GameTooltip end
if not tooltip then return end
if not pin or not tooltip.GetOwner or tooltip:GetOwner()==pin then
tooltip:Hide()
end
end
local function QuestTitle(q)
local title=tostring(q.title or ("Quest "..tostring(q.id or "")))
if Settings():Get("enableTooltipsQuestLevel") then
title="["..tostring(q.level or 0).."] "..title
end
if Settings():Get("enableTooltipsQuestID") then
title=title.." ("..tostring(q.id or 0)..")"
end
return title
end
local function FormatDropRate(rate)
rate=tonumber(rate)
if not rate then return nil end
if rate>=10 then return string.format("%.0f",rate) end
if rate>=2 then return string.format("%.1f",rate) end
if rate>=0.01 then return string.format("%.2f",rate) end
return string.format("%.3f",rate)
end
local function ItemStartAreaDropRateText(area)
local minimum=nil
local maximum=nil
for _,source in pairs((area and area.sourceList) or {}) do
local chance=tonumber(source.chance)
if chance and chance>0 then
if not minimum or chance<minimum then minimum=chance end
if not maximum or chance>maximum then maximum=chance end
end
end
if not minimum or not maximum then return nil end
local minText=FormatDropRate(minimum)
local maxText=FormatDropRate(maximum)
if not minText or not maxText then return nil end
if minText==maxText then return minText.."%" end
return minText.."%~"..maxText.."%"
end
local function DifficultyColor(level,questID)
-- Use the same authority as the native Quest Log. Octo/Turtle modifies the
-- low-level/easy band, so reproducing stock Classic thresholds makes map
-- tooltips disagree with the Quest Log (green there, gray here).
if QuestieOcto.GetNativeQuestDifficultyColor then
local r,g,b=QuestieOcto:GetNativeQuestDifficultyColor(level,questID)
if r then return r,g,b end
end
return 1,1,0
end
local function RoleText(role)
if role=="available" or role=="itemStart" then return "(Available)" end
if role=="turnin" then return "(Complete)" end
return "(Active)"
end
local function RareRankText(rank)
rank=tonumber(rank)
if rank==4 then return "Rare" end
if rank==2 then return "Rare Elite" end
return nil
end
local function RespawnText(seconds)
seconds=tonumber(seconds)
if not seconds or seconds<=0 then return nil end
seconds=math.floor(seconds)
if seconds<60 then return "~"..tostring(seconds).."s" end
local minutes=math.floor(seconds/60)
-- Roll long respawns up into hours so 18000s reads "~5h", not "~300 min".
if minutes>=60 then
local hours=math.floor(minutes/60)
local mins=math.mod(minutes,60)
if mins==0 then return "~"..tostring(hours).."h" end
return "~"..tostring(hours).."h "..tostring(mins).."m"
end
local secs=math.mod(seconds,60)
if secs==0 then return "~"..tostring(minutes).." min" end
return "~"..tostring(minutes).."m "..tostring(secs).."s"
end
local function SourceDisplayName(source)
local name=tostring(source.name or "Creature")
local rank=RareRankText(source.rank)
if rank then name=name.." ["..rank.."]" end
if Settings():Get("enableTooltipsNPCID") and source.id then
name=name.." (NPC "..tostring(source.id)..")"
end
return name
end
local function NormalizeFirstRowFont(tooltip)
-- WoW 1.12 gives a GameTooltipTemplate's first left row a larger title font.
-- Copy the normal second-row font onto line 1 for item-start source lists.
tooltip=tooltip or GameTooltip
local name=tooltip and tooltip.GetName and tooltip:GetName() or "GameTooltip"
local first=getglobal and getglobal(tostring(name).."TextLeft1") or nil
local normal=getglobal and getglobal(tostring(name).."TextLeft2") or nil
if first and normal and normal.GetFont and first.SetFont then
local font,size,flags=normal:GetFont()
if font and size then
first:SetFont(font,size,flags)
end
end
end
-- Map/minimap node geometry intentionally ignores numeric objective progress
-- (for example 3/10 -> 4/10) so a simple kill/loot counter does not rebuild
-- thousands of pins. Tooltips must therefore resolve the current quest-log row
-- at hover time instead of trusting the objective text snapshot stored on the
-- node when its geometry was last published.
local function LiveObjectiveText(node)
local questID=tonumber(node and node.questID)
local objectiveIndex=tonumber(node and node.objectiveIndex)
local state=questID and QuestieOcto.QuestLog and QuestieOcto.QuestLog.active and QuestieOcto.QuestLog.active[questID] or nil
if state and objectiveIndex then
for _,row in pairs(state.objectives or {}) do
if tonumber(row.index)==objectiveIndex then
local text=row.text or row.rawText or node.objectiveText or ""
-- When the row text is only the entity name (its live objective ID did
-- not resolve, so LocalizeObjectiveRows left off the counter) append the
-- x/y progress from the row so the tooltip still shows it.
local current=tonumber(row.current)
local required=tonumber(row.required)
if current and required and required>0 and not string.find(text,"%d+%s*/%s*%d+") then
if text~="" then text=text.." "..tostring(current).."/"..tostring(required)
else text=tostring(current).."/"..tostring(required) end
end
return text
end
end
end
return node and node.objectiveText or nil
end
local function Extra(node)
if node.role=="available" and node.conditionalOffer then
return tostring(node.conditionalOffer)
end
if node.role=="itemStart" and node.itemName then
local text
if node.vendor then text="Sells ["..tostring(node.itemName).."]"
else text="Drops ["..tostring(node.itemName).."]" end
if node.chance and Settings():Get("enableTooltipDroprates") then text=text.." ("..(FormatDropRate(node.chance) or tostring(node.chance)).."%)" end
return text.." - item starts quest"
end
if node.role=="objectiveCreature"
or node.role=="objectiveObject"
or node.role=="objectiveItemSource"
or node.role=="objectiveArea" then
local liveText=LiveObjectiveText(node)
if liveText and liveText~="" then
local text=liveText
if node.role=="objectiveItemSource" and node.chance and Settings():Get("enableTooltipDroprates") then
local rate=FormatDropRate(node.chance)
if rate then text=text.." |cff999999["..rate.."%]|r" end
end
return text
end
if node.role=="objectiveItemSource" and node.itemName then
local text="["..tostring(node.itemName).."]"
if node.vendor then text=text.." (Vendor)" end
if node.chance and Settings():Get("enableTooltipDroprates") then text=text.." ("..(FormatDropRate(node.chance) or tostring(node.chance)).."%)" end
return text
end
return nil
end
return nil
end
local function AddSourceIDLine(pin,tooltip)
tooltip=tooltip or MapTooltip()
if pin.sourceKind=="creature" and pin.sourceID and Settings():Get("enableTooltipsNPCID") then
tooltip:AddLine("NPC ID: "..tostring(pin.sourceID),.65,.65,.65)
end
end
local function SortedEntries(pin)
local rows={}
for _,entry in pairs(pin.entries or {}) do
rows[table.getn(rows)+1]=entry
end
table.sort(rows,function(a,b)
local qa=QuestieOcto.QuestModel:Get(a.node.questID)
local qb=QuestieOcto.QuestModel:Get(b.node.questID)
local la=qa and qa.level or 0
local lb=qb and qb.level or 0
if la==lb then
return tonumber(a.node.questID or 0)<tonumber(b.node.questID or 0)
end
return la<lb
end)
return rows
end
local function AddQuestEntries(pin,seen,tooltip)
tooltip=tooltip or MapTooltip()
local rows=SortedEntries(pin)
local _,entry
for _,entry in pairs(rows) do
local node=entry.node
local unique=tostring(node.questID)..":"..tostring(node.role)..":"..
tostring(node.sourceKind)..":"..tostring(node.sourceID)..":"..
tostring(node.itemID or 0)
if not seen[unique] then
seen[unique]=true
local q=QuestieOcto.QuestModel:Get(node.questID)
if q then
local r,g,b=DifficultyColor(q.level,q.id)
tooltip:AddDoubleLine(
QuestTitle(q),
RoleText(node.role),
r,g,b,1,.82,0
)
local extra=Extra(node)
if extra then tooltip:AddLine(" "..extra,.82,.82,.82,true) end
if node.itemID and Settings():Get("enableTooltipsItemID") then
tooltip:AddLine(" Item ID: "..tostring(node.itemID),.65,.65,.65)
end
end
end
end
end
local function RareSourceInfo(pin)
for _,entry in pairs(pin.entries or {}) do
local node=entry.node
if node and node.sourceKind=="creature" then
local rank=RareRankText(node.sourceRank)
if rank then return rank,node.respawnSeconds end
end
end
return nil,nil
end
local function QuestSourceTitle(pin)
local title=pin.displayName or "Quest source"
local rank=RareSourceInfo(pin)
if rank then title=title.." ["..rank.."]" end
return title
end
local function AddRareSourceRespawn(pin,tooltip)
tooltip=tooltip or MapTooltip()
local rank,respawnSeconds=RareSourceInfo(pin)
if not rank then return end
local respawn=RespawnText(respawnSeconds)
if respawn then tooltip:AddLine("Respawn: "..respawn,.75,.75,.75) end
end
local function TooltipRelationshipSignature(pin)
-- Full Nodes pins can merge several canonical nodes at one exact physical
-- coordinate. Build the semantic signature from the actual entries rather
-- than from the visual-owner source on the pin. Otherwise a mixed exact
-- coordinate can accidentally look equivalent to a nearby single-source pin
-- simply because the same creature happened to win visual priority.
local rows={}
local seen={}
for _,entry in pairs(pin.entries or {}) do
local node=entry.node
if node then
local source=tostring(node.sourceKind or "")..":"..tostring(node.sourceID or "")
if not node.sourceID then source=source..":"..tostring(node.sourceName or "") end
local row=source.."|"..
tostring(node.questID or 0)..":"..tostring(node.role or "")..":"..
tostring(node.itemID or 0)..":"..tostring(node.objectiveIndex or 0)..":"..
tostring(node.objectiveText or "")
if not seen[row] then
seen[row]=true
rows[table.getn(rows)+1]=row
end
end
end
table.sort(rows)
return table.concat(rows,",")
end
local function AddFullNodeQuestEntries(pins,tooltip)
tooltip=tooltip or MapTooltip()
-- Exact-coordinate deduplication can put several source records on one pin.
-- Those records may point at the same player-visible quest relationship.
-- Group by quest + displayed status, then print each unique objective/drop
-- line once. This preserves distinct objectives (for example two different
-- People's Militia kill targets) without repeating the quest header or an
-- identical Red Leather Bandana / item-start line for each internal source.
local groups={}
local order={}
for _,near in pairs(pins or {}) do
for _,entry in pairs(near.entries or {}) do
local node=entry.node
local q=node and QuestieOcto.QuestModel:Get(node.questID) or nil
if node and q then
local status=RoleText(node.role)
local key=tostring(node.questID or 0)..":"..tostring(status)
local group=groups[key]
if not group then
group={
quest=q,
status=status,
lines={},
seenLines={},
itemIDs={},
seenItemIDs={}
}
groups[key]=group
order[table.getn(order)+1]=group
end
local extra=Extra(node)
if extra and extra~="" and not group.seenLines[extra] then
group.seenLines[extra]=true
group.lines[table.getn(group.lines)+1]=extra
end
if node.itemID and Settings():Get("enableTooltipsItemID") then
local itemID=tonumber(node.itemID) or node.itemID
local itemKey=tostring(itemID)
if not group.seenItemIDs[itemKey] then
group.seenItemIDs[itemKey]=true
group.itemIDs[table.getn(group.itemIDs)+1]=itemID
end
end
end
end
end
table.sort(order,function(a,b)
local la=tonumber(a.quest.level) or 0
local lb=tonumber(b.quest.level) or 0
if la==lb then
local ia=tonumber(a.quest.id or 0) or 0
local ib=tonumber(b.quest.id or 0) or 0
if ia==ib then return tostring(a.status)<tostring(b.status) end
return ia<ib
end
return la<lb
end)
for _,group in pairs(order) do
local q=group.quest
local r,g,b=DifficultyColor(q.level,q.id)
tooltip:AddDoubleLine(QuestTitle(q),group.status,r,g,b,1,.82,0)
for _,line in pairs(group.lines) do
tooltip:AddLine(" "..tostring(line),.82,.82,.82,true)
end
for _,itemID in pairs(group.itemIDs) do
tooltip:AddLine(" Item ID: "..tostring(itemID),.65,.65,.65)
end
end
end
local function SemanticallyRelatedNearbyFullNodePins(pin,pins)
local result={}
local signature=TooltipRelationshipSignature(pin)
result[1]=pin
for _,near in pairs(pins or {}) do
if near~=pin and near.fullNode and TooltipRelationshipSignature(near)==signature then
result[table.getn(result)+1]=near
end
end
return result
end
local function ShowCombinedNearbyFullNodeTooltip(pin,pins)
local tooltip=MapTooltip()
tooltip:SetOwner(pin,"ANCHOR_CURSOR")
tooltip:ClearLines()
-- Full Nodes can place several coordinates of the same source within only a
-- few screen pixels. Collapse only semantically identical source/relationship
-- markers. Spatial proximity by itself must never concatenate unrelated mobs
-- or quest relationships into one giant tooltip. No map/node data is removed.
local title=QuestSourceTitle(pin)
if table.getn(pins)>1 then
title=title.." |cffaaaaaa("..tostring(table.getn(pins)).." nearby)|r"
end
tooltip:AddLine(title,.2,1,.35)
AddSourceIDLine(pin,tooltip)
AddRareSourceRespawn(pin,tooltip)
AddFullNodeQuestEntries(pins,tooltip)
NormalizeFirstRowFont(tooltip)
tooltip:Show()
end
local function ShowCombinedNearbyQuestTooltip(pin,pins)
local tooltip=MapTooltip()
tooltip:SetOwner(pin,"ANCHOR_CURSOR")
tooltip:ClearLines()
local seen={}
local first=true
local _,near
for _,near in pairs(pins) do
if near.itemStartArea then
-- Existing item-start area formatting stays self-contained. Nearby
-- ordinary quest markers are still included below it.
local area=near.itemStartArea
if not first then tooltip:AddLine(" ",1,1,1) end
first=false
tooltip:AddLine(tostring(area.displayName or "Item-start source"),.2,1,.35)
local q=QuestieOcto.QuestModel:Get(area.questID)
if q then
local key="itemstart:"..tostring(area.questID)
if not seen[key] then
seen[key]=true
local r,g,b=DifficultyColor(q.level,q.id)
tooltip:AddDoubleLine(QuestTitle(q),"(Available)",r,g,b,1,.82,0)
end
end
elseif near.entries and next(near.entries) then
if not first then tooltip:AddLine(" ",1,1,1) end
first=false
local title=QuestSourceTitle(near)
if near.clusterCount and near.clusterCount>1 then
title=title.." |cffaaaaaa("..tostring(near.clusterCount).." nearby spawns)|r"
end
tooltip:AddLine(title,.2,1,.35)
AddSourceIDLine(near,tooltip)
AddRareSourceRespawn(near,tooltip)
AddQuestEntries(near,seen,tooltip)
end
end
NormalizeFirstRowFont(tooltip)
tooltip:Show()
end
local function IsQuestHoverRole(role)
return role=="available" or role=="itemStart" or role=="turnin" or
role=="objectiveCreature" or role=="objectiveObject" or role=="objectiveItemSource"
end
local function AppendIndexed(index,key,node)
if key==nil then return end
index[key]=index[key] or {}
table.insert(index[key],node)
end
local function UnitNameKey(name)
name=tostring(name or "")
if name=="" then return nil end
return string.lower(name)
end
function T:RebuildHoverIndex()
local nextIndex={ unitByName={}, objectByID={}, itemByID={} }
for _,node in pairs((QuestieOcto.Nodes and QuestieOcto.Nodes.nodes) or {}) do
local questID=tonumber(node.questID)
if questID and questID>0 and IsQuestHoverRole(node.role) then
if node.sourceKind=="creature" and node.sourceName then
AppendIndexed(nextIndex.unitByName,UnitNameKey(node.sourceName),node)
elseif node.sourceKind=="gameObject" and tonumber(node.sourceID) then
AppendIndexed(nextIndex.objectByID,tonumber(node.sourceID),node)
end
end
end
-- Item hover should not depend on whether a map source survived source-rate
-- filtering. Index the active item objective itself, then let creature/object
-- hover use the source nodes above. This also avoids one item tooltip entry
-- per possible drop source.
for questID,resolved in pairs((QuestieOcto.Objectives and QuestieOcto.Objectives.byQuest) or {}) do
if QuestieOcto.QuestLog.active[questID] then
for _,item in pairs(resolved.item or {}) do
local itemID=tonumber(item.itemID)
if itemID then
AppendIndexed(nextIndex.itemByID,itemID,{
questID=questID,
role="objectiveItemSource",
itemID=itemID,
itemName=item.name,
objectiveIndex=item.objectiveIndex,
objectiveText=item.objectiveText,
current=item.current,
required=item.required,
objectiveComplete=item.complete and true or false,
})
end
end
end
end
-- Available item-start quests likewise belong on the starter item's own
-- tooltip even if the item came from a source outside our current map data.
for questID,resolved in pairs((QuestieOcto.ItemStarts and QuestieOcto.ItemStarts.byQuest) or {}) do
if QuestieOcto.AvailableQuests and QuestieOcto.AvailableQuests.available[questID] then
for _,item in pairs(resolved.items or {}) do
local itemID=tonumber(item.itemID)
if itemID then
AppendIndexed(nextIndex.itemByID,itemID,{
questID=questID,
role="itemStart",
itemID=itemID,
itemName=item.name,
})
end
end
end
end
self.hoverIndex=nextIndex
self.hoverIndexReady=true
self.hoverIndexPending=false
end
function T:ScheduleHoverIndex()
if self.hoverIndexPending then return end
self.hoverIndexPending=true
QuestieOcto.Scheduler:After(0.05,function()
T:RebuildHoverIndex()
end,"tooltip-hover-index")
end
local function HoverExtra(node,subjectKind)
if not node then return nil end
if node.role=="itemStart" then
if subjectKind=="item" then return "Item starts quest" end
local text
if node.vendor then text="Sells ["..tostring(node.itemName or "Quest item").."]"
else text="Drops ["..tostring(node.itemName or "Quest item").."]" end
if node.chance and Settings():Get("enableTooltipDroprates") then
local rate=FormatDropRate(node.chance)
if rate then text=text.." ("..rate.."%)" end
end
return text.." - item starts quest"
end
if node.role=="objectiveCreature" or node.role=="objectiveObject" or node.role=="objectiveItemSource" then
local text=LiveObjectiveText(node)
if not text or text=="" then
if node.itemName then text="["..tostring(node.itemName).."]" end
end
-- A source-specific drop rate is useful while hovering the creature/object
-- that drops the item, but it would be misleading on the item's own tooltip
-- because the item can have many sources with different rates.
if text and subjectKind~="item" and node.role=="objectiveItemSource" and node.chance and Settings():Get("enableTooltipDroprates") then
local rate=FormatDropRate(node.chance)
if rate then text=text.." |cff999999["..rate.."%]|r" end
end
return text
end
return nil
end
local function HoverStatusPriority(role)
if role=="turnin" then return 3 end
if role=="available" or role=="itemStart" then return 2 end
return 1
end
local function HoverStatusText(priority)
if priority==3 then return "(Complete)" end
if priority==2 then return "(Available)" end
return "(Active)"
end
local function BuildHoverGroups(nodes,subjectKind)
local groups={}
for _,node in pairs(nodes or {}) do
local questID=tonumber(node.questID)
local q=questID and QuestieOcto.QuestModel:Get(questID) or nil
if q then
local group=groups[questID]
if not group then
group={ quest=q, status=0, lines={}, seenLines={}, sourceIDs={} }
groups[questID]=group
end
local priority=HoverStatusPriority(node.role)
if priority>group.status then group.status=priority end
local extra=HoverExtra(node,subjectKind)
if extra and extra~="" and not group.seenLines[extra] then
group.seenLines[extra]=true
table.insert(group.lines,extra)
end
if subjectKind=="unit" and tonumber(node.sourceID) then
group.sourceIDs[tonumber(node.sourceID)]=true
end
end
end
local ordered={}
for _,group in pairs(groups) do table.insert(ordered,group) end
table.sort(ordered,function(a,b)
local la=tonumber(a.quest.level) or 0
local lb=tonumber(b.quest.level) or 0
if la==lb then return tonumber(a.quest.id or 0)<tonumber(b.quest.id or 0) end
return la<lb
end)
return ordered
end
local function UniqueUnitSourceID(nodes)
local ids={}
local count=0
local only=nil
for _,node in pairs(nodes or {}) do
local id=tonumber(node.sourceID)
if id and not ids[id] then
ids[id]=true
count=count+1
only=id
if count>1 then return nil end
end
end
return only
end
local function TooltipLineCount()
if GameTooltip and type(GameTooltip.NumLines)=="function" then
local count=tonumber(GameTooltip:NumLines())
if count then return count end
end
-- Vanilla fallback for replacement UIs that do not expose NumLines cleanly.
local count=0
if getglobal then
for i=1,128 do
local left=getglobal("GameTooltipTextLeft"..tostring(i))
local right=getglobal("GameTooltipTextRight"..tostring(i))
local leftText=left and left.GetText and left:GetText() or nil
local rightText=right and right.GetText and right:GetText() or nil
if leftText or rightText then count=i end
end
end
return count
end
local function WorldTooltipSignature(subjectKind,subjectID)
return tostring(subjectKind or "")..":"..tostring(subjectID or "")
end
local function ShouldAppendWorldTooltip(signature)
local state=T.worldTooltipState or {}
local lines=TooltipLineCount()
-- pfUI and other replacement interfaces may hide/show or restyle the same
-- GameTooltip several times for one hover. If our previously appended block
-- is still present, never append it again. If the tooltip was rebuilt/cleared,
-- its line count drops below our recorded end and it is safe to append anew.
if state.signature==signature
and tonumber(state.afterLines or 0)>0
and lines>=tonumber(state.afterLines or 0) then
return false
end
return true
end
local function MarkWorldTooltipAppended(signature)
T.worldTooltipState=T.worldTooltipState or {}
T.worldTooltipState.signature=signature
T.worldTooltipState.afterLines=TooltipLineCount()
end
-- pfUI owns the global GameTooltip and performs its normal size/layout pass
-- from its own OnShow lifecycle. Questie-Octo cannot safely call Show() again
-- after appending world-hover quest lines because that can re-enter pfUI and
-- duplicate tooltip content. Instead, when pfUI is present, resize the already
-- visible GameTooltip directly from its rendered FontStrings. pfUI's backdrop
-- is anchored to the tooltip edges, so it follows this size change immediately.
local function ResizePfUIWorldTooltip(beforeLines,beforeWidth,beforeHeight,beforeBottomPadding)
if not GetPfUI() or not GameTooltip then return end
local afterLines=TooltipLineCount()
if afterLines<=tonumber(beforeLines or 0) then return end
local currentWidth=tonumber(GameTooltip.GetWidth and GameTooltip:GetWidth()) or tonumber(beforeWidth) or 0
local desiredWidth=math.max(currentWidth,tonumber(beforeWidth) or 0)
local maxWidth=desiredWidth
-- Preserve pfUI's existing width unless the new Questie rows actually need
-- more room. AddDoubleLine rows need both left and right strings plus a gap;
-- wrapped objective rows report their rendered line width on this client.
if getglobal then
for i=1,afterLines do
local left=getglobal("GameTooltipTextLeft"..tostring(i))
local right=getglobal("GameTooltipTextRight"..tostring(i))
local leftText=left and left.GetText and left:GetText() or nil
local rightText=right and right.GetText and right:GetText() or nil
local leftWidth=0
local rightWidth=0
if leftText and left.GetStringWidth then leftWidth=tonumber(left:GetStringWidth()) or 0 end
if rightText and right.GetStringWidth then rightWidth=tonumber(right:GetStringWidth()) or 0 end
local rowWidth=leftWidth
if rightText and rightText~="" then rowWidth=leftWidth+rightWidth+18 end
if rowWidth+20>maxWidth then maxWidth=rowWidth+20 end
end
end
-- Keep world-hover tooltips readable without allowing a very long localized
-- quest string to turn them into a screen-wide panel. Objective text already
-- wraps, so 420px is a presentation ceiling rather than a data truncation.
desiredWidth=math.min(math.max(desiredWidth,maxWidth),420)
if GameTooltip.SetWidth and desiredWidth>currentWidth+0.5 then
GameTooltip:SetWidth(desiredWidth)
end
local tooltipTop=GameTooltip.GetTop and tonumber(GameTooltip:GetTop()) or nil
local lowestBottom=nil
if getglobal then
for i=1,afterLines do
local left=getglobal("GameTooltipTextLeft"..tostring(i))
local right=getglobal("GameTooltipTextRight"..tostring(i))
local leftText=left and left.GetText and left:GetText() or nil
local rightText=right and right.GetText and right:GetText() or nil
if leftText and left.GetBottom then
local bottom=tonumber(left:GetBottom())
if bottom and (not lowestBottom or bottom<lowestBottom) then lowestBottom=bottom end
end
if rightText and right.GetBottom then
local bottom=tonumber(right:GetBottom())
if bottom and (not lowestBottom or bottom<lowestBottom) then lowestBottom=bottom end
end
end
end
local currentHeight=tonumber(GameTooltip.GetHeight and GameTooltip:GetHeight()) or tonumber(beforeHeight) or 0
local desiredHeight=currentHeight
if tooltipTop and lowestBottom then
local padding=tonumber(beforeBottomPadding) or 8
if padding<4 then padding=4 elseif padding>24 then padding=24 end
desiredHeight=tooltipTop-lowestBottom+padding
else
-- Defensive Vanilla fallback if a replacement client does not expose
-- FontString screen coordinates. This still encloses every appended row.
local added=afterLines-tonumber(beforeLines or 0)
desiredHeight=math.max(currentHeight,(tonumber(beforeHeight) or currentHeight)+(added*14))
end
if GameTooltip.SetHeight and desiredHeight>currentHeight+0.5 then
GameTooltip:SetHeight(desiredHeight)
end
end
-- pfUI world/unit tooltips often start intentionally compact. Measure the
-- Questie quest header before appending it so the single-row pfUI compatibility
-- layout has enough room in normal cases; very long localized text can still
-- wrap naturally. Native clients continue to use Blizzard AddDoubleLine rows.
local function PfUIWorldTooltipTextWidth(text)
if not GetPfUI() or not UIParent then return 0 end
if not T.pfUIWorldMeasureText then
local measure=UIParent:CreateFontString(nil,"ARTWORK","GameTooltipText")
if not measure then return 0 end
measure:Hide()
T.pfUIWorldMeasureText=measure
end
local measure=T.pfUIWorldMeasureText
measure:SetText(tostring(text or ""))
if measure.GetStringWidth then return tonumber(measure:GetStringWidth()) or 0 end
return 0
end
local function PreSizePfUIWorldTooltip(groups)
if not GetPfUI() or not GameTooltip or not GameTooltip.SetWidth then return end
local currentWidth=tonumber(GameTooltip.GetWidth and GameTooltip:GetWidth()) or 0
local desiredWidth=currentWidth
for _,group in pairs(groups or {}) do
local q=group.quest
local leftWidth=PfUIWorldTooltipTextWidth(QuestTitle(q))
local rightWidth=PfUIWorldTooltipTextWidth(HoverStatusText(group.status))
local rowWidth=leftWidth+rightWidth+38
if rowWidth>desiredWidth then desiredWidth=rowWidth end
end
-- Keep the same presentation ceiling used by the post-append resize pass.
-- This establishes a comfortable width before pfUI's single-row quest header
-- is added; objective text remains wrapped and no quest information is lost.
if desiredWidth>420 then desiredWidth=420 end
if desiredWidth>currentWidth+0.5 then GameTooltip:SetWidth(desiredWidth) end
end
local function AddWorldHoverEntries(nodes,subjectKind,subjectID,signature)
local groups=BuildHoverGroups(nodes,subjectKind)
if table.getn(groups)==0 then return false end
local beforeLines=TooltipLineCount()
local beforeWidth=GameTooltip and GameTooltip.GetWidth and tonumber(GameTooltip:GetWidth()) or 0
local beforeHeight=GameTooltip and GameTooltip.GetHeight and tonumber(GameTooltip:GetHeight()) or 0
local beforeBottomPadding=nil
-- pfUI is widened before Questie rows are added so its single-row compatibility
-- header normally fits without wrapping; the post-pass still encloses all rows.
PreSizePfUIWorldTooltip(groups)
if GetPfUI() and GameTooltip and GameTooltip.GetBottom and getglobal then
local tooltipBottom=tonumber(GameTooltip:GetBottom())
local lowestBottom=nil
for i=1,beforeLines do
local left=getglobal("GameTooltipTextLeft"..tostring(i))
local right=getglobal("GameTooltipTextRight"..tostring(i))
local leftText=left and left.GetText and left:GetText() or nil
local rightText=right and right.GetText and right:GetText() or nil
if leftText and left.GetBottom then
local bottom=tonumber(left:GetBottom())
if bottom and (not lowestBottom or bottom<lowestBottom) then lowestBottom=bottom end
end
if rightText and right.GetBottom then
local bottom=tonumber(right:GetBottom())
if bottom and (not lowestBottom or bottom<lowestBottom) then lowestBottom=bottom end
end
end
if tooltipBottom and lowestBottom then beforeBottomPadding=lowestBottom-tooltipBottom end
end
GameTooltip:AddLine(" ",1,1,1)
if subjectKind=="unit" and Settings():Get("enableTooltipsNPCID") then
local npcID=UniqueUnitSourceID(nodes)
if npcID then GameTooltip:AddLine("NPC ID: "..tostring(npcID),.65,.65,.65) end
elseif subjectKind=="item" and Settings():Get("enableTooltipsItemID") and subjectID then
GameTooltip:AddLine("Item ID: "..tostring(subjectID),.65,.65,.65)
end
for _,group in pairs(groups) do
local q=group.quest
local r,g,b=DifficultyColor(q.level,q.id)
-- pfUI's compact unit tooltip can keep stale left/right anchors for
-- AddDoubleLine rows even when Questie-Octo pre-sizes the frame. The
-- result is the right-side status being drawn through the quest title.
-- Keep the native Blizzard two-column row for normal clients, but use one
-- wrapped row under pfUI so there are no competing column anchors. The
-- status retains the same gold presentation via an inline color escape.
if GetPfUI() then
GameTooltip:AddLine(
QuestTitle(q).." |cffffd100"..HoverStatusText(group.status).."|r",
r,g,b,true
)
else
GameTooltip:AddDoubleLine(QuestTitle(q),HoverStatusText(group.status),r,g,b,1,.82,0)
end
for _,line in pairs(group.lines) do
GameTooltip:AddLine(" "..tostring(line),.82,.82,.82,true)
end
end
MarkWorldTooltipAppended(signature)
-- Stock Vanilla recalculates the GameTooltip backdrop after Show(). The old
-- Questie-Octo 1.0.15 path deliberately re-showed the tooltip here so newly
-- appended quest lines were enclosed by the Blizzard tooltip background.
-- Keep that exact native behavior for normal clients. pfUI is resized
-- directly instead: re-showing its globally managed GameTooltip can re-enter
-- its OnShow lifecycle and was the source of the earlier duplicate blocks.
if GetPfUI() then
ResizePfUIWorldTooltip(beforeLines,beforeWidth,beforeHeight,beforeBottomPadding)
elseif GameTooltip and type(GameTooltip.Show)=="function" then
GameTooltip:Show()
end
return true
end
local function ItemIDFromTooltipLink(link)
if not link then return nil end
local _,_,id=string.find(link,"item:(%d+)")
return tonumber(id)
end
function T:AugmentWorldTooltip()
if not Settings():Get("enableTooltips") then return end
if not self.hoverIndexReady then return end
-- Map/minimap pins already render their own richer tooltip through T:Show().
-- Do not append the world-hover index to those same tooltips a second time.
local owner=nil
if GameTooltip and type(GameTooltip.GetOwner)=="function" then owner=GameTooltip:GetOwner() end
if owner and owner.questieOctoTooltipPin then return end
if GameTooltip and type(GameTooltip.GetUnitGUID)=="function" then
local unitName=GameTooltip:GetUnitGUID()
local key=UnitNameKey(unitName)
local nodes=key and self.hoverIndex.unitByName[key] or nil
local signature=key and WorldTooltipSignature("unit",key) or nil
if nodes and signature and ShouldAppendWorldTooltip(signature)
and AddWorldHoverEntries(nodes,"unit",nil,signature) then
return
end
end
if GameTooltip and type(GameTooltip.GetGameObject)=="function" then
local objectName,objectID=GameTooltip:GetGameObject()
objectID=tonumber(objectID)
local nodes=objectID and self.hoverIndex.objectByID[objectID] or nil
local signature=objectID and WorldTooltipSignature("object",objectID) or nil
if nodes and signature and ShouldAppendWorldTooltip(signature)
and AddWorldHoverEntries(nodes,"object",objectID,signature) then
return
end
end
if GameTooltip and type(GameTooltip.GetItem)=="function" then
local itemName,itemLink=GameTooltip:GetItem()
local itemID=ItemIDFromTooltipLink(itemLink)
local nodes=itemID and self.hoverIndex.itemByID[itemID] or nil
local signature=itemID and WorldTooltipSignature("item",itemID) or nil
if nodes and signature and ShouldAppendWorldTooltip(signature) then
AddWorldHoverEntries(nodes,"item",itemID,signature)
end
end
end
function T:Initialize()
if self.initialized then return end
self.initialized=true
-- pfQuest's ClassicAPI build uses a child frame's OnShow rather than modern
-- OnTooltipSetUnit/OnTooltipSetItem hooks, which are not reliable APIs on the
-- Vanilla 1.12 target. ClassicAPI supplies GetUnitGUID/GetGameObject.
if GameTooltip then
local watcher=CreateFrame("Frame","QuestieOctoWorldTooltipWatcher",GameTooltip)
watcher:SetScript("OnShow",function()
T:AugmentWorldTooltip()
end)
self.worldWatcher=watcher
end
if QuestieOcto.Nodes and QuestieOcto.Nodes.ready then self:RebuildHoverIndex() end
end
QuestieOcto:RegisterMessage("NODES_READY",T,"ScheduleHoverIndex")
QuestieOcto:RegisterMessage("NODES_CHANGED",T,"ScheduleHoverIndex")
function T:Show(pin)
if not pin then return end
pin.questieOctoTooltipPin=true
local tooltip=MapTooltip()
if not tooltip then return end
local permanentLabels={
flightMaster="Flight Master",
auctioneer="Auctioneer",
banker="Banker",
mailbox="Mailbox",
battlemaster="Battlemaster",
innkeeper="Innkeeper",
meetingStone="Meeting Stone",
repair="Repair",
spiritHealer="Spirit Healer",
stableMaster="Stable Master",
vendor="Vendor",
classTrainer="Class Trainer",
professionTrainer="Profession Trainer",
rareMob="[Rare]"
}
local permanentLabel=permanentLabels[pin.role]
-- Trainers refine the generic label with the specific class/profession, e.g.
-- "Warrior Trainer" / "Cooking Trainer".
if permanentLabel and (pin.role=="classTrainer" or pin.role=="professionTrainer") and type(pin.trainerKey)=="string" and pin.trainerKey~="" then
local key=pin.trainerKey
permanentLabel=string.upper(string.sub(key,1,1))..string.sub(key,2).." Trainer"
end
if permanentLabel then
tooltip:SetOwner(pin,"ANCHOR_CURSOR")
tooltip:ClearLines()
local title=pin.displayName or permanentLabel
if pin.role=="rareMob" then
tooltip:SetText(tostring(title),1,.82,0)
local level=nil
local respawnSeconds=nil
for _,entry in pairs(pin.entries or {}) do
if entry.node and entry.node.role=="rareMob" then
level=entry.node.rareLevel
respawnSeconds=entry.node.respawnSeconds
break
end
end
if level then
tooltip:AddLine("[Rare] - Level "..tostring(level),1,.82,0)
else
tooltip:AddLine("[Rare]",1,.82,0)
end
local respawn=RespawnText(respawnSeconds)
if respawn then
tooltip:AddLine("Respawn: "..respawn,.75,.75,.75)
end
else
tooltip:SetText(tostring(title),.2,1,.35)
tooltip:AddLine(permanentLabel,1,.82,0)
end
tooltip:Show()
return
end
if not Settings():Get("enableTooltips") then return end
if pin:GetParent()==WorldMapButton and
QuestieOcto.Map and QuestieOcto.Map.GetNearbyQuestTooltipPins then
local nearby=QuestieOcto.Map:GetNearbyQuestTooltipPins(pin,5)
if pin.fullNode then
local related=SemanticallyRelatedNearbyFullNodePins(pin,nearby)
if table.getn(related)>1 then
ShowCombinedNearbyFullNodeTooltip(pin,related)
return
end
elseif table.getn(nearby)>1 then
-- Clustered/item-start presentation keeps its established behavior.
ShowCombinedNearbyQuestTooltip(pin,nearby)
return
end
end
tooltip:SetOwner(pin,"ANCHOR_CURSOR")
tooltip:ClearLines()
if pin.itemStartArea then
local area=pin.itemStartArea
if area.zoneWideRare then
-- A zone-wide representative exists specifically to make enormous
-- world-drop source sets readable. Do not undo that simplification by
-- dumping every represented creature into the tooltip (Pendant of
-- Myzrael can have dozens of source types in a single zone). Keep the
-- full source list in the data and present only a compact aggregate.
local sourceCount=table.getn(area.sourceList or {})
local spawnCount=tonumber(area.n) or 0
tooltip:SetText("Rare item-start sources",.2,1,.35)
tooltip:AddLine(
tostring(sourceCount).." creature types, "..tostring(spawnCount).." zone spawns",
.65,.65,.65
)
else
-- Ordinary clustered item-start areas still list their nearby sources.
-- Use AddDoubleLine with an empty right column for every source row so
-- the first source does not inherit the tooltip's title-sized font.
for _,source in pairs(area.sourceList or {}) do
local sourceText=SourceDisplayName(source).." ("..tostring(source.count).." nearby spawns)"
tooltip:AddDoubleLine(
sourceText,
"",
.2,1,.35,
.2,1,.35
)
local rank=RareRankText(source.rank)
local respawn=rank and RespawnText(source.respawnSeconds) or nil
if respawn then
local rareTag=RareRankText(source.rank) or "Rare"
tooltip:AddLine("["..rareTag.."] Respawn: "..respawn,.75,.75,.75)
end
end
end
local q=QuestieOcto.QuestModel:Get(area.questID)
if q then
local r,g,b=DifficultyColor(q.level,q.id)
tooltip:AddDoubleLine(
QuestTitle(q),
"(Available)",
r,g,b,
1,.82,0
)
end
local dropRateText=ItemStartAreaDropRateText(area)
local itemLabel=tostring(area.itemName or ("Item "..tostring(area.itemID)))
if Settings():Get("enableTooltipsItemID") and area.itemID then
itemLabel=itemLabel.." (Item "..tostring(area.itemID)..")"
end
local line="Drops ["..itemLabel.."]"
if dropRateText and Settings():Get("enableTooltipDroprates") then
line=line.." ("..dropRateText..")"
end
tooltip:AddLine(line.." - item starts quest",.82,.82,.82,true)
if not area.zoneWideRare then NormalizeFirstRowFont(tooltip) end
tooltip:Show()
return
end
if not pin.entries then return end
local title=QuestSourceTitle(pin)
if pin.clusterCount and pin.clusterCount>1 then
title=title.." |cffaaaaaa("..tostring(pin.clusterCount).." nearby spawns)|r"
end
tooltip:SetText(title,.2,1,.35)
AddSourceIDLine(pin,tooltip)
AddRareSourceRespawn(pin,tooltip)
if pin.fullNode then
AddFullNodeQuestEntries({pin},tooltip)
else
AddQuestEntries(pin,{},tooltip)
end
tooltip:Show()
end