Files
Questie-Octo/Tracker/TrackerFrame.lua
T
2026-08-19 21:21:31 -05:00

1152 lines
39 KiB
Lua

-- Questie-Octo visual quest/objective tracker.
--
-- Backport lineage:
-- Questie 5.2.3/6.0.0: simple tracker presentation and quest title interactions.
-- Questie 3.3.5: TrackerBaseFrame/Header/QuestFrame separation, movable/resizable
-- shell, zone grouping and saved geometry. This is the primary implementation bridge.
-- Questie 7/8: renderer consumes cached quest state and rebuilds presentation only.
--
-- Vanilla/Turtle adaptation: native 1.12 frames/scrollframe only; no modern mixins.
QuestieOcto.TrackerFrame = QuestieOcto.TrackerFrame or {}
local T=QuestieOcto.TrackerFrame
T.started=false
T.frame=nil
T.header=nil
T.scroll=nil
T.content=nil
T.rows={}
T.rowCount=0
T.topRow=1
T.timerRows={}
T.timerElapsed=0
T.defaultWidth=280
T.defaultHeight=420
T.defaultVisibleRows=18
T.minVisibleRows=6
T.maxVisibleRows=60
T.frameVerticalChrome=38
T.minWidth=100
T.minHeight=100
T.maxWidth=2000
T.maxHeight=2000
T.padding=8
T.autoFitMinWidth=100
T.autoFitMaxWidth=280
local function Settings()
return QuestieOcto.MinimapSettings
end
local function CharacterDB()
QuestieOctoDB=QuestieOctoDB or {}
QuestieOctoDB.tracker=QuestieOctoDB.tracker or {}
local db=QuestieOctoDB.tracker
db.window=db.window or {}
if db.expanded==nil then db.expanded=true end
return db
end
local function Clamp(v,minv,maxv)
v=tonumber(v) or minv
if v<minv then return minv end
if v>maxv then return maxv end
return v
end
local function CollapsedZones()
local db=CharacterDB()
db.collapsedZones=db.collapsedZones or {}
return db.collapsedZones
end
function T:IsZoneCollapsed(zone)
return CollapsedZones()[tostring(zone)] and true or false
end
function T:ToggleZone(zone)
local z=CollapsedZones()
local key=tostring(zone)
z[key]=(not z[key]) or nil
self:Render()
end
local function GetScreenBounds()
local width=(UIParent and UIParent.GetWidth and UIParent:GetWidth()) or 1024
local height=(UIParent and UIParent.GetHeight and UIParent:GetHeight()) or 768
return math.max(T.minWidth,width-20),math.max(T.minHeight,height-20)
end
function T:AnchorTopRightAtCurrentPosition()
if not self.frame or not UIParent then return end
local right=self.frame.GetRight and self.frame:GetRight()
local top=self.frame.GetTop and self.frame:GetTop()
local parentRight=UIParent.GetRight and UIParent:GetRight()
local parentTop=UIParent.GetTop and UIParent:GetTop()
if not right or not top or not parentRight or not parentTop then return end
local x=right-parentRight
local y=top-parentTop
self.frame:ClearAllPoints()
self.frame:SetPoint("TOPRIGHT",UIParent,"TOPRIGHT",x,y)
end
function T:SaveGeometry()
if not self.frame then return end
-- The tracker is intentionally normalized to TOPRIGHT after user movement.
-- This keeps its right/top edge fixed when auto-fit changes width later.
self:AnchorTopRightAtCurrentPosition()
local db=CharacterDB()
local point,relativeTo,relativePoint,x,y=self.frame:GetPoint(1)
db.window.point=point or "TOPRIGHT"
db.window.relativePoint=relativePoint or point or "TOPRIGHT"
db.window.x=tonumber(x) or -30
db.window.y=tonumber(y) or -180
-- Width and height are content/settings-driven; placement stores only anchor offsets.
end
function T:RestoreGeometry()
if not self.frame then return end
local db=CharacterDB()
local window=db.window
self.frame:SetWidth(self.defaultWidth)
self.frame:SetHeight(self.defaultHeight)
self.frame:ClearAllPoints()
-- New tracker placement is always TOPRIGHT so content-driven width changes
-- expand to the left and never move the player's chosen right/top edge.
-- Existing TOPRIGHT saves carry over directly; other legacy anchors fall
-- back to the normal Questie placement once and are then saved canonically.
local x=tonumber(window.x)
local y=tonumber(window.y)
if window.point~="TOPRIGHT" or window.relativePoint~="TOPRIGHT" then
x=-30
y=-180
end
if x==nil then x=-30 end
if y==nil then y=-180 end
self.frame:SetPoint("TOPRIGHT",UIParent,"TOPRIGHT",x,y)
end
local function SetFont(fs,size,r,g,b)
if not fs then return end
local font=STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"
local flags=""
if Settings():Get("trackerThickOutlineText") then
flags="THICKOUTLINE"
elseif Settings():Get("trackerOutlineText") then
flags="OUTLINE"
end
fs:SetFont(font,size or 10,flags)
if r then fs:SetTextColor(r,g,b) end
end
function T:AutoFitWidth()
if not self.frame then return self.defaultWidth,self.defaultWidth-16 end
-- pfQuest's proven Vanilla technique: measure the actual FontStrings that
-- are already being rendered at their natural one-line width. Questie-Octo
-- then applies the player-selected practical tracker cap (280px by default).
-- Long quest/objective text is wrapped during the second layout pass instead
-- of being allowed to stretch the entire tracker across the screen.
local longest=0
for i=1,self.rowCount do
local row=self.rows[i]
if row and row:IsShown() and row.text and row.text.GetStringWidth then
local indent=tonumber(row.textIndent) or 0
local width=tonumber(row.text:GetStringWidth()) or 0
width=width+indent
if width>longest then longest=width end
end
end
local screenW=GetScreenBounds()
-- Scroll area begins 8px inside the frame. Add another 8px of breathing
-- room after the longest rendered line. The fixed TOPRIGHT anchor means
-- this width change grows/shrinks leftward without moving the right edge.
local frameWidth=longest+16
local configuredMax=tonumber(Settings():Get("trackerMaxWidth")) or self.autoFitMaxWidth or 280
configuredMax=Clamp(configuredMax,200,500)
local maxWidth=math.min(configuredMax,screenW)
frameWidth=Clamp(frameWidth,self.autoFitMinWidth,maxWidth)
local scrollWidth=math.max(1,frameWidth-16)
local contentWidth=scrollWidth
self.frame:SetWidth(frameWidth)
if self.scroll and self.scroll.SetWidth then self.scroll:SetWidth(scrollWidth) end
if self.content and self.content.SetWidth then self.content:SetWidth(contentWidth) end
return frameWidth,contentWidth
end
function T:GetVisibleRowsSetting()
local value=tonumber(Settings():Get("trackerVisibleRows")) or self.defaultVisibleRows
return math.floor(Clamp(value,self.minVisibleRows,self.maxVisibleRows)+0.5)
end
function T:ApplyVisibleRowsHeight()
if not self.frame or not self.scroll then return end
local wanted=self:GetVisibleRowsSetting()
local available=math.min(wanted,self.rowCount)
if available<1 then available=1 end
-- Use the tallest actual consecutive page of up to the requested row count.
-- The frame therefore has one stable, safe pixel height for every scroll page
-- without exposing raw pixel resizing to the player.
local maxPageHeight=0
if self.rowCount>0 then
for first=1,self.rowCount do
local pageHeight=0
local last=math.min(self.rowCount,first+available-1)
for i=first,last do
local row=self.rows[i]
local h=(row and row.GetHeight and row:GetHeight()) or 1
if h<1 then h=1 end
pageHeight=pageHeight+h
end
if pageHeight>maxPageHeight then maxPageHeight=pageHeight end
end
end
if maxPageHeight<1 then
local size=tonumber(Settings():Get("trackerFontSize")) or 10
maxPageHeight=(size+5)*available
end
local _,maxH=GetScreenBounds()
-- The tracker grows downward from a fixed top anchor, so the real ceiling is
-- the space from its top edge to the bottom of the screen. Using the whole
-- screen height let the frame run off the bottom, which made high Visible Rows
-- values render rows below the screen where they could never be seen.
if self.frame.GetTop and UIParent and UIParent.GetBottom then
local top=self.frame:GetTop()
local bottom=UIParent:GetBottom()
if top and bottom then
local available=top-bottom-6
if available>=self.minHeight then maxH=math.min(maxH,available) end
end
end
local frameHeight=Clamp(maxPageHeight+self.frameVerticalChrome,self.minHeight,math.min(self.maxHeight,maxH))
self.frame:SetHeight(frameHeight)
-- Vanilla does not always resolve a TOP/BOTTOM anchored child to its new
-- height synchronously after the parent changes size. Whole-row paging must
-- use the new viewport during this same render pass, so make it explicit.
local scrollHeight=math.max(1,frameHeight-self.frameVerticalChrome)
if self.scroll.SetHeight then self.scroll:SetHeight(scrollHeight) end
end
function T:GetVisibleRowRange()
if self.rowCount<=0 then return 1,0,0 end
local first=tonumber(self.topRow) or 1
if first<1 then first=1 end
if first>self.rowCount then first=self.rowCount end
local viewHeight=(self.scroll and self.scroll.GetHeight and self.scroll:GetHeight()) or 0
local maxRows=self:GetVisibleRowsSetting()
local used=0
local count=0
local last=first-1
for i=first,self.rowCount do
if count>=maxRows then break end
local row=self.rows[i]
local h=(row and row.GetHeight and row:GetHeight()) or 0
if h<=0 then h=1 end
-- 1px tolerance: the viewport height is set to the exact summed row heights,
-- but SetHeight->GetHeight can round down a fraction, which would otherwise
-- clip the final row that actually fits (worst on the last zone after a
-- collapse changes the row set).
if used+h>viewHeight+1 then break end
used=used+h
count=count+1
last=i
end
return first,last,used
end
function T:ClampTopRow()
if self.rowCount<=0 then
self.topRow=1
return
end
local top=tonumber(self.topRow) or 1
if top<1 then top=1 end
if top>self.rowCount then top=self.rowCount end
self.topRow=top
-- On the final page, walk upward while the entire remainder still fits.
-- This prevents an unnecessary blank area at the bottom while preserving
-- the rule that every visible tracker row is shown completely.
local first,last=self:GetVisibleRowRange()
if last==self.rowCount then
while self.topRow>1 do
local old=self.topRow
self.topRow=old-1
local prevFirst,prevLast=self:GetVisibleRowRange()
if prevLast~=self.rowCount then
self.topRow=old
break
end
end
end
end
function T:LayoutVisibleRows()
if not self.scroll or not self.content then return end
self:ClampTopRow()
local first,last=self:GetVisibleRowRange()
local y=0
local contentWidth=math.max(1,self.scroll:GetWidth())
for i=1,self.rowCount do
local row=self.rows[i]
if i>=first and i<=last then
row:Show()
row:SetWidth(contentWidth)
row:ClearAllPoints()
row:SetPoint("TOPLEFT",self.content,"TOPLEFT",0,-y)
y=y+row:GetHeight()
else
row:Hide()
end
end
self.content:SetWidth(contentWidth)
self.content:SetHeight(math.max(1,y))
if self.scroll.SetVerticalScroll then self.scroll:SetVerticalScroll(0) end
end
function T:ScrollBy(delta)
if not self.scroll or not self.scroll:IsShown() then return end
local wheel=tonumber(delta) or 0
if wheel==0 or self.rowCount<=0 then return end
if wheel<0 then
self.topRow=(tonumber(self.topRow) or 1)+1
else
self.topRow=(tonumber(self.topRow) or 1)-1
end
self:ClampTopRow()
self:LayoutVisibleRows()
end
-- Put a clickable quest hyperlink into the chat edit box so the player can send
-- it, falling back to a direct SAY message when no chat box is available.
local function LinkQuestToChat(questID)
questID=tonumber(questID)
if not questID then return end
local q=QuestieOcto.QuestModel and QuestieOcto.QuestModel:Get(questID) or nil
local state=QuestieOcto.QuestLog and QuestieOcto.QuestLog.active and QuestieOcto.QuestLog.active[questID] or nil
local name=(q and q.title) or (state and state.title) or ("Quest "..tostring(questID))
local level=(q and tonumber(q.level)) or (state and tonumber(state.level)) or 0
local link="|cffffff00|Hquest:"..tostring(questID)..":"..tostring(level).."|h["..tostring(name).."]|h|r"
local editBox=getglobal and (getglobal("ChatFrame1EditBox") or getglobal("ChatFrameEditBox")) or nil
-- If the player already has text typed, keep it and append the link
if editBox and editBox:IsVisible() and editBox.GetText and editBox.SetText then
local current=editBox:GetText() or ""
if current~="" then
editBox:SetText(current..link)
else
editBox:SetText(link)
end
if editBox.SetFocus then editBox:SetFocus() end
return
end
if ChatFrame_OpenChat then
ChatFrame_OpenChat(link)
elseif SendChatMessage then
SendChatMessage(link,"SAY")
end
end
local function EnsureRow(index,parent)
local row=T.rows[index]
if row then
row:Show()
return row
end
row=CreateFrame("Button",nil,parent)
row:SetHeight(14)
row:EnableMouse(true)
if row.RegisterForClicks then row:RegisterForClicks("LeftButtonUp","RightButtonUp") end
local text=row:CreateFontString(nil,"ARTWORK","GameFontNormal")
text:SetPoint("TOPLEFT",row,"TOPLEFT",0,0)
text:SetJustifyH("LEFT")
text:SetJustifyV("TOP")
row.text=text
-- Native quest-log style +/- button, shown only on zone header rows.
local collapse=CreateFrame("Button",nil,row)
collapse:SetWidth(14)
collapse:SetHeight(14)
collapse:SetPoint("LEFT",row,"LEFT",0,0)
local nt=collapse:CreateTexture(nil,"BACKGROUND"); nt:SetAllPoints(collapse); collapse:SetNormalTexture(nt)
local pt=collapse:CreateTexture(nil,"BACKGROUND"); pt:SetAllPoints(collapse); collapse:SetPushedTexture(pt)
local ht=collapse:CreateTexture(nil,"HIGHLIGHT"); ht:SetTexture("Interface\\Buttons\\UI-PlusButton-Hilight"); ht:SetAllPoints(collapse); collapse:SetHighlightTexture(ht)
collapse:SetScript("OnClick",function() if this.zoneName then T:ToggleZone(this.zoneName) end end)
collapse:Hide()
row.collapse=collapse
row:SetScript("OnEnter",function()
if this.questID then
if this.kind=="quest" and QuestieOcto.MapHighlight then QuestieOcto.MapHighlight:Set(this.questID) end
GameTooltip:SetOwner(this,"ANCHOR_LEFT")
GameTooltip:SetText(this.questTitle or "Quest",1,0.82,0)
GameTooltip:AddLine("Click to open this quest in the Quest Log.",1,1,1)
GameTooltip:AddLine("Ctrl + Click to stop tracking it.",0.7,0.7,0.7)
GameTooltip:AddLine("Shift + Click to link it to chat.",0.7,0.7,0.7)
GameTooltip:Show()
end
end)
row:SetScript("OnLeave",function()
if QuestieOcto.MapHighlight then QuestieOcto.MapHighlight:Clear() end
if GameTooltip then GameTooltip:Hide() end
end)
row:SetScript("OnClick",function()
if this.kind=="zone" then
if this.zoneName then T:ToggleZone(this.zoneName) end
return
end
if not this.questID then return end
if arg1=="RightButton" then
if this.kind=="quest" and QuestieOcto.QuestMenu then QuestieOcto.QuestMenu:Open(this.questID,this) end
return
end
if IsControlKeyDown and IsControlKeyDown() then
if QuestieOcto.TrackerDriver then QuestieOcto.TrackerDriver:Toggle(this.questID) end
return
end
if IsShiftKeyDown and IsShiftKeyDown() then
LinkQuestToChat(this.questID)
return
end
T:OpenQuest(this.questID)
end)
if row.EnableMouseWheel then row:EnableMouseWheel(true) end
row:SetScript("OnMouseWheel",function() T:ScrollBy(arg1) end)
T.rows[index]=row
return row
end
function T:ClearRows()
for i=1,table.getn(self.rows) do
local row=self.rows[i]
row:Hide()
row.questID=nil
row.questTitle=nil
row.kind=nil
row.displayText=nil
row.timerQuestID=nil
row.timerLogIndex=nil
row.failed=nil
row.textIndent=nil
row.zoneName=nil
if row.collapse then row.collapse:Hide(); row.collapse.zoneName=nil end
end
self.rowCount=0
self.topRow=1
self.timerRows={}
end
local function WrapTextToWidth(fs,text,maxWidth)
text=tostring(text or "")
maxWidth=tonumber(maxWidth) or 0
if text=="" or maxWidth<=0 or not fs or not fs.SetText or not fs.GetStringWidth then
return text,1
end
-- Do not rely on Vanilla/Turtle FontString auto-wrapping. Some client/UI
-- combinations keep drawing a constrained FontString at its natural one-line
-- width. Build the line breaks explicitly from the exact rendered font width
-- so long event/exploration objectives are deterministic on every client.
local lines={}
local line=""
local pos=1
while true do
local s,e=string.find(text,"%S+",pos)
if not s then break end
local word=string.sub(text,s,e)
local candidate=(line=="") and word or (line.." "..word)
fs:SetText(candidate)
local candidateWidth=tonumber(fs:GetStringWidth()) or 0
if line~="" and candidateWidth>maxWidth then
table.insert(lines,line)
line=word
else
line=candidate
end
pos=e+1
end
if line~="" then table.insert(lines,line) end
local count=table.getn(lines)
if count<1 then return text,1 end
return table.concat(lines,"\n"),count
end
local function ZoneSpacerHeight(fontSize)
-- Keep zone groups visually separated without consuming a full text row.
-- The spacer scales with the configured tracker font and adds a small amount
-- for OUTLINE/THICKOUTLINE so heavier glyph edges do not feel crowded.
local height=(tonumber(fontSize) or 10)-4
if height<4 then height=4 end
if height>13 then height=13 end
if Settings():Get("trackerThickOutlineText") then
height=height+2
elseif Settings():Get("trackerOutlineText") then
height=height+1
end
if height>15 then height=15 end
return height
end
local function ApplyRowStyle(row, contentWidth, constrainWidth)
local size=tonumber(Settings():Get("trackerFontSize")) or 10
local left=0
local r,g,b=1,1,1
local kind=row.kind
if kind=="zone" then
size=size+1
r,g,b=0.88,0.69,0.24
left=16
elseif kind=="quest" then
-- Match the classic Questie/Quest Log presentation: completion is shown
-- by appending "(Complete)" to the title, not by replacing difficulty color.
if row.failed then
r,g,b=1,0.35,0.35
elseif QuestieOcto.GetNativeQuestDifficultyColor then
local nr,ng,nb=QuestieOcto:GetNativeQuestDifficultyColor(row.questLevel,row.questID)
if nr then r,g,b=nr,ng,nb else r,g,b=1,0.82,0 end
else
r,g,b=1,0.82,0
end
left=6
elseif kind=="objective" then
r,g,b=row.complete and 0.35 or 0.82, row.complete and 0.80 or 0.82, row.complete and 0.35 or 0.82
left=18
elseif kind=="timer" then
r,g,b=0.35,0.75,1
left=18
end
if kind=="zone" and row.collapse then
row.collapse.zoneName=row.zoneName
local collapsed=T:IsZoneCollapsed(row.zoneName)
local nt=row.collapse.GetNormalTexture and row.collapse:GetNormalTexture()
local pt=row.collapse.GetPushedTexture and row.collapse:GetPushedTexture()
if nt then nt:SetTexture(collapsed and "Interface\\Buttons\\UI-PlusButton-Up" or "Interface\\Buttons\\UI-MinusButton-Up") end
if pt then pt:SetTexture(collapsed and "Interface\\Buttons\\UI-PlusButton-Down" or "Interface\\Buttons\\UI-MinusButton-Down") end
row.collapse:ClearAllPoints()
row.collapse:SetPoint("LEFT",row,"LEFT",0,0)
row.collapse:Show()
elseif row.collapse then
row.collapse:Hide()
end
row:SetWidth(math.max(1,contentWidth or 1))
row.text:ClearAllPoints()
row.text:SetPoint("TOPLEFT",row,"TOPLEFT",left,0)
-- Measure before adding a right-side constraint. GetStringWidth() must see
-- each candidate at its natural width so explicit wrapping is deterministic.
SetFont(row.text,size,r,g,b)
if kind=="zone" and row.text.SetFont then
local font=STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"
local flags=""
if Settings():Get("trackerThickOutlineText") then
flags="THICKOUTLINE"
elseif Settings():Get("trackerOutlineText") then
flags="OUTLINE"
end
row.text:SetFont(font,size,flags)
row.text:SetTextColor(r,g,b)
end
local displayText=row.displayText or ""
local lineCount=1
if constrainWidth and kind=="objective" then
local available=math.max(1,(tonumber(contentWidth) or 1)-left)
displayText,lineCount=WrapTextToWidth(row.text,displayText,available)
end
-- Preserve the normal right boundary too, but explicit newlines above are
-- what guarantee the visual wrap on old clients where anchors alone do not.
if constrainWidth then
row.text:SetPoint("TOPRIGHT",row,"TOPRIGHT",0,0)
end
row.text:SetText(displayText)
row.textIndent=left
local baseHeight=size+4
local height
if kind=="spacer" then
height=ZoneSpacerHeight(size)
else
height=baseHeight*math.max(1,lineCount)
end
row:SetHeight(height)
end
function T:AddRow(text,kind,questID,complete,questLevel,failed)
self.rowCount=self.rowCount+1
local row=EnsureRow(self.rowCount,self.content)
row.questID=questID
row.questTitle=(kind=="quest") and text or nil
row.kind=kind
row.complete=complete and true or false
row.failed=failed and true or false
row.questLevel=questLevel
row.displayText=text or ""
row:EnableMouse(questID and true or false)
-- First render pass: create the exact visible text at natural width. The
-- subsequent AutoFitWidth() measures this same FontString via GetStringWidth().
ApplyRowStyle(row, math.max(1,(self.scroll and self.scroll:GetWidth()) or self.defaultWidth))
return row
end
local function FormatTimer(seconds)
seconds=tonumber(seconds)
if not seconds then return nil end
if seconds<0 then seconds=0 end
if SecondsToTime then
local ok,value=pcall(SecondsToTime,seconds)
if ok and value and value~="" then return value end
end
local total=math.floor(seconds+0.5)
local minutes=math.floor(total/60)
local secs=math.mod(total,60)
if minutes>=60 then
local hours=math.floor(minutes/60)
minutes=math.mod(minutes,60)
return tostring(hours).."h "..tostring(minutes).."m"
end
if minutes>0 then return tostring(minutes).."m "..tostring(secs).."s" end
return tostring(secs).."s"
end
function T:GetQuestTimerSeconds(quest)
if not quest then return nil end
local index=tonumber(quest.logIndex)
if not index or type(GetQuestLogTimeLeft)~="function" then return nil end
-- Questie 3.3.5 compatibility path: GetQuestLogTimeLeft is tied to the
-- selected quest on old clients. Temporarily select this quest, read the
-- timer, then restore the player's previous selection immediately.
if type(GetQuestLogSelection)=="function" and type(SelectQuestLogEntry)=="function" then
local selected=GetQuestLogSelection()
SelectQuestLogEntry(index)
local ok,value=pcall(GetQuestLogTimeLeft,index)
if selected and selected>0 then SelectQuestLogEntry(selected) end
if ok and tonumber(value) then return tonumber(value) end
return nil
end
-- Compatibility fallback for ClassicAPI implementations that accept the
-- quest-log index directly.
local ok,value=pcall(GetQuestLogTimeLeft,index)
if ok and tonumber(value) then return tonumber(value) end
return nil
end
function T:AddTimerRow(quest)
local seconds=self:GetQuestTimerSeconds(quest)
if not seconds then return end
local text=FormatTimer(seconds)
if not text then return end
local row=self:AddRow(text,"timer",nil,false)
row.timerQuestID=quest.id
row.timerLogIndex=quest.logIndex
self.timerRows[quest.id]=row
end
function T:UpdateTimerRows()
if not self.frame or not self.frame:IsShown() or not CharacterDB().expanded then return end
local expired=false
for questID,row in pairs(self.timerRows or {}) do
if row then
local seconds=nil
local quest=QuestieOcto.TrackerDriver and QuestieOcto.TrackerDriver.tracked and QuestieOcto.TrackerDriver.tracked[questID]
if quest then seconds=self:GetQuestTimerSeconds(quest) end
if seconds then
local value=FormatTimer(seconds)
if value and row.displayText~=value then
row.displayText=value
row.text:SetText(value)
end
else
expired=true
end
end
end
if expired then self:Render() end
end
local function ObjectiveDisplayText(objective)
-- Turtle can return leaderboard text without its entity name prefer the DB-localized text
local raw=objective and objective.rawText or nil
local loc=objective and objective.text or nil
local text
if raw and string.find(raw,"%a") then
text=raw
elseif loc and string.find(loc,"%a") then
text=loc
else
text=raw or loc or ""
end
if not text then text="" end
-- Only synthesize amounts when the compatibility API supplied numeric progress
local current=objective and tonumber(objective.current)
local required=objective and tonumber(objective.required)
if current and required and required>0 and not string.find(text,"%d+%s*/%s*%d+") then
if text~="" then
text=tostring(current).."/"..tostring(required).." "..text
else
text=tostring(current).."/"..tostring(required)
end
end
if text~="" then
text="- "..text
end
return text
end
function T:Render()
if not self.frame or not self.content then return end
if not Settings():Get("trackerEnabled") or (Settings():Get("trackerHideInCombat") and self.inCombat) then
self.frame:Hide()
return
end
self.frame:Show()
self:ApplyLockState()
local db=CharacterDB()
local expanded=db.expanded and true or false
self.minimize:SetText(expanded and "-" or "+")
self.headerText:SetText("")
if not expanded then
self.scroll:Hide()
if self.resizeGrip then self.resizeGrip:Hide() end
self.frame:SetHeight(30)
return
end
self.scroll:Show()
self:ClearRows()
local ordered=(QuestieOcto.TrackerDriver and QuestieOcto.TrackerDriver.ordered) or {}
local sortMode=Settings():Get("trackerSort")
local lastZone=nil
local collapsedZone=false
for i=1,table.getn(ordered) do
local quest=ordered[i]
if sortMode=="zone" then
local zone=quest.zoneGroup or "Other"
if zone~=lastZone then
if self.rowCount>0 then self:AddRow("","spacer",nil,false) end
local zoneRow=self:AddRow(zone,"zone",nil,false)
if zoneRow then zoneRow.zoneName=zone end
lastZone=zone
collapsedZone=self:IsZoneCollapsed(zone)
end
end
if not collapsedZone then
local prefix=""
if quest.level and tonumber(quest.level) and tonumber(quest.level)>0 then
-- Match pfQuest's Vanilla tracker convention: the native quest-log tag
-- adds a '+' inside the level brackets, e.g. [40+].
prefix="["..tostring(quest.level)..(quest.tag and "+" or "").."] "
end
local title=tostring(quest.title or "Quest")
if quest.failed then
title=title.." (Failed)"
elseif quest.complete then
title=title.." ("..tostring(_G["COMPLETE"] or "Complete")..")"
end
self:AddRow(prefix..title,"quest",quest.id,quest.complete,quest.level,quest.failed)
self:AddTimerRow(quest)
-- A completed quest is represented by "(Complete)" in its title, matching
-- the classic Questie/Quest Log presentation. Do not keep redundant 8/8,
-- 10/10, etc. objective rows beneath an already-complete quest.
if not quest.complete then
for j=1,table.getn(quest.objectives or {}) do
local objective=quest.objectives[j]
self:AddRow(ObjectiveDisplayText(objective),"objective",nil,objective.complete)
end
end
end
end
if self.rowCount==0 then
self:AddRow("No tracked quests.","objective",nil,false)
end
-- First pass has already created the visible FontStrings at natural width.
-- Measure those exact strings and choose a tracker width capped like pfQuest.
-- Then perform one constrained pass so only text that exceeds the available
-- width wraps, and row heights expand to keep every wrapped line visible.
local frameWidth,contentWidth=self:AutoFitWidth()
for i=1,self.rowCount do
ApplyRowStyle(self.rows[i],contentWidth,true)
end
self:ApplyVisibleRowsHeight()
self:LayoutVisibleRows()
end
function T:OpenQuest(questID)
local state=QuestieOcto.QuestLog and QuestieOcto.QuestLog.active and QuestieOcto.QuestLog.active[tonumber(questID)]
if not state then return end
if QuestLogFrame and ShowUIPanel then
ShowUIPanel(QuestLogFrame)
elseif ToggleQuestLog and (not QuestLogFrame or not QuestLogFrame:IsShown()) then
ToggleQuestLog()
end
if QuestLog_SetSelection and state.logIndex then
QuestLog_SetSelection(state.logIndex)
end
if QuestLog_Update then QuestLog_Update() end
if QuestLog_UpdateQuestDetails then QuestLog_UpdateQuestDetails() end
end
function T:ApplyLockState()
if not self.frame then return end
local locked=Settings():Get("trackerLocked") and true or false
self.frame:SetMovable(not locked)
-- Height is controlled exclusively by the discrete Visible Rows option.
-- Do not expose pixel resizing, which can reintroduce partial-row clipping.
self.frame:SetResizable(false)
if self.resizeGrip then self.resizeGrip:Hide() end
end
function T:ToggleExpanded()
local db=CharacterDB()
if db.expanded then
self:SaveGeometry()
db.expanded=false
else
db.expanded=true
end
self:Render()
end
function T:ResetLocation()
if not self.frame then return end
local db=CharacterDB()
db.window.point="TOPRIGHT"
db.window.relativePoint="TOPRIGHT"
db.window.x=-30
db.window.y=-180
db.expanded=true
self:RestoreGeometry()
self:Render()
self:SaveGeometry()
end
function T:HideNativeQuestTimer()
if not QuestTimerFrame then return end
local clear=QuestTimerFrame.questieOctoOriginalClearAllPoints or QuestTimerFrame.ClearAllPoints
local setpoint=QuestTimerFrame.questieOctoOriginalSetPoint or QuestTimerFrame.SetPoint
if clear then clear(QuestTimerFrame) end
if setpoint then setpoint(QuestTimerFrame,"TOP",UIParent,"TOP",-10000,-10000) end
end
function T:LockNativeQuestTimerPosition()
if not QuestTimerFrame or self.nativeTimerMovementLocked then return end
QuestTimerFrame.questieOctoOriginalSetPoint=QuestTimerFrame.SetPoint
QuestTimerFrame.questieOctoOriginalClearAllPoints=QuestTimerFrame.ClearAllPoints
QuestTimerFrame.questieOctoOriginalSetAllPoints=QuestTimerFrame.SetAllPoints
QuestTimerFrame.SetPoint=function() end
QuestTimerFrame.ClearAllPoints=function() end
if QuestTimerFrame.SetAllPoints then QuestTimerFrame.SetAllPoints=function() end end
self.nativeTimerMovementLocked=true
end
function T:UnlockNativeQuestTimerPosition()
if not QuestTimerFrame or not self.nativeTimerMovementLocked then return end
if QuestTimerFrame.questieOctoOriginalSetPoint then
QuestTimerFrame.SetPoint=QuestTimerFrame.questieOctoOriginalSetPoint
end
if QuestTimerFrame.questieOctoOriginalClearAllPoints then
QuestTimerFrame.ClearAllPoints=QuestTimerFrame.questieOctoOriginalClearAllPoints
end
if QuestTimerFrame.questieOctoOriginalSetAllPoints then
QuestTimerFrame.SetAllPoints=QuestTimerFrame.questieOctoOriginalSetAllPoints
end
QuestTimerFrame.questieOctoOriginalSetPoint=nil
QuestTimerFrame.questieOctoOriginalClearAllPoints=nil
QuestTimerFrame.questieOctoOriginalSetAllPoints=nil
self.nativeTimerMovementLocked=false
end
function T:InstallNativeTimerSuppression()
if self.nativeTimerSuppressionInstalled then return end
self.nativeTimerSuppressionInstalled=true
if type(QuestTimerFrame_Update)=="function" then
local secureHook=hooksecurefunc
if type(secureHook)~="function" and LibStub then
local aceCore=LibStub("QuestieOcto_AceCore-3.0",true)
secureHook=aceCore and aceCore.hooksecurefunc
end
if type(secureHook)=="function" then
secureHook("QuestTimerFrame_Update",function()
if Settings():Get("trackerEnabled") then T:HideNativeQuestTimer() end
end)
else
-- Last-resort Vanilla compatibility only if neither the client nor Ace3v
-- provides Questie's normal post-hook primitive.
local original=QuestTimerFrame_Update
QuestTimerFrame_Update=function()
local result=original()
if Settings():Get("trackerEnabled") then T:HideNativeQuestTimer() end
return result
end
end
end
if QuestTimerFrame and QuestTimerFrame.SetScript then
local previous=QuestTimerFrame:GetScript("OnShow")
QuestTimerFrame:SetScript("OnShow",function()
if previous then previous() end
if Settings():Get("trackerEnabled") then T:HideNativeQuestTimer() end
end)
end
end
function T:ApplyNativeTrackerVisibility()
local enabled=Settings():Get("trackerEnabled") and true or false
self:InstallNativeTimerSuppression()
-- Questie 5.2.3/6.0.0 replace Vanilla's QuestWatchFrame while the Questie
-- tracker is enabled. Turtle may expose this frame depending on its UI build.
if QuestWatchFrame then
if enabled then QuestWatchFrame:Hide() else QuestWatchFrame:Show() end
end
-- Old Questie embeds quest timers in its tracker and moves Blizzard's timer
-- frame offscreen instead of destroying it. Preserve its original point so
-- disabling Questie Tracker can restore the native timer cleanly.
if QuestTimerFrame then
self.nativeTimerPoint=self.nativeTimerPoint or {QuestTimerFrame:GetPoint()}
if enabled then
self:HideNativeQuestTimer()
self:LockNativeQuestTimerPosition()
else
self:UnlockNativeQuestTimerPosition()
if self.nativeTimerPoint and self.nativeTimerPoint[1] then
QuestTimerFrame:ClearAllPoints()
QuestTimerFrame:SetPoint(unpack(self.nativeTimerPoint))
end
end
end
end
function T:ApplyBackgroundOpacity()
if not self.frame or not self.frame.SetBackdropColor then return end
local alpha=tonumber(Settings():Get("trackerBackgroundOpacity")) or 0
alpha=Clamp(alpha,0,1)
self.frame:SetBackdropColor(0.03,0.03,0.03,alpha)
-- Old Questie's transparent tracker is genuinely frameless. Tie the border
-- to the optional background so the default 0 opacity has no chrome.
if self.frame.SetBackdropBorderColor then
self.frame:SetBackdropBorderColor(0.45,0.45,0.45,0.75*alpha)
end
end
function T:CreateFrame()
if self.frame then return end
local frame=CreateFrame("Frame","QuestieOctoTrackerFrame",UIParent)
self.frame=frame
frame:SetFrameStrata("MEDIUM")
if frame.SetClampedToScreen then frame:SetClampedToScreen(false) end
frame:SetMovable(true)
frame:SetResizable(false)
if frame.EnableMouseWheel then frame:EnableMouseWheel(true) end
frame:SetScript("OnMouseWheel",function() T:ScrollBy(arg1) end)
frame:SetScript("OnUpdate",function()
T.timerElapsed=(T.timerElapsed or 0)+(arg1 or 0)
if T.timerElapsed>=1 then
T.timerElapsed=0
T:UpdateTimerRows()
end
end)
frame:EnableMouse(true)
frame:SetBackdrop({
bgFile="Interface\\ChatFrame\\ChatFrameBackground",
edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",
tile=true,tileSize=16,edgeSize=12,
insets={left=3,right=3,top=3,bottom=3}
})
frame:SetBackdropColor(0.03,0.03,0.03,0)
frame:SetBackdropBorderColor(0.45,0.45,0.45,0)
local header=CreateFrame("Frame",nil,frame)
self.header=header
header:SetPoint("TOPLEFT",frame,"TOPLEFT",5,-5)
header:SetPoint("TOPRIGHT",frame,"TOPRIGHT",-5,-5)
header:SetHeight(20)
header:EnableMouse(true)
if header.EnableMouseWheel then header:EnableMouseWheel(true) end
header:SetScript("OnMouseWheel",function() T:ScrollBy(arg1) end)
header:SetScript("OnMouseDown",function()
if arg1=="LeftButton" and not Settings():Get("trackerLocked") then
frame:StartMoving()
end
end)
header:SetScript("OnMouseUp",function()
frame:StopMovingOrSizing()
T:SaveGeometry()
end)
local title=header:CreateFontString(nil,"ARTWORK","GameFontNormal")
self.headerText=title
title:SetJustifyH("RIGHT")
SetFont(title,12,1,0.82,0)
title:SetText("(0)")
-- Old Questie/pfQuest-style lightweight expand control: keep the hit area,
-- but render only a +/- glyph with no button chrome.
local minimize=CreateFrame("Button",nil,header)
self.minimize=minimize
minimize:SetWidth(16)
minimize:SetHeight(16)
minimize:SetPoint("RIGHT",header,"RIGHT",0,0)
local minimizeText=minimize:CreateFontString(nil,"OVERLAY","GameFontNormal")
minimize.text=minimizeText
minimizeText:SetAllPoints(minimize)
minimizeText:SetJustifyH("CENTER")
minimizeText:SetJustifyV("MIDDLE")
SetFont(minimizeText,13,1,0.82,0)
minimizeText:SetText("-")
minimize.SetText=function(self,value) self.text:SetText(value or "") end
title:SetPoint("RIGHT",minimize,"LEFT",-2,0)
minimize:SetScript("OnClick",function() T:ToggleExpanded() end)
-- Questie 3.3.5-style tracker scrolling does not need visible scrollbar
-- chrome on Vanilla. Keep a plain ScrollFrame and use the mouse wheel.
local scroll=CreateFrame("ScrollFrame","QuestieOctoTrackerScrollFrame",frame)
self.scroll=scroll
-- Keep vertical anchors, but make width explicit. A TOPLEFT+BOTTOMRIGHT
-- horizontal anchor pair would override SetWidth() on Vanilla and leave the
-- scroll/content region one render behind the auto-fit frame width.
scroll:SetPoint("TOPLEFT",frame,"TOPLEFT",8,-29)
-- Height is explicit rather than derived from a second vertical anchor.
-- This prevents Vanilla from keeping the previous viewport height until the
-- next input event after a row-count driven frame resize.
scroll:SetWidth(math.max(1,self.defaultWidth-16))
scroll:SetHeight(math.max(1,self.defaultHeight-self.frameVerticalChrome))
if scroll.EnableMouseWheel then scroll:EnableMouseWheel(true) end
scroll:SetScript("OnMouseWheel",function() T:ScrollBy(arg1) end)
local content=CreateFrame("Frame","QuestieOctoTrackerScrollChild",scroll)
self.content=content
content:SetWidth(220)
content:SetHeight(1)
if content.EnableMouseWheel then content:EnableMouseWheel(true) end
content:SetScript("OnMouseWheel",function() T:ScrollBy(arg1) end)
scroll:SetScrollChild(content)
-- No mouse resize grip: tracker height is row-count driven.
self.resizeGrip=nil
-- Keep Blizzard's native watch/timer UI from duplicating Questie while
-- enabled. Re-apply when those native frames try to show later.
if QuestWatchFrame and QuestWatchFrame.HookScript then
QuestWatchFrame:HookScript("OnShow",function()
if Settings():Get("trackerEnabled") then this:Hide() end
end)
end
if QuestTimerFrame and QuestTimerFrame.HookScript then
QuestTimerFrame:HookScript("OnShow",function()
if Settings():Get("trackerEnabled") then T:ApplyNativeTrackerVisibility() end
end)
end
self:RestoreGeometry()
self:ApplyLockState()
self:ApplyBackgroundOpacity()
self:ApplyNativeTrackerVisibility()
frame:Hide()
end
function T:OnTrackerStateChanged()
self:Render()
end
function T:OnTrackerSettingChanged(key,value)
if key=="trackerEnabled" then
self:ApplyNativeTrackerVisibility()
self:Render()
elseif key=="trackerLocked" then
self:ApplyLockState()
self:Render()
elseif key=="trackerBackgroundOpacity" then
self:ApplyBackgroundOpacity()
elseif key=="trackerFontSize" or key=="trackerMaxWidth" or key=="trackerVisibleRows" or key=="trackerSort" or
key=="trackerOutlineText" or key=="trackerThickOutlineText" or
key=="trackerShowCompleted" or key=="trackerHideCompletedObjectives" or
key=="trackerAutoTrack" or key=="trackerHideInCombat" then
self:Render()
end
end
function T:OnCombatEvent(eventName)
if eventName=="PLAYER_REGEN_DISABLED" then self.inCombat=true else self.inCombat=false end
self:Render()
end
function T:Start()
if self.started then return end
self.started=true
self:CreateFrame()
if self.frame and self.frame.RegisterEvent then
self.frame:RegisterEvent("PLAYER_REGEN_DISABLED")
self.frame:RegisterEvent("PLAYER_REGEN_ENABLED")
local previous=self.frame:GetScript("OnEvent")
self.frame:SetScript("OnEvent",function()
if previous then previous() end
if event=="PLAYER_REGEN_DISABLED" or event=="PLAYER_REGEN_ENABLED" then T:OnCombatEvent(event) end
end)
end
QuestieOcto:RegisterMessage("TRACKER_STATE_CHANGED",self,"OnTrackerStateChanged")
QuestieOcto:RegisterMessage("TRACKER_SETTING_CHANGED",self,"OnTrackerSettingChanged")
self:Render()
end
QuestieOcto:RegisterMessage("FOUNDATION_READY",T,"Start")