local _, L = ... local Timer, GetTime = CreateFrame('Frame'), GetTime -- Borrowed fixes from Storyline :) local LINE_FEED_REPLACE, LINE_BREAK_REPLACE do local LINE_FEED, CARRIAGE_RETURN = string.char(10), string.char(13) LINE_FEED_REPLACE = LINE_FEED .. '+' LINE_BREAK_REPLACE = LINE_FEED .. CARRIAGE_RETURN .. LINE_FEED end local TEXT_TIME_DIVISOR -- set later as baseline divisor for (text length / time). local TEXT_TIME_PADDING = 2 -- static padding, feels more natural with a pause to breathe. local MAX_UNTIL_SPLIT = 200 -- start recursive string splitting if the text is too long. Timer.Texts = {} L.TextMixin = {} local Text = L.TextMixin ---------------------------------- -- Text: manage text input ---------------------------------- function Text:SetText(text) TEXT_TIME_DIVISOR = L('delaydivisor') self:PreparePlayback() self.storedText = text if text then local timeToFinish, strings, timers = self:CreateLineData(text) self.numTexts = #strings self.timeToFinish = timeToFinish self.timeStarted = GetTime() self:QueueTexts(strings, timers) end end function Text:ReplaceLinefeed(text) return text:gsub(LINE_FEED_REPLACE, '\n'):gsub(LINE_BREAK_REPLACE, '\n') end function Text:ReplaceNatural(str) local new = str -- substitute natural breaks with newline. :gsub('%.%s%.%s%.', '...') -- ponder special case :gsub('%.%s+', '.\n') -- sentence :gsub('%.%.%.\n', '...\n...') -- ponder :gsub('%!%s+', '!\n') -- exclamation :gsub('%?%s+', '?\n') -- question return new, (new == str) -- return new string, and whether something changed end function Text:CreateLineData(text) text = self:ReplaceLinefeed(text) local timeToFinish, strings, timers = 0, {}, {} for _, paragraph in ipairs({strsplit('\n', text)}) do timeToFinish = timeToFinish + self:AddString(paragraph, strings, timers) end return timeToFinish, strings, timers end function Text:CalculateLineTime(length) return (length / (TEXT_TIME_DIVISOR or 15) ) + TEXT_TIME_PADDING end function Text:AddString(str, strings, timers) local length, timer, new, forceShow = str:len(), 0 if length > MAX_UNTIL_SPLIT then new, forceShow = self:ReplaceNatural(str) --[[ If the string is unchanged, this will recurse infinitely, therefore force the long string to be shown. This safeguard is probably meaningless, as it requires 200+ chars without any punctuation. ]] if not forceShow then -- recursively split the altered string for _, sentence in ipairs({strsplit('\n', new)}) do timer = timer + self:AddString(sentence, strings, timers) end return timer end end if ( length ~= 0 or forceShow ) then timer = self:CalculateLineTime(length) timers[ #strings + 1] = timer strings[ #strings + 1 ] = str end return timer end ---------------------------------- -- Text: playback ---------------------------------- function Text:QueueTexts(strings, timers) assert(strings, 'No strings added to object '.. ( self:GetName() or '' ) ) assert(timers, 'No timers added to object '.. ( self:GetName() or '' ) ) self.strings = strings self.timers = timers Timer:AddText(self) end function Text:ForceNext() if self:HasLineData() then local _, remainingLineTime = self:RemoveLine() self.timeToFinish = self.timeToFinish - remainingLineTime if self:HasLine() then self:SetToCurrentLine() else self:PauseTimer() self:RepeatTexts() self:FlagForceFinished(true) end if not self:HasFollowup() then self:OnFinished() self:FlagForceFinished(true) end end end function Text:SetToCurrentLine() self:DisplayLine(self:GetLine()) end function Text:SetCurrentLineTime(time) self.currentLineTime = time or 0 end function Text:UpdateCurrentLineTime(delta) self.timers[1] = self.timers[1] + delta end function Text:RepeatTexts() if self.storedText then self:SetText(self.storedText) end end function Text:OnFinished() self.strings = nil self.timers = nil end function Text:FlagForceFinished(state) self.forceFinished = state end function Text:IsForceFinishedFlagged() return self.forceFinished end function Text:PreparePlayback() self.numTexts = nil self:FlagForceFinished(false) self:PauseTimer() self:OnFinished() self:DisplayLine() end function Text:ResumeTimer() if self:HasLineData() then Timer:AddText(self) return true end end function Text:PauseTimer() Timer:RemoveText(self) end ---------------------------------- -- Text: display ---------------------------------- function Text:DisplayLine(text, time) if not self:GetFont() then self:CheckApplicableFonts() self:SetFontObject(self.fontObjectsToTry[1]) end getmetatable(self).__index.SetText(self, text) self:SetCurrentLineTime(time) self:ApplyFontObjects() if self.OnDisplayLineCallback then self:OnDisplayLineCallback(text, time) end end function Text:SetFontObjectsToTry(...) self.fontObjectsToTry = { ... } if self:GetText() then self:ApplyFontObjects() end end function Text:ApplyFontObjects() self:CheckApplicableFonts() for i, fontObject in ipairs(self.fontObjectsToTry) do self:SetFontObject(fontObject) if not self:IsTruncated() then break end end end function Text:CheckApplicableFonts() if not self.fontObjectsToTry or not self.fontObjectsToTry[1] then error('No fonts applied to TextMixin, call SetFontObjectsToTry first') end end ---------------------------------- -- Text: state getters ---------------------------------- function Text:GetTimeRemaining() if self.timeStarted and self.timeToFinish then local difference = ( self.timeStarted + self.timeToFinish ) - GetTime() return difference < 0 and 0 or difference end return 0 end function Text:GetProgress() local full = self:GetNumTexts() local remaining = self:GetNumRemaining() return ('%d/%d'):format(full - remaining + 1, full) end function Text:GetProgressPercent() if self.timeStarted and self.timeToFinish then local progress = ( GetTime() - self.timeStarted ) / self.timeToFinish return ( progress > 1 ) and 1 or progress end return 1 end function Text:GetCurrentProgress() local modifiedTime = self:GetModifiedTime() local fullTime = self:GetOriginalTime() if modifiedTime and fullTime and fullTime > 0 then return (1 - modifiedTime / fullTime) end end function Text:IsFinished() return not self.strings end function Text:IsSequence() return self.numTexts and self.numTexts > 1 end function Text:IsLineFinished() return self.timers[1] <= 0 end function Text:GetNumTexts() return self.numTexts or 0 end function Text:GetNumRemaining() return self.strings and #self.strings or 0 end function Text:HasLineData() return self.strings and self.timers end function Text:HasLine() return self.strings and self.strings[1] and true end function Text:HasFollowup() return self.strings and self.strings[2] and true end function Text:GetModifiedTime() return self.timers and self.timers[1] end function Text:GetOriginalTime() return self.currentLineTime or 0 end function Text:GetLineProgress() return (self.timers and self.currentLineTime) and (self.timers[1]/self.currentLineTime) or 1 end function Text:GetLine() return self.strings[1], self.timers[1] end function Text:RemoveLine() return tremove(self.strings, 1), tremove(self.timers, 1) end ---------------------------------- -- Timer handle ---------------------------------- function Timer:AddText(fontString) if fontString then self.Texts[fontString] = true self:SetScript('OnUpdate', self.OnUpdate) end end function Timer:GetTexts() return pairs(self.Texts) end function Timer:RemoveText(fontString) if fontString then self.Texts[fontString] = nil end end function Timer:OnTextFinished(fontString) if fontString then self:RemoveText(fontString) if fontString.OnFinishedCallback then fontString:OnFinishedCallback() end end end function Timer:OnUpdate(elapsed) for text in self:GetTexts() do if text:HasLine() then -- if there's no text displayed, display the current line. if not text:GetText() then text:SetToCurrentLine() end -- deduct elapsed time since update from current timer text:UpdateCurrentLineTime(-elapsed) -- timer is below/equal to zero, move on to next line if text:IsLineFinished() then text:RemoveLine() -- check if there's another line waiting if text:HasLine() then text:SetToCurrentLine() else text:OnFinished() end end else self:OnTextFinished(text) end end if not next(self.Texts) then self:SetScript('OnUpdate', nil) end end