vrtc / chorus (public) (License: CC0) (since 2023-08-12) (hash sha1)
World of Warcraft add-on stub. The overall goal is to create a specialized raid frame.

/src/ChorusAuraButtonTemplate.lua (427c8ea6a7f4d2c7f35bec05f3f783e19bc19f81) (16763 bytes) (mode 100644) (type blob)

--[[--
`ChorusAuraButtonTemplate` handles individual aura pictograms.

Features:
  * show formatted remaining aura duration;
  * highlight auras that were applied by the player character;
  * show remaining charge quantity when applicable;
  * show aura artwork;
  * show aura category (Magic, Poison, Disease, Curse);
  * display tooltip on mouseover;

@submodule chorus
]]

local Chorus = Chorus

local GetTime = GetTime
local UnitAura = Chorus.test.UnitAura or UnitAura
local UnitExists = Chorus.test.UnitExists or UnitExists
local UnitIsConnected = Chorus.test.UnitIsConnected or UnitIsConnected
local UnitIsUnit = Chorus.test.UnitIsUnit or UnitIsUnit

local DebuffTypeColor = DebuffTypeColor

local GameTooltip = GameTooltip

local SecureButton_GetUnit = Chorus.test.SecureButton_GetUnit or SecureButton_GetUnit

--[[--
Render pictogram artwork for this aura button.

Fallback to a picture of a question mark if the artwork could not be loaded.
That is, when `artworkFile` is `nil`.

@function applyArtwork

@tparam frame auraButton this aura button

@tparam string artworkFile pathname in Windows format with escape characters;
]]
local function applyArtwork(auraButton, artworkFile)
	assert(auraButton ~= nil)

	if not artworkFile then
		artworkFile = "Interface\\Icons\\INV_Misc_QuestionMark"
	end

	local artwork = auraButton.artwork
	assert(artwork ~= nil)
	artwork:SetTexture(artworkFile)
end

--[[--
Render sanitized and color coded border for this aura button.

@see FrameXML/BuffFrame.lua:DebuffTypeColor

@function applyOverlay

@tparam frame auraButton this aura button

@tparam string category key of `DebuffTypeColor` table

@tparam string owner unit designation of caster of the given aura, used for
color coding; in reality, either `player` or `nil`

@return nothing
]]
local function applyOverlay(auraButton, category, owner)
	if not category then
		--[[ Empty string is equivalent to 'none' by default for DebuffTypeColor. ]]--
		category = ''
	end

	local r = 1
	local g = 1
	local b = 1
	if category then
		assert(category ~= nil)
		assert('string' == type(category))
		category = strtrim(category)
		--[[ Empty string is permissible ]]--
		assert(string.len(category) >= 0)
		assert(string.len(category) <= 256)

		local colorTuple = DebuffTypeColor[category]
		r = colorTuple.r
		g = colorTuple.g
		b = colorTuple.b
	end

	local overlay = auraButton.overlay
	assert(overlay ~= nil)

	overlay:SetVertexColor(r, g, b)

	local label = auraButton.label
	assert(label ~= nil)
	if owner and 'player' == owner then
		label:SetTextColor(1, 225 / 255, 0)
	else
		label:SetTextColor(1, 1, 1)
	end
	overlay:SetAlpha(1)
end

--[[--
Format the given amount of seconds into a narrow human readable string.

This is inteded for aura effects. It may be used for any generic duration.

@function formatDuration

@tparam number durationSec positive number and not zero, the remaining seconds
of some effect

@treturn string remaining duration coerced into a string that is human
readeable and narrow for convenient rendering
]]
local function formatDuration(durationSec)
	assert(durationSec ~= nil)
	assert('number' == type(durationSec))

	local t
	local durationSecAbs = math.abs(durationSec)
	if durationSecAbs < 60 then
		t = string.format("%.0f", durationSec)
	elseif durationSecAbs < 3600 then
		t = string.format("%.0f m", durationSec / 60)
	elseif durationSecAbs < 3600 * 24 then
		t = string.format("%.0f h", durationSec / 60 / 60)
	else
		t = string.format("%.0f d", durationSec / 60 / 60 / 24)
	end
	return t
end

--[[--
Compute remaining aura duration for this aura button, given time instances,
then sanitize, format and render it.

@function applyDuration

@tparam frame auraButton this aura button

@tparam number now time instance, in the format of `function GetTime`, current
real time

@tparam number totalDurationSec positive number, the total duration in seconds
of the aura

@tparam number expirationInstance  time instance, in the format of `function
GetTime`, the instance when the aura effect ends

@return nothing
]]
local function applyDuration(auraButton, now, totalDurationSec, expirationInstance)
	assert(now ~= nil)
	assert('number' == type(now))
	assert(now >= 0)

	assert(totalDurationSec ~= nil)
	assert('number' == type(totalDurationSec))
	assert(totalDurationSec >= 0)

	assert(expirationInstance ~= nil)
	assert('number' == type(expirationInstance))
	assert(expirationInstance >= 0)

	local label = auraButton.label
	assert (label ~= nil)

	local durationRemainingSec
	if totalDurationSec and now < expirationInstance then
		durationRemainingSec = expirationInstance - now
	end

	local t
	if durationRemainingSec then
		t = formatDuration(durationRemainingSec)
	else
		--[[ The aura button text is color coded to report if the owner
		     of the aura is the user or another player. Therefore, the
		     label should never be empty. ]]--
		t = '∞'
	end

	label:SetText(t)

	local artwork = auraButton.artwork
	if artwork and totalDurationSec and durationRemainingSec and totalDurationSec >= 12 and durationRemainingSec <= 3 then
		artwork:SetAlpha(0.6)
	else
		artwork:SetAlpha(1)
	end
end

--[[--
Sanitize, format and render the charge quantity for this aura button.

@function applyChargeQuantity

@tparam frame auraButton this aura button

@tparam integer chargeQuantity positive integer and not zero, the remaining
charges (stacks) of the aura

@return nothing
]]
local function applyChargeQuantity(auraButton, chargeQuantity)
	local label = auraButton.label2
	assert (label ~= nil)

	local t = nil
	if chargeQuantity then
		assert(chargeQuantity ~= nil)
		assert('number' == type(chargeQuantity))
		chargeQuantity = math.abs(math.floor(chargeQuantity))
		if chargeQuantity < 2 then
			t = nil
		elseif chargeQuantity < 100 then
			t = string.format('%d', chargeQuantity)
		else
			t = '>99'
		end
	end
	label:SetText(t)
end

--[[--
Every frame, update the remaining duration and remaining stack quantity of the
aura, of this aura button.

Update scripts like this should be optimized for performance. Update scripts
also rely on the current real time and implicit game state.

Remaining duration of an aura cannot be queried. It must be computed, given
time instances that could be queried. This is the purpose of the update
processor function.

@see FrameXML/SecureTemplates.lua:function SecureButton_GetUnit
@function auraButtonUpdateProcessor
@tparam frame self aura button
@return nothing
]]
local function auraButtonUpdateProcessor(self)
	--[[-- @warning This function is executed every frame for every unit
	aura button. It must be optimized as much as possible.]]

	--[[-- It is not throttled for the sake of accuracy. Maybe it should
	be. ]]

	local index = self.index
	if not index then
		return
	end

	local unitDesignation = SecureButton_GetUnit(self)
	if not unitDesignation then
		return
	end

	local filter = SecureButton_GetAttribute(self, 'filter')
	if not filter then
		return
	end

	local name, _, _, chargeQuantity, _, durationSec, expirationInstance = UnitAura(unitDesignation, index, filter)
	if not name then
		return
	end
	applyDuration(self, GetTime(), durationSec, expirationInstance)
	applyChargeQuantity(self, chargeQuantity)
end

local function saneUnit(frame)
	assert(frame ~= nil)

	--[[-- @warning Insecure aura buttons access protected properties, like
	"unit" via `SecureButton_GetUnit`. This may or may not cause issues,
	like aura buttons not updating property. ]]

	local u = frame.unit or SecureButton_GetUnit(frame) or 'none'

	assert(u ~= nil)

	assert('string' == type(u))
	u = string.lower(strtrim(u))
	assert(string.len(u) >= 1)
	assert(string.len(u) <= 256)

	return u
end

local function saneUnitAuraFilter(auraButton)
	assert(auraButton ~= nil)

	local filter = auraButton.filter or SecureButton_GetAttribute(auraButton, 'filter')

	assert(filter ~= nil)

	assert('string' == type(filter))
	filter = string.upper(strtrim(filter))
	assert(string.len(filter) >= 1)
	assert(string.len(filter) <= 256)

	return filter
end

local function saneUnitAuraIndex(auraButton)
	assert(auraButton ~= nil)

	local i = auraButton.index or 0
	assert(i ~= nil)
	assert('number' == type(i))
	i = math.min(math.max(0, math.abs(math.floor(i))), 8192)

	return i
end

local function saneEvent(eventCategory)
	assert(eventCategory ~= nil)

	assert('string' == type(eventCategory))
	eventCategory = string.upper(strtrim(eventCategory))
	assert(string.len(eventCategory) >= 1)
	assert(string.len(eventCategory) <= 256)

	return eventCategory
end

local function auraLargeButtonApply(auraLargeButton, ...)

	assert(auraLargeButton ~= nil)

	local artworkFile = select(3, ...)
	local durationSec = select(6, ...)
	local expirationInstance = select(7, ...)
	local category = select(5, ...)
	local owner = select(8, ...)
	local chargeQuantity = select(4, ...)

	applyArtwork(auraLargeButton, artworkFile)
	applyDuration(auraLargeButton, GetTime(), durationSec, expirationInstance)
	applyOverlay(auraLargeButton, category, owner)
	applyChargeQuantity(auraLargeButton, chargeQuantity)
end

local function auraTinyButtonApply(auraTinyButton, ...)
	assert(auraTinyButton ~= nil)

	local artworkFile = select(3, ...)

	applyArtwork(auraTinyButton, artworkFile)
end

--[[--
Change properties of given aura button according to given arguments.

Use values returned by `UnitAura` for arguments. Assumes sane arguments. Does
not check arguments for validity. May produce widget that does not accurately
represent game state. Sanity properties should be handled elsewhere.

The function may ignore some arguments and interpret others differently
depending on context.

@function auraButtonApply
@see auraButtonRefresh
@see auraButtonUnapply
]]
local function auraButtonApply(auraButton, ...)
	assert(auraButton ~= nil)

	local strategy = auraButton.strategy

	if 'ChorusAuraLargeButtonTemplate' == strategy then
		auraLargeButtonApply(auraButton, ...)
	elseif 'ChorusAuraTinyButtonTemplate' == strategy then
		auraTinyButtonApply(auraButton, ...)
	else
		error('ChorusAuraButtonTemplate.lua: invalid enum: ' ..
		'unknown aura rendering strategy')
	end
end

local function auraLargeButtonUnapply(auraLargeButton)
	assert(auraLargeButton ~= nil)

	auraLargeButton.index = nil
	auraLargeButton.spell = nil

	local artwork = auraLargeButton.artwork
	assert(artwork ~= nil)
	artwork:SetTexture(artwork)
	artwork:SetAlpha(1)

	--[[ Remaining duration ]]--
	local label = auraLargeButton.label
	assert (label ~= nil)
	label:SetText(nil)

	--[[ Remaining charge quantity ]]--
	local label2 = auraLargeButton.label2
	assert (label2 ~= nil)
	label2:SetText(nil)

	local overlay = auraLargeButton.overlay
	assert(overlay ~= nil)
	overlay:SetAlpha(0)
end

local function auraTinyButtonUnapply(auraTinyButton)
	assert(auraTinyButton ~= nil)

	auraTinyButton.index = nil
	auraTinyButton.spell = nil

	local artwork = auraTinyButton.artwork

	if artwork then
		artwork:SetTexture(nil)
	end
end

--[[--
Obscure the aura button to the user's eye, without toggling frame visibility
mechanically.

Toggling visibility of a frame is a restricted action. That is, it can only be
done in combat by trusted or secure frames. The aura buttons, for the sake of
their core features, are not secure or trusted frames. Therefore, they must
never be toggled with `Show` or `Hide` frame methods.

This implementation must also leave to traces in the taint log.
(Logs/taint.log)

@see InCombatLockdown
@see issecure
@see auraButtonApply
@see auraButtonRefresh

@function auraButtonUnapply
@tparam frame auraButton this aura button
@return nothing, only side effects
]]
local function auraButtonUnapply(auraButton)
	assert(auraButton ~= nil)

	local strategy = auraButton.strategy

	if 'ChorusAuraLargeButtonTemplate' == strategy then
		auraLargeButtonUnapply(auraButton)
	elseif 'ChorusAuraTinyButtonTemplate' == strategy then
		auraTinyButtonUnapply(auraButton)
	else
		error('ChorusAuraButtonTemplate.lua: invalid enum: ' ..
		'unknown aura rendering strategy')
	end
end

--[[--
Query game state and attempt to update this aura button appropriately.

May toggle button visibility.

@see auraButtonApply
@see auraButtonUnapply

@function auraButtonRefresh
@tparam frame auraButton this aura button
@return nothing, produces side effects
]]
local function auraButtonRefresh(auraButton)
	assert(auraButton ~= nil)

	local u = saneUnit(auraButton)
	local filter = saneUnitAuraFilter(auraButton)
	local i = saneUnitAuraIndex(auraButton)

	local auraName, rank, artworkFile, chargeQuantity, category,
	durationSec, expirationInstance, owner = UnitAura(u, i, filter)

	if UnitExists(u) and UnitIsConnected(u) and auraName then
		auraButtonApply(auraButton, auraName, rank, artworkFile,
		chargeQuantity, category, durationSec, expirationInstance,
		owner)
	else
		auraButtonUnapply(auraButton)
	end
end

--[[--
Process stream of events, filter events only relevant to the aura button.

When aura is added or removed from a unit, given the aura button is assigned to
watch that unit, then apply relevant changes to the aura button.

@function auraButtonEventProcessor
@see FrameXML/SecureTemplates.lua:function SecureButton_GetUnit
@see auraButtonRefresh
@tparam frame auraButton the aura button
@tparam string eventCategory event category designation of the given event
@param unitDesignation vararg, given `UNIT_AURA`, the relevant unit
@return nothing
]]
local function auraButtonEventProcessor(auraButton, eventCategory, ...)
	assert(auraButton ~= nil)

	local e = saneEvent(eventCategory)

	--[[ Only refresh for the relevant unit. ]]--

	if 'UNIT_AURA' == e then
		local eventUnit = select(1, ...)
		local thisUnit = saneUnit(auraButton)
		if UnitIsUnit(thisUnit, eventUnit) then
			auraButtonRefresh(auraButton)
		end
	else
		auraButtonRefresh(auraButton)
	end
end

--[[--
Process the state of given aura button, then apply the details to the native
`GameTooltip`.

@see FrameXML/GameTooltip.lua:GameTooltip
@see FrameXML/SecureTemplates.lua:function SecureButton_GetUnit
@function auraButtonGameTooltipShow
@tparam frame self the aura button
@return nothing
]]
function Chorus.auraButtonGameTooltipShow(self)
	GameTooltip:SetOwner(self, "ANCHOR_BOTTOMLEFT");
	GameTooltip:SetFrameLevel(self:GetFrameLevel() + 2);
	local unitDesignation = SecureButton_GetUnit(self) or 'none'
	local filter = SecureButton_GetAttribute(self, 'filter') or self.filter
	local index = self.index
	local spellName = SecureButton_GetAttribute(self, 'spell') or self.spell
	if index then
		GameTooltip:SetUnitAura(unitDesignation, index, filter)
	elseif spellName then
		local rank = nil
		GameTooltip:SetUnitAura(unitDesignation, spellName, rank, filter)
	end
end

--[[--
Hide the tooltip associated with the aura button, if necessary.

Effectively, simply hides the native `GameTooltip`.

@see FrameXML/GameTooltip.lua:GameTooltip
@function auraButtonGameTooltipHide
@return nothing
]]
function Chorus.auraButtonGameTooltipHide()
	GameTooltip:Hide();
end

local function auraButtonTextScale(auraButton)
	assert(auraButton ~= nil)

	--[[ ShadowedUnitFrames recommend this approach for adjusting font
	size. ]]--

	--[[ Only do this at initialization to save on processing and prevent
	visual flicker. ]]--

	local label = auraButton.label
	assert(label ~= nil)
	local t = label:GetText()
	if t ~= nil and string.len(t) >= 1 then
		local fontSize = 12
		auraButton:SetScale(label:GetStringHeight() / fontSize)
	end
end

--[[--
Initialize the aura button frame with callbacks and children.

@function auraLargeButtonMain
@tparam frame self
@return nothing
]]
function Chorus.auraLargeButtonMain(self)
	local n = self:GetName()
	if n then
		self.artwork = _G[n .. 'Artwork']
		self.label = _G[n .. 'Text1']
		self.label2 = _G[n .. 'Text2']
		self.overlay = _G[n .. 'Overlay']
	end

	self.strategy = 'ChorusAuraLargeButtonTemplate'
	self:SetScript('OnEvent', auraButtonEventProcessor)
	self:SetScript('OnUpdate', auraButtonUpdateProcessor)
	self:RegisterEvent('PLAYER_ENTERING_WORLD')
	self:RegisterEvent('PLAYER_LOGIN')
	self:RegisterEvent('UNIT_AURA')

	auraButtonTextScale(self)
end

function Chorus.auraTinyButtonMain(auraTinyButton)
	assert(auraTinyButton ~= nil)

	local b = auraTinyButton

	local n = b:GetName()
	assert(n ~= nil)

	assert('string' == type(n))
	n = strtrim(n)
	assert(string.len(n) >= 1)
	assert(string.len(n) <= 256)

	local artwork =	_G[n .. 'Artwork']
	assert(artwork ~= nil)

	b.artwork = artwork

	b.strategy = 'ChorusAuraTinyButtonTemplate'
	b:RegisterEvent('UNIT_AURA')
	b:RegisterEvent('PLAYER_ENTERING_WORLD')

	b:SetScript('OnEvent', auraButtonEventProcessor)
end

function Chorus.auraButtonRefresh(...)
	return auraButtonRefresh(...)
end

function Chorus.auraButtonEventProcessor(...)
	return auraButtonEventProcessor(...)
end


Mode Type Size Ref File
100644 blob 35 5c40e6e2862d70b5c51c326a13073a4012ac05c7 .gitignore
100644 blob 3606 f73da473168d1897963fd2e32d89841ca0461ec0 README.adoc
040000 tree - 271296b4bafcaa151458a0192fd313641ca9b409 bin
100644 blob 228 c7dd24afa7d5c2375ff60a91c73623a304b808f9 chorus.toc
040000 tree - 99c99c3cbc641f8954a5be291e61681cb5e74629 conf
040000 tree - efa7258757edf7b888ea13c215e14b497fef8a16 doc
100644 blob 2391 1b0ca1bc25f74a34476360e5a8f14e28767b204e makefile
040000 tree - f16576d6b030728a6fd22869589b1fbc2fa5bef6 src
Hints:
Before first commit, do not forget to setup your git environment:
git config --global user.name "your_name_here"
git config --global user.email "your@email_here"

Clone this repository using HTTP(S):
git clone https://rocketgit.com/user/vrtc/chorus

Clone this repository using ssh (do not forget to upload a key first):
git clone ssh://rocketgit@ssh.rocketgit.com/user/vrtc/chorus

Clone this repository using git:
git clone git://git.rocketgit.com/user/vrtc/chorus

You are allowed to anonymously push to this repository.
This means that your pushed commits will automatically be transformed into a merge request:
... clone the repository ...
... make some changes and some commits ...
git push origin main