-----------------------------------------------------------
-- VirtualScrollList-1.0.lua
-----------------------------------------------------------
-- A virtual scroll list is a scroll list frame that is capable of displaying infinite
-- amount of user data with a fixed number of list buttons. Blizzard's FauxScrollFrame
-- does basically the same thing, though this one is far more powerful and convenient to use.
--
-- Abin (2010-1-27)

-----------------------------------------------------------
-- API Documentation:
-----------------------------------------------------------

-- frame = UICreateVirtualScrollList("name", parent, maxButtons [, selectable [, "buttonTemplate"]) -- Create a virtual scroll list frame

-- frame:GetScrollOffset() -- Return current scroll offset (0-N)
-- frame:SetScrollOffset(offset) -- Scroll the list
-- frame:CheckVisible([position]) -- Check whether a position is visible in the list (nil-invalid, 0-visible, other-invisible), "position" defaults to current selection
-- frame:EnsureVisible([position]) -- Ensure a position being visible, scroll the list if needed, "position" defaults to current selection
-- frame:RefreshContents() -- Refresh the entire list, call only when necessary!

-- frame:SetSelection(position) -- Select a data
-- frame:GetSelection() -- Get current selection

-- frame:GetDataCount() -- Return number of data in the list
-- frame:GetData(position) -- Retrieve a particular data
-- frame:SetData(position, data) -- Modify an existing data
-- frame:FindData(data [, compareFunc]) == Search for the first match of a particular data
-- frame:InsertData(data [, position]) -- Insert a new data to the list, by default the data is inserted at the end of list
-- frame:RemoveData(position) -- Remove an existing data, by default it removes the last data from the list
-- frame:ShiftData(position1, position2) -- Shift a data from position1 to position2
-- frame:SwapData(position1, position2) -- Swap 2 data in the list
-- frame:UpdateData(position) -- Call frame:OnButtonUpdate(button, data) if the list button reflects to position is visible at the moment
-- frame:Clear() -- Clear the list, all data are deleted

-----------------------------------------------------------
-- Callback Methods:
-----------------------------------------------------------

-- frame:OnButtonCreated(button) -- Called when a new list button is created
-- frame:OnButtonUpdate(button, data) -- Called when a list button needs to be re-painted
-- frame:OnButtonTooltip(button, data) -- Called when the mouse hovers a list button, you only need to populate texts into GameTooltip
-- frame:OnButtonEnter(button, data, motion) -- Called when the mouse hovers a list button
-- frame:OnButtonLeave(button, data, motion) -- Called when the mouse leaves a list button
-- frame:OnButtonClick(button, data, flag, down) -- Called when a list button is clicked
-- frame:OnSelectionChanged(position, data) -- Called when the selection changed

-----------------------------------------------------------

local MAJOR_VERSION = 1
local MINOR_VERSION = 10

-- To prevent older libraries from over-riding newer ones...
if type(UICreateVirtualScrollList_IsNewerVersion) == "function" and not UICreateVirtualScrollList_IsNewerVersion(MAJOR_VERSION, MINOR_VERSION) then return end

local NIL = "!2BFF-1B787839!"

local function EncodeData(data)
	return (data == nil) and NIL or data -- Must be nil, not false
end

local function DecodeData(data)
	return (data ~= NIL) and data or nil
end

local function SafeCall(func, ...)
	if type(func) == "function" then
		return func(...)
	end
end

-- Update slider buttons stats: enable/disable according to scroll range and offset
local function Slider_UpdateSliderButtons(self)
	local low, high = self:GetMinMaxValues()
	local value = self:GetValue()
	local up = self:GetParent().Up
	local down = self:GetParent().Down

	if low and high and value then
		if value <= low then
			up:Disable()
		else
			up:Enable()
		end

		if value >= high then
			down:Disable()
		else
			down:Enable()
		end
	end
end

local function ScrollBar_Button_OnClick(self)
	local slider = self:GetParent().slider
	slider:SetValue(slider:GetValue() + self.value)
end

-- Create slider button: Up/Down
local function ScrollBar_CreateScrollButton(self, value)
	local button = CreateFrame("Button", self:GetName()..value.."Button", self, "UIPanelScroll"..value.."ButtonTemplate")
	button.value = value == "Up" and -1 or 1
	self[value] = button
	button:SetWidth(16)
	button:SetHeight(14)
	button:SetPoint(value == "Up" and "TOP" or "BOTTOM")
	button:Disable()
	button:SetScript("OnClick", ScrollBar_Button_OnClick)
end

-- Apply or remove a texture(highlight/checked) to/from a particular list button
local function Frame_TextureButton(self, textureName, button)
	local texture = self[textureName]
	if texture then
		if button then
			if texture.button ~= button then
				texture.button = button
				texture:SetParent(button)
				texture:ClearAllPoints()
				texture:SetAllPoints(button)
				texture:Show()
			end
		else
			texture.button = nil
			texture:Hide()
		end
	end
end

-- Schedule a frame refresh
local function Frame_ScheduleRefresh(self)
	self.needRefresh = 1
end

local function Frame_GetScrollOffset(self)
	return self.slider:GetValue()
end

local function Frame_SetScrollOffset(self, offset)
	if type(offset) == "number" then
		self.slider:SetValue(offset)
		return Frame_GetScrollOffset(self)
	end
end

local function ListButton_OnEnter(self, motion)
	local parent = self:GetParent()
	Frame_TextureButton(parent, "highlightTexture", self)
	SafeCall(parent.OnButtonEnter, parent, self, self.data, motion)
	if type(parent.OnButtonTooltip) == "function" then
		GameTooltip:SetOwner(self, "ANCHOR_LEFT")
		GameTooltip:ClearLines()
		parent:OnButtonTooltip(self, self.data)
		GameTooltip:Show()
	end
end

local function ListButton_OnLeave(self, motion)
	local parent = self:GetParent()
	Frame_TextureButton(parent, "highlightTexture")
	if parent.OnButtonTooltip then
		GameTooltip:Hide()
	end
	SafeCall(parent.OnButtonLeave, parent, self, self.data, motion)
end

local function ListButton_OnClick(self, flag, down)
	local parent = self:GetParent()
	if flag == "LeftButton" and parent.selectable then
		local dataIndex = self:GetID() + Frame_GetScrollOffset(parent)
		if parent.selection ~= dataIndex then
			Frame_TextureButton(self:GetParent(), "checkedTexture", self)
			parent.selection = dataIndex
			SafeCall(parent.OnSelectionChanged, parent, dataIndex, self.data)
		end
	end
	SafeCall(parent.OnButtonClick, parent, self, self.data, flag, down)
end

-- Get the list button which is currently displaying the given data
local function Frame_PositionToButton(self, position)
	return self.listButtons[position - Frame_GetScrollOffset(self)]
end

local function Frame_UpdateButtonData(self, position)
	local button = Frame_PositionToButton(self, position)
	if button then
		button.data = DecodeData(self.listData[position])
		SafeCall(self.OnButtonUpdate, self, button, button.data)
	end
end

-- Create a list button
local function Frame_CreateListButton(self, id)
	local button = CreateFrame("Button", self:GetName().."Button"..id, self, self.buttonTemplate)
	button:SetID(id)

	if button:GetHeight() == 0 then
		button:SetHeight(20)
	end

	local prev = self.listButtons[id - 1]
	if prev then
		button:SetPoint("TOPLEFT", prev, "BOTTOMLEFT")
		button:SetPoint("TOPRIGHT", prev, "BOTTOMRIGHT")
	else
		button:SetPoint("TOPLEFT")
		button:SetPoint("TOPRIGHT", self.scrollBar, "TOPLEFT")
	end

	tinsert(self.listButtons, button)
	SafeCall(self.OnButtonCreated, self, button, id)

	button:HookScript("OnEnter", ListButton_OnEnter)
	button:HookScript("OnLeave", ListButton_OnLeave)
	button:HookScript("OnClick", ListButton_OnClick)

	return button
end

local function Frame_CheckVisible(self, position)
	if not position then
		position = self.selection
	end

	if type(position) ~= "number" or not self.listData[position] then
		return
	end

	local low = Frame_GetScrollOffset(self) + 1
	local high = low + self.maxButtons - 1

	if position < low then
		return position - low
	elseif position > high then
		return position - high
	else
		return 0
	end
end

local function Frame_EnsureVisible(self, position)
	local visible = Frame_CheckVisible(self, position)
	if visible then
		if visible ~= 0 then
			Frame_SetScrollOffset(self, Frame_GetScrollOffset(self) + visible)
		end
		return 1
	end
end

-- Update list buttons' contents, gives the user a chance to re-paint buttons
local function Frame_UpdateList(self)
	local offset = self.slider:GetValue()
	local i, checkedButton
	for i = 1, self.maxButtons do
		local button = self.listButtons[i]
		local dataIndex = i + offset
		local data = self.listData[dataIndex]
		if data ~= nil then
			if not button then
				button = Frame_CreateListButton(self, i)
			end

			if not checkedButton and self.selection == dataIndex then
				checkedButton = button
			end

			button.data = DecodeData(data)
			SafeCall(self.OnButtonUpdate, self, button, button.data)
			button:Hide()
			button:Show()
		elseif button then
			button.data = nil
			button:Hide()
		end
	end
	Frame_TextureButton(self, "checkedTexture", checkedButton)
end

-- Refresh list contents including scroll bar stats after the list frame is resized or data are inserted/removed
local function Frame_RefreshContents(self)
	self.needRefresh = nil
	local scrollBar = self.scrollBar
	local slider = self.slider
	local maxButtons = self.maxButtons
	local dataCount = #(self.listData)
	local range = max(0, dataCount - maxButtons)

	if range > 0 then
		slider.thumb:SetHeight(max(6, slider:GetHeight() * (maxButtons / dataCount)))
		scrollBar:SetWidth(14)
		scrollBar:Show()
	else
		scrollBar:Hide()
		scrollBar:SetWidth(1)
	end

	slider:SetMinMaxValues(0, range)
	if slider:GetValue() > range then
		slider:SetValue(range)
	else
		Frame_UpdateList(self)
	end
end

local function Slider_OnValueChanged(self, value)
	Frame_UpdateList(self.listFrame)
end

local function Frame_GetDataCount(self)
	return #(self.listData)
end

local function Frame_FindData(self, data, compareFunc)
	compareFunc = type(compareFunc) == "function" and compareFunc
	local i, stored
	for i, stored in ipairs(self.listData) do
		local d = DecodeData(stored)
		if compareFunc then
			if compareFunc(d, data) then
				return i
			end
		else
			if d == data then
				return i
			end
		end
	end
end

local function Frame_GetData(self, position)
	return DecodeData(self.listData[position])
end

local function Frame_SetData(self, position, data)
	if self.listData[position] then
		self.listData[position] = EncodeData(data)
		Frame_UpdateButtonData(self, position)
		return 1
	end
end

local function Frame_InsertData(self, data, position)
	local limit = #(self.listData) + 1
	position = type(position) == "number" and min(limit, max(1, floor(position))) or limit
	tinsert(self.listData, position, EncodeData(data))

	if self.selection and self.selection >= position then
		self.selection = self.selection + 1
		SafeCall(self.OnSelectionChanged, self, self.selection, Frame_GetData(self, self.selection))
	end

	Frame_ScheduleRefresh(self)
	return position
end

local function Frame_RemoveData(self, position)
	local data
	if type(position) == "number" then
		data = tremove(self.listData, position)
	else
		position = #(self.listData)
		data = tremove(self.listData)
	end

	if not data then
		return
	end

	if self.selection and self.selection >= position then
		if self.selection == position then
			self.selection = nil
		else
			self.selection = self.selection - 1
		end
		SafeCall(self.OnSelectionChanged, self, self.selection, Frame_GetData(self, self.selection))
	end

	Frame_ScheduleRefresh(self)
	return DecodeData(data)
end

local function Frame_ShiftData(self, position1, position2)
	if type(position1) ~= "number" or type(position2) ~= "number" or position1 == position2 then
		return
	end

	if not self.listData[position1] or not self.listData[position2] then
		return
	end

	tinsert(self.listData, position2, tremove(self.listData, position1))

	if self.selection then
		local selection = self.selection
		if selection == position1 then
			selection = position2
		elseif position1 < position2 then
			if selection > position1 and selection <= position2 then
				selection = selection - 1
			end
		elseif position1 > position2 then
			if selection >= position2 and selection < position1 then
				selection = selection + 1
			end
		end

		if self.selection ~= selection then
			self.selection = selection
			SafeCall(self.OnSelectionChanged, self, selection, Frame_GetData(self, selection))
		end
	end

	Frame_UpdateList(self)
	return 1
end

local function Frame_SwapData(self, position1, position2)
	if type(position1) ~= "number" or type(position2) ~= "number" or position1 == position2 then
		return
	end

	local data1 = self.listData[position1]
	local data2 = self.listData[position2]
	if data1 and data2 then
		self.listData[position1] = data2
		self.listData[position2] = data1

		Frame_UpdateButtonData(self, position1)
		Frame_UpdateButtonData(self, position2)

		if self.selection == position1 then
			Frame_SetSelection(self, position2)
		elseif self.selection == position2 then
			Frame_SetSelection(self, position1)
		end

		return 1
	end
end

local function Frame_UpdateData(self, position)
	if type(self.OnButtonUpdate) ~= "function" then
		return
	end

	local data = self.listData[position]
	if not data then
		return
	end

	local low = Frame_GetScrollOffset(self) + 1
	local high = low + self.maxButtons - 1
	if position < low or position > high then
		return
	end

	local button = Frame_PositionToButton(self, position)
	if button then
		self:OnButtonUpdate(button, DecodeData(data))
		return 1
	end
end

local function Frame_Clear(self)
	wipe(self.listData)
	Frame_SetScrollOffset(self, 0)
	if self.selection then
		self.selection = nil
		SafeCall(self.OnSelectionChanged, self)
	end
	Frame_RefreshContents(self)
end

local function Frame_GetSelection(self)
	if self.selection then
		local data = self.listData[self.selection]
		return self.selection, DecodeData(data)
	end
end

local function Frame_SetSelection(self, position)
	if not self.selectable or type(position) ~= "number" or not self.listData[position] then
		return
	end

	if self.selection ~= position then
		self.selection = position
		Frame_TextureButton(self, "checkedTexture", Frame_PositionToButton(self, position))
		SafeCall(self.OnSelectionChanged, self, position, Frame_GetData(self, position))
	end
	return 1
end

local function Frame_OnMouseWheel(self, value)
	local slider = self.slider
	local _, range = slider:GetMinMaxValues()
	if range > 0 then
		slider:SetValue(slider:GetValue() - max(1, range / 10) * value)
	end
end

local function Frame_OnUpdate(self)
	if self.needRefresh then
		Frame_RefreshContents(self)
	end
end

-- Create the scroll list frame
function UICreateVirtualScrollList(name, parent, maxButtons, selectable, buttonTemplate)
	if type(name) ~= "string" then
		error(format("bad argument #1 to 'UICreateVirtualScrollList' (string expected, got %s)", type(name)))
		return
	end

	local frame = CreateFrame("Frame", name, parent)
	if not frame then
		error("'UICreateVirtualScrollList' frame creation failed, check name and parent")
		return
	end

	frame:EnableMouseWheel(true)
	frame.maxButtons = type(maxButtons) == "number" and max(1, floor(maxButtons)) or 1
	frame.selectable = selectable
	frame.buttonTemplate = type(buttonTemplate) == "string" and buttonTemplate or nil
	frame.listButtons = {}
	frame.listData = {}

	local scrollBar = CreateFrame("Frame", name.."ScrollBar", frame)
	frame.scrollBar = scrollBar
	scrollBar:Hide()
	scrollBar:SetWidth(1)
	scrollBar:SetPoint("TOPRIGHT")
	scrollBar:SetPoint("BOTTOMRIGHT")
	ScrollBar_CreateScrollButton(scrollBar, "Up")
	ScrollBar_CreateScrollButton(scrollBar, "Down")

	local slider = CreateFrame("Slider", scrollBar:GetName().."Slider", scrollBar)
	frame.slider = slider
	scrollBar.slider = slider
	slider.listFrame = frame
	slider:SetValueStep(1)
	slider:SetWidth(14)
	slider:SetPoint("TOP", scrollBar.Up, "BOTTOM", 0, -1)
	slider:SetPoint("BOTTOM", scrollBar.Down, "TOP", 0, 1)
	slider:SetMinMaxValues(0, 0)
	slider:SetValue(0)
	hooksecurefunc(slider, "SetMinMaxValues", Slider_UpdateSliderButtons)
	hooksecurefunc(slider, "SetValue", Slider_UpdateSliderButtons)

	local thumb = slider:CreateTexture(name.."SliderThumbTexture", "OVERLAY")
	slider.thumb = thumb
	thumb:SetTexture("Interface\\ChatFrame\\ChatFrameBackground")
	thumb:SetWidth(slider:GetWidth())
	thumb:SetGradient("HORIZONTAL", CreateColor(0.5, 0.5, 0.5, 0.75), CreateColor(0.15, 0.15, 0.15, 1))
	slider:SetThumbTexture(thumb)

	slider:SetScript("OnValueChanged", Slider_OnValueChanged)

	frame.highlightTexture = frame:CreateTexture(name.."HighlightTexture", "BORDER")
	frame.highlightTexture:Hide()
	frame.highlightTexture:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight")
	frame.highlightTexture:SetBlendMode("ADD")
	frame.highlightTexture:SetVertexColor(1, 1, 1, 0.7)

	if selectable then
		frame.checkedTexture = frame:CreateTexture(name.."CheckedTexture", "BORDER")
		frame.checkedTexture:Hide()
		frame.checkedTexture:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight")
		frame.checkedTexture:SetBlendMode("ADD")
		frame.checkedTexture:SetVertexColor(1, 1, 1, 0.7)
	end

	frame:SetScript("OnShow", Frame_RefreshContents)
	frame:SetScript("OnSizeChanged", Frame_RefreshContents)
	frame:SetScript("OnMouseWheel", Frame_OnMouseWheel)
	frame:SetScript("OnUpdate", Frame_OnUpdate)
	frame.needRefresh = 1

	-- Public API
	frame.GetSelection = Frame_GetSelection
	frame.SetSelection = Frame_SetSelection
	frame.GetScrollOffset = Frame_GetScrollOffset
	frame.SetScrollOffset = Frame_SetScrollOffset
	frame.CheckVisible = Frame_CheckVisible
	frame.EnsureVisible = Frame_EnsureVisible
	frame.GetDataCount = Frame_GetDataCount
	frame.GetData = Frame_GetData
	frame.SetData = Frame_SetData
	frame.FindData = Frame_FindData
	frame.InsertData = Frame_InsertData
	frame.RemoveData = Frame_RemoveData
	frame.ShiftData = Frame_ShiftData
	frame.SwapData = Frame_SwapData
	frame.UpdateData = Frame_UpdateData
	frame.Clear = Frame_Clear
	frame.RefreshContents = Frame_RefreshContents
	return frame
end

-- Provides version check
function UICreateVirtualScrollList_IsNewerVersion(major, minor)
	if type(major) ~= "number" or type(minor) ~= "number" then
		return false
	end

	if major > MAJOR_VERSION then
		return true
	elseif major < MAJOR_VERSION then
		return false
	else -- major equal, check minor
		return minor > MINOR_VERSION
	end
end