/choir.lua (8aae38b2c80aca1af9691104dcd29596a5bb8214) (18516 bytes) (mode 100644) (type blob)

local function trace(...)
	print(date('%X'), '[Choir]:', ...)
end

local function getDefaultUnitButtonBarColor()
	return 0, 1, 0
end

local function createClearcastingSubset(unitButton)
	assert (unitButton ~= nil)

	if not ClearcastingFrame then
		trace('could not access Clearcasting module')
		return
	end

	local createSubset = ClearcastingFrame.createSubset
	assert (createSubset ~= nil)

	--[[ FIXME Update indicator unit designation when unit button attribute changes at runtime.
	--         This is not an expected use-case. Rather it is a matter of robustness. ]]--
	local unitDesignation = unitButton:GetAttribute('unit')
	assert (unitDesignation ~= nil)

	local harmSubset = createSubset(unitButton, unitButton:GetName() .. 'HarmfulSubsetFrame',
	                                unitDesignation, 'HARMFUL',
					5, 1)
	harmSubset:SetPoint('BOTTOMLEFT', 0, 0)

	--[[ TODO Track tank defensives in addition to player (healer) buffs. ]]--
	local helpSubset = createSubset(unitButton, unitButton:GetName() .. 'HelpfulSubsetFrame',
	                                unitDesignation, 'PLAYER HELPFUL',
					5, 1)
	helpSubset:SetPoint('BOTTOMLEFT', 0, harmSubset:GetHeight())

	return harmSubset, helpSubset
end

local function createBindingKeyHandler(button)
	assert (button ~= nil)

	local handler = CreateFrame('FRAME', button:GetName() .. 'ChoirBindingKeyHandler',
	                            button, 'SecureHandlerShowHideTemplate')

	handler:WrapScript(button, 'OnShow', [=[
		local key = self:GetAttribute('choirBindingKey')
		if key then
			self:SetBindingClick(true, key, self)
		end

	]=])

	handler:WrapScript(button, 'OnHide', [=[
		self:ClearBindings()
	]=])


	return handler
end

local function createSpellShortcut(unitButton, frameDesignation, spellName)
	assert (unitButton ~= nil)

	assert (spellName ~= nil)
	assert ('string' == type(spellName))
	spellName = strtrim(spellName)
	assert (string.len(spellName) >= 2)
	assert (string.len(spellName) <= 256)

	local unitDesignation = unitButton:GetAttribute('unit')
	assert (unitDesignation ~= nil)
	assert ('string' == type(unitDesignation))
	unitDesignation = strtrim(unitDesignation)
	assert (string.len(unitDesignation) >= 2)
	assert (string.len(unitDesignation) <= 32)

	local b = CreateFrame('BUTTON', frameDesignation, unitButton, 'SecureActionButtonTemplate')
	b:SetAttribute('type', 'spell')
	b:SetAttribute('unit', unitDesignation)
	b:SetAttribute('spell', spellName)

	createBindingKeyHandler(b)

	assert (b ~= nil)
	return b
end

local function createLabel(ownerFrame, fontObject)
	assert (ownerFrame ~= nil)

	local t = ownerFrame:CreateFontString((ownerFrame:GetName() or '') .. 'Text', 'OVERLAY')
	fontObject = fontObject or NumberFont_OutlineThick_Mono_Small
	assert (fontObject ~= nil)
	t:SetFontObject(fontObject)
	t:SetAllPoints()

	return t
end

local function createBackground(ownerFrame)
	assert (ownerFrame ~= nil)

	local background = ownerFrame:CreateTexture(ownerFrame:GetName() .. 'Background', 'BACKGROUND')
	background:SetAllPoints()
	background:SetTexture(0, 0, 0, 0.8)

	return background
end

local function getUnitHealthDeficit(unitDesignation)
	assert (unitDesignation ~= nil)

	local m = UnitHealthMax(unitDesignation) or 0
	local c = UnitHealth(unitDesignation) or 0

	return math.abs(c) - math.abs(m)
end

local function getUnitHealthRatio(unitDesignation)
	assert (unitDesignation ~= nil)

	local m = math.abs(math.max(UnitHealthMax(unitDesignation) or 1, 1))
	local c = math.abs(UnitHealth(unitDesignation) or 1)

	return c / m
end

local function updateUnitButtonBarText(bar, unitDesignation)
	assert (bar ~= nil)

	local label = bar.text
	assert (label ~= nil)

	local t
	local d = getUnitHealthDeficit(unitDesignation) or 0
	if UnitIsDead(unitDesignation) then
		t = '(Dead)'
	elseif UnitIsGhost(unitDesignation) then
		t = '(Ghost)'
	elseif d < 0 then
		t = tostring(math.floor(d))
	else
		t = nil
	end

	label:SetText(t)
end

local function updateUnitButtonText(self)
	assert (self ~= nil)

	local label = self.text
	assert (label ~= nil)

	local unitDesignation = self:GetAttribute('unit')
	assert (unitDesignation ~= nil)

	local n = UnitName(unitDesignation) or unitDesignation

	local key = GetBindingKey('CLICK ' .. self:GetName() .. ':LeftButton')
	if not key then
		key = self:GetAttribute('choirBindingKey')
	end
	if key then
		n = n .. ' <' .. key .. '>'
	end

	label:SetText(n)
end

local function getClassColor(classDesignation)
	assert (classDesignation ~= nil)
	assert ('string' == type(classDesignation))
	classDesignation = strtrim(classDesignation)
	assert (string.len(classDesignation) >= 2)
	assert (string.len(classDesignation) <= 64)

	local t = RAID_CLASS_COLORS[classDesignation]
	if not t then
		return nil
	end
	return t['r'], t['g'], t['b']
end

local function updateUnitButtonBarOverlay(bar, unitDesignation)
	assert (bar ~= nil)

	assert (unitDesignation ~= nil)
	assert ('string' == type(unitDesignation))
	unitDesignation = strtrim(unitDesignation)
	assert (string.len(unitDesignation) >= 2)
	assert (string.len(unitDesignation) <= 32)

	--[[ Apply bar color update ]]--
	local r, g, b = getDefaultUnitButtonBarColor()

	local _, classDesignation = UnitClass(unitDesignation)
	if classDesignation then
		r, g, b = getClassColor(classDesignation)
	end

	--[[ TODO Add line of sight indicator ]]--
	local a = 1
	local rangeSpell = ChoirRangeSpellName
	--[[ NOTE IsSpellInRange returns either 0, 1 or nil ]]--
	if rangeSpell and 1 ~= IsSpellInRange(rangeSpell, unitDesignation) then
		a = 1 / 2
	end

	local overlay = bar.overlay
	assert (overlay ~= nil)
	overlay:SetTexture(r, g, b, a)

	--[[ Apply bar width update ]]--
	local ratio = 1
	if UnitExists(unitDesignation) then
		ratio = getUnitHealthRatio(unitDesignation) or 1
	end
	ratio = math.min(math.max(0, ratio or 1), 1)
	overlay:SetPoint('BOTTOMLEFT', bar, 'BOTTOMLEFT', 0, 0)
	overlay:SetPoint('TOPRIGHT', bar, 'TOPRIGHT', (ratio - 1) * bar:GetWidth(), 0)
end

local function createUnitButtonBar(unitButton)
	assert (unitButton ~= nil)

	local n = unitButton:GetName() or ''
	local padding = 4
	local marginTop = 24
	local marginBottom = 40 * 2 + (3 * 3)
	local bar = CreateFrame('FRAME', n .. 'Bar', unitButton)
	bar:SetPoint('BOTTOMLEFT', unitButton, 'BOTTOMLEFT', padding, padding + marginBottom)
	bar:SetPoint('TOPRIGHT', unitButton, 'TOPRIGHT', -padding, -padding - marginTop)

	local b = bar:CreateTexture(bar:GetName() .. 'Overlay', 'OVERLAY')
	b:SetAllPoints()
	b:SetTexture(getDefaultUnitButtonBarColor(), 1)
	bar.overlay = b

	local t = createLabel(bar)
	bar.text = t

	bar.unitButton = unitButton

	--[[ Update health indicator ]]--
	bar:SetScript('OnEvent', function(healthBarFrame)
		assert (healthBarFrame ~= nil)
		assert (unitButton ~= nil)

		local u = unitButton:GetAttribute('unit')
		assert (u ~= nil)
		updateUnitButtonBarOverlay(healthBarFrame, u)
		updateUnitButtonBarText(healthBarFrame, u)
	end)
	bar:RegisterEvent('PLAYER_FOCUS_CHANGED')
	bar:RegisterEvent('PLAYER_TARGET_CHANGED')
	bar:RegisterEvent('UNIT_HEALTH')
	--[[ NOTE UNIT_SPELLCAST_* family of events are relied on to render range indicator correctly,
	--        instead of the more expensive update hook. ]]--
	bar:RegisterEvent('UNIT_SPELLCAST_FAILED')
	bar:RegisterEvent('UNIT_SPELLCAST_FAILED_QUIET')
	bar:RegisterEvent('UNIT_SPELLCAST_SENT')
	bar:RegisterEvent('UNIT_SPELLCAST_START')

	return bar
end

local function unitButtonEventProcessor(unitButton)
	assert (unitButton ~= nil)

	updateUnitButtonText(unitButton)

	local bar = unitButton.bar
	assert (unitButton ~= nil)

	local unitDesignation = unitButton:GetAttribute('unit')
	assert (unitDesignation ~= nil)
	assert ('string' == type(unitDesignation))
	unitDesignation = strtrim(unitDesignation)
	assert (string.len(unitDesignation) >= 2)
	assert (string.len(unitDesignation) <= 32)

	updateUnitButtonBarOverlay(bar, unitDesignation)
	updateUnitButtonBarText(bar, unitDesignation)
end

local function createInheritanceHandler(unitButton)
	assert (unitButton ~= nil)

	local n = (unitButton:GetName() or 'Choir') .. 'InheritanceHandler'
	local inheritor = CreateFrame('FRAME', n, unitButton, 'SecureHandlerAttributeTemplate')

	--[[ When a button's target unit changes, make sure that all children buttons of this button update,
	--   to also target the same unit.
	--   Spell shortcut feature applied by createSpellShortcut funciton depends on the inheritance handler. ]]--
	inheritor:WrapScript(unitButton, 'OnAttributeChanged', [=[
		local unitButton = self

		local unitDesignation = unitButton:GetAttribute('unit')
		if not unitDesignaiton then
			return
		end

		local buttonChildList = unitButton:GetChildList(newtable())
		local i = 0
		while (i < #buttonChildList) do
			i = i + 1
			local child = buttonChildList[i]

			child:SetAttribute('unit', unitDesignation)
		end
	]=])

	return inheritor
end

local function createUnitButton(parentFrame, frameName, unit)
	assert (parentFrame ~= nil)
	assert (frameName ~= nil)
	assert (unit ~= nil)

	--[[ TODO Add children buttons that are secure spell buttons on the same target as the unit button.
	--        Set override bindings to those buttons as ALT-A, CTRL-A, SHIFT-A, where A is the unit button key.
	--        The spells assigned to the buttons let be something like Cleanse, Dispel Magic or Flash Heal.
	--        That way, user may cast something without targeting or hiding the selection spoiler.
	--        The problem is that it is class dependant.]]--
	local u = CreateFrame('BUTTON', frameName, parentFrame, 'SecureUnitButtonTemplate')
	u:SetAttribute('type', 'target')
	u:SetAttribute('unit', unit)

	createBindingKeyHandler(u)
	createInheritanceHandler(u)

	u:SetSize(24 * 5 + 3 * 6, 40 * 2 + 3 * 3 + 24 * 2)

	local t = createLabel(u)
	t:SetPoint('BOTTOMLEFT', u, 'BOTTOMLEFT', 4, u:GetHeight() - 24 - 4)
	t:SetPoint('TOPRIGHT', u, 'TOPRIGHT', -4, -4)
	u.text = t

	local b = createBackground(u)
	u.background = b

	local bar = createUnitButtonBar(u)
	u.bar = bar

	createClearcastingSubset(u)

	u:SetScript('OnEvent', unitButtonEventProcessor)
	u:RegisterEvent('PARTY_CONVERTED_TO_RAID')
	u:RegisterEvent('PARTY_MEMBERS_CHANGED')
	u:RegisterEvent('PLAYER_ALIVE')
	u:RegisterEvent('RAID_ROSTER_UPDATE')
	u:RegisterEvent('UPDATE_BATTLEFIELD_SCORE')
	u:RegisterEvent('ADDON_LOADED')

	assert (u ~= nil)
	return u
end

local function createSpoiler(spoilerParent, spoilerDesignation)
	local spoiler = CreateFrame('BUTTON', spoilerDesignation, spoilerParent, 'SecureHandlerClickTemplate')
	spoiler:EnableMouse(true)
	spoiler:SetAttribute('_onclick', [=[
		--[[ Toggle the spoiler on repeated clicks. ]]--
		if self:IsShown() then
			self:Hide()
			return
		end

		--[[ Override binding key to toggle the spoiler on Escape key press. ]]--
		self:SetBindingClick(true, 'CTRL-W', self)
		self:SetBindingClick(true, 'ESCAPE', self)

		self:Show()

		local childTable = self:GetChildList(newtable())
		local i = 0
		while (i < #childTable) do
			i = i + 1
			local child = childTable[i]
			child:Show()
		end
	]=])

	--[[ Toggle sibling frames, which are other spoilers that contain unit buttons ]]--
	--[[ WARNING Make sure not to toggle this frame (self).
	--           Otherwise wrap scripts might trigger unexpectedly. ]]--
	spoiler:WrapScript(spoiler, 'OnShow', [=[
		local parentFrame = self:GetParent()
		local siblingTable = parentFrame:GetChildList(newtable())
		local i = 0
		while (i < #siblingTable) do
			i = i + 1
			local sibling = siblingTable[i]
			if self ~= sibling then
				sibling:Hide()
			end
		end
	]=])

	spoiler:WrapScript(spoiler, 'OnHide', [=[
		self:ClearBindings()
	]=])

	createBindingKeyHandler(spoiler)

	return spoiler
end

local function getButtonBindingKeyExplicit(buttonRef)
	assert (buttonRef ~= nil)

	local key = GetBindingKey('CLICK ' .. buttonRef:GetName() .. ':LeftButton')
	return key
end

local function getButtonBindingKeyDefault(buttonNumber)
	assert (buttonNumber ~= nil)
	assert ('number' == type(buttonNumber))
	buttonNumber = math.abs(math.floor(buttonNumber))

	local key
	local actionBarSize = 12
	if buttonNumber >= 1 and buttonNumber <= actionBarSize then
		key = GetBindingKey('ACTIONBUTTON' .. tostring(buttonNumber))
	elseif buttonNumber > actionBarSize and buttonNumber <= actionBarSize * 6 then
		local r = buttonNumber % actionBarSize
		local n = buttonNumber - r * actionBarSize
		key = GetBindingKey('MULTIACTIONBAR' .. r .. 'BUTTON' .. n)
	else
		key = nil
	end

	return key
end

local function getShortcutBindingKeySuggestion(buttonNumber, spellNumber)
	assert (buttonNumber ~= nil)
	assert (buttonNumber >= 1 and buttonNumber <= 5)

	assert (spellNumber ~= nil)
	assert (spellNumber >= 1 and spellNumber <= 5)

	local map = {
		{'Q', 'ALT-Q', 'ALT-A', 'SHIFT-A', '1',},
		{'W', 'ALT-W', 'ALT-S', 'SHIFT-S', '2',},
		{'E', 'ALT-E', 'ALT-D', 'SHIFT-D', '3',},
		{'R', 'ALT-R', 'ALT-F', 'SHIFT-F', '4',},
		{'T', 'ALT-T', 'ALT-G', 'SHIFT-G', '5',},
	}

	local bindingList = map[buttonNumber]

	return bindingList[spellNumber]
end

local function createUnitButtonSpellShortcut(unitButton, buttonNumber)
	assert (unitButton ~= nil)

	--[[ FIXME Add shortcuts for all classes and use cases ]]--
	local spellSet = {
		'Dispel Magic',
		'Abolish Disease',
		'Renew',
		'Power Word: Shield',
		'Flash Heal',
	}

	local spellNumber = 0
	while (spellNumber < #spellSet) do
		spellNumber = spellNumber + 1

		local n = unitButton:GetName() .. 'Shortcut' .. tostring(spellNumber)
		local spellName = spellSet[spellNumber]
		local b = createSpellShortcut(unitButton, n, spellName)

		local key = getShortcutBindingKeySuggestion(buttonNumber, spellNumber)
		b:SetAttribute('choirBindingKey', key)
	end
end

local function createGroup(rootFrame, groupNumber, unitTable)
	assert (rootFrame ~= nil)

	assert (groupNumber ~= nil)
	groupNumber = math.floor(math.abs(groupNumber))
	assert ((groupNumber >= 1 and groupNumber <= 8) or unitTable ~= nil)

	local groupSize = 5

	local u
	if unitTable then
		u = unitTable
	else
		local q = 0
		u = {}
		while (q < groupSize) do
			q = q + 1
			u[q] = 'raid' .. tostring((groupNumber - 1) * groupSize + q)
		end
	end
	assert (u ~= nil)
	assert ('table' == type(u))
	assert (#u == groupSize)

	local spoiler = createSpoiler(rootFrame, 'ChoirSpoiler' .. tostring(groupNumber))

	local i = 0
	local marginLeft = 0
	local padding = 4
	while (i < #u) do
		i = i + 1
		local unitDesignation = u[i]
		assert (unitDesignation ~= nil)
		local memberNumber = (groupNumber - 1) * groupSize + i
		local b = createUnitButton(spoiler, 'ChoirUnitButton' .. tostring(memberNumber), unitDesignation)
		b:SetPoint('BOTTOMLEFT', marginLeft, 0)
		marginLeft = marginLeft + b:GetWidth() + padding

		spoiler:WrapScript(b, 'OnClick', [=[
			--[[ Assume that parent is the spoiler frame
			--  which was created with createSpoiler and
			--  has required scripts hooked to it ]]--
			local spoilerFrame = self:GetParent()
			spoilerFrame:Hide()
		]=])
		b:Hide()

		local key = getButtonBindingKeyExplicit(b) or getButtonBindingKeyDefault(i)
		if key then
			b:SetAttribute('choirBindingKey', key)
		end

		_G['BINDING_NAME_CLICK ' .. b:GetName() .. ':LeftButton'] = 'Unit ' .. tostring(i)

		createUnitButtonSpellShortcut(b, i)
	end
	spoiler:SetSize(marginLeft, 144)
	spoiler:SetPoint('CENTER', 0, 12 * 6)

	_G['BINDING_NAME_CLICK ' .. spoiler:GetName() .. ':LeftButton'] = 'Group ' .. tostring(groupNumber)

	spoiler:Hide()

	return spoiler
end

local function getRangeSpellNameSuggestion()
	local _, classDesignation = UnitClass('player')
	assert (classDesignation ~= nil)
	assert ('string' == type(classDesignation))
	classDesignation = strtrim(classDesignation)
	assert (string.len(classDesignation) >= 2)
	assert (string.len(classDesignation) <= 64)

	local map = {
		['DRUID'] = {'Cure Poison', 'Healing Touch'},
		['PALADIN'] = {'Cleanse', 'Purify', 'Holy Light'},
		['PRIEST'] = {'Dispel Magic', 'Cure Disease', 'Lesser Heal'},
		['SHAMAN'] = {'Healing Wave'},
	}

	local t = map[classDesignation]
	if not t then
		return
	end
	assert (t ~= nil)
	assert ('table' == type(t))
	assert (#t >= 1)

	local s = nil
	local i = 0
	while (i < #t) do
		i = i + 1
		local candidate = t[i]
		assert (candidate ~= nil)
		local spellName = GetSpellInfo(candidate)
		if spellName then
			s = spellName
			break
		end
	end

	return s
end

local function initRangeSpellName(rootFrame)
	local f = CreateFrame('FRAME', rootFrame:GetName() .. 'RangeSpellFrame')
	f:SetScript('OnEvent', function()
		ChoirRangeSpellName = getRangeSpellNameSuggestion()
	end)
	f:RegisterEvent('SPELLS_CHANGED')

	local s = ChoirRangeSpellName
	if s then
		assert (s ~= nil)
		assert ('string' == type(s))
		s = strtrim(s)
		assert (string.len(s) >= 2)
		assert (string.len(s) <= 256)
	else
		s = getRangeSpellNameSuggestion()
	end

	ChoirRangeSpellName = s

	return s
end

local function initSpoiler(rootFrame)
	assert (rootFrame ~= nil)

	createGroup(rootFrame, 1)
	createGroup(rootFrame, 2)
	createGroup(rootFrame, 3)
	createGroup(rootFrame, 4)
	createGroup(rootFrame, 5)
	createGroup(rootFrame, 6)
	createGroup(rootFrame, 7)
	createGroup(rootFrame, 8)

	--[[ TODO Add Esc key that closes the spoiler without clicking any of the children buttons ]]--
	--[[ TODO Generalize the interface to be used with any kind of child frames,
	--        especially nested spoilers and spell buttons. ]]--
	--[[ NOTE To get a saved variables kind of table from a restricted frame snippet, use ```
	--           local env = GetManagedEnvironment(headerRef)
	--           local t = env['MySavedVariableTable']
	--   ```
	--   See http://www.iriel.org/wow/docs/SecureHeadersGuide-4.0-r1.pdf ]]--
	local spoilerParty = createGroup(rootFrame, 9, {'player', 'party1', 'party2', 'party3', 'party4'})
	_G['BINDING_NAME_CLICK ' .. spoilerParty:GetName() .. ':LeftButton'] = 'Player party'

	BINDING_HEADER_CHOIR = 'Choir'
end

local function init(rootFrame)
	assert (rootFrame ~= nil)

	local locale = GetLocale()
	assert (locale == 'enGB' or locale == 'enUS', 'requires English localization')

	rootFrame:UnregisterAllEvents()

	rootFrame:SetSize(1024, 768)
	rootFrame:SetPoint('CENTER', 0, 0)

	initRangeSpellName(rootFrame)
	initSpoiler(rootFrame)

	trace('init')
end

local function main()
	local rootFrame = CreateFrame('FRAME', 'ChoirFrame', UIParent)
	--[[ NOTE The add-on requires key bindings data.
	--        Therefore trigger the add-on initialization after the key bindings were loaded. ]]--
	rootFrame:RegisterEvent('PLAYER_LOGIN')
	rootFrame:SetScript('OnEvent', init)
end
main()


Mode Type Size Ref File
100644 blob 9 b72f9be2042aa06db9cb3a6f533f953b5ce29901 .gitignore
100644 blob 910 dcef2424d4e3e746bbada588273af208b6da8042 .luacheckrc
100644 blob 934 73158d32fbccb1838992b04c2901ba6348410e3d bindings.xml
100644 blob 18516 8aae38b2c80aca1af9691104dcd29596a5bb8214 choir.lua
100644 blob 189 779ba5a963c4dfea22c164ec7876f212f4bca7f0 choir.toc
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/choir

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

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

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