--[[--------------------------------------------------------------------------
	Boom - Modern Healing & Support Raid Frames
	Options/Widgets.lua

	The widget toolkit the whole settings UI is built from.

	Boom ships no art and no libraries, so every control here is assembled from
	plain frames and solid-colour textures through Boom.Skin. That is not a
	limitation - it is the reason the options window and the raid frames look
	like one product, and why a colour change in Skin.colors propagates
	everywhere at once.

	Every widget obeys the same contract:

	  local cb = Boom.Widgets:CreateCheckBox(parent, { label = ..., path = ... })
	  cb:Refresh()          -- re-read the setting and repaint
	  cb:SetEnabled(false)  -- grey out and stop accepting input

	Value plumbing is either declarative (opts.path, read/written through
	Boom.Config so listeners fire) or imperative (opts.get / opts.set) - a panel
	may mix both freely. Widgets never touch Boom.profile directly.

	Widgets are tagged with .isBoomWidget so Widgets:RefreshAll(container) can
	walk an entire panel after a profile swap without the panel keeping lists.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local Widgets = Boom:NewModule("Widgets")

local L     = Boom.L
local Util  = Boom.Util
local Media = Boom.Media
local Skin  = Boom.Skin

local CreateFrame, UIParent = CreateFrame, UIParent
local GameTooltip = GameTooltip
local GetCursorPosition = GetCursorPosition
local floor, max, min = math.floor, math.max, math.min
local format = string.format
local type, tonumber, tostring = type, tonumber, tostring
local pairs, unpack, wipe = pairs, unpack, wipe
local setmetatable, pcall = setmetatable, pcall

--------------------------------------------------------------------------------
-- Metrics
--
-- One set of numbers for the whole settings UI. Panels never invent their own,
-- which is what keeps rows optically aligned across four different files.
--------------------------------------------------------------------------------

local WHITE = "Interface\\Buttons\\WHITE8X8"

local M = {
	rowHeight     = 22,   -- a single-line control (checkbox, colour swatch)
	controlHeight = 24,   -- boxed controls (dropdown button, edit box)
	labelGap      = 4,    -- between a label and the control underneath it
	width         = 220,  -- default widget width
	boxSize       = 16,   -- checkbox square
	popupRow      = 20,   -- dropdown list row
	popupMaxRows  = 12,   -- before the dropdown starts scrolling
	tabHeight     = 30,
	listRow       = 28,
	scrollBarW    = 6,
	scrollStep    = 28,
}
Widgets.METRICS = M

--------------------------------------------------------------------------------
-- Accent colour registry
--
-- The accent is user configurable, so anything painted with it registers here
-- and gets repainted on BOOM_SKIN_REFRESH. Weak keys mean a discarded widget
-- does not keep its textures alive.
--------------------------------------------------------------------------------

local accentUsers = setmetatable({}, { __mode = "k" })

local function applyAccent(obj, alpha)
	local r, g, b = Skin:Accent()
	if obj.GetObjectType and obj:GetObjectType() == "FontString" then
		obj:SetTextColor(r, g, b, alpha or 1)
	elseif obj.SetVertexColor then
		obj:SetVertexColor(r, g, b, alpha or 1)
	end
end

local function accentUse(obj, alpha)
	if not obj then return obj end
	accentUsers[obj] = alpha or 1
	applyAccent(obj, alpha)
	return obj
end

--------------------------------------------------------------------------------
-- Small drawing helpers
--------------------------------------------------------------------------------

--- Options text deliberately uses the client default font instead of the
--- profile font: the user is allowed to pick a condensed pixel face for raid
--- frames, and the settings window still has to be legible afterwards.
local function fontString(parent, size, justify, color)
	local fs = parent:CreateFontString(nil, "OVERLAY")
	Util.SetFont(fs, Media:Font("Boom Default"), size or 12, "NONE")
	fs:SetTextColor(Util.Unpack(color or Skin.colors.text))
	fs:SetJustifyH(justify or "LEFT")
	fs:SetWordWrap(false)
	return fs
end

local function setFill(frame, color, alphaOverride)
	if frame.boomFill then
		local r, g, b, a = Util.Unpack(color)
		frame.boomFill:SetVertexColor(r, g, b, alphaOverride or a)
	end
end

local function setBorder(frame, color)
	if frame.boomBorder then
		frame.boomBorder:SetColor(Util.Unpack(color))
	end
end

local function setAccentBorder(frame)
	if frame.boomBorder then
		frame.boomBorder:SetColor(Skin:Accent())
	end
end

local function plainTexture(parent, layer, color)
	local tex = parent:CreateTexture(nil, layer or "ARTWORK")
	tex:SetTexture(WHITE)
	if color then tex:SetVertexColor(Util.Unpack(color)) end
	return tex
end

--- A downward chevron built from four 1px bars.
--- Drawn rather than fetched so it can never depend on a Blizzard art file that
--- a future patch renames, and it stays pixel-crisp at every UI scale.
local function createCaret(parent)
	local caret = CreateFrame("Frame", nil, parent)
	caret:SetSize(7, 4)
	caret.rows = {}

	local widths = { 7, 5, 3, 1 }
	for i = 1, #widths do
		local tex = plainTexture(caret, "OVERLAY")
		tex:SetSize(widths[i], 1)
		tex:SetPoint("TOP", caret, "TOP", 0, -(i - 1))
		caret.rows[i] = tex
	end

	function caret:SetColor(r, g, b, a)
		for i = 1, #self.rows do
			self.rows[i]:SetVertexColor(r, g, b, a or 1)
		end
	end

	caret:SetColor(Util.Unpack(Skin.colors.textDim))
	return caret
end

--------------------------------------------------------------------------------
-- Tooltips
--------------------------------------------------------------------------------

local function tooltipShow(self)
	local title, text = self.boomTipTitle, self.boomTipText
	if not title and not text then return end

	GameTooltip:SetOwner(self, "ANCHOR_RIGHT", 10, 0)
	if title then
		GameTooltip:SetText(title, 1, 1, 1)
	end
	if text then
		local r, g, b = unpack(Skin.colors.textDim)
		GameTooltip:AddLine(text, r, g, b, true)
	end
	GameTooltip:Show()
end

local function tooltipHide()
	GameTooltip:Hide()
end

--- Attach a hover tooltip. Always call this AFTER the widget's own
--- OnEnter/OnLeave are installed - it hooks, it does not replace.
function Widgets:AttachTooltip(frame, title, text)
	if not frame or (not title and not text) then return end

	frame.boomTipTitle = title
	frame.boomTipText  = text

	if frame.boomTipHooked then return end
	frame.boomTipHooked = true

	if frame.EnableMouse then frame:EnableMouse(true) end
	frame:HookScript("OnEnter", tooltipShow)
	frame:HookScript("OnLeave", tooltipHide)
end

--------------------------------------------------------------------------------
-- Value plumbing
--------------------------------------------------------------------------------

local function bind(widget, opts)
	widget.isBoomWidget = true
	widget.path     = opts.path
	widget.getter   = opts.get
	widget.setter   = opts.set
	widget.onChange = opts.onChange
	widget.fallback = opts.default
end

local function readValue(widget)
	if widget.getter then
		local ok, value = pcall(widget.getter, widget)
		if ok then return value end
		Boom:Debug("widget getter failed for", tostring(widget.path))
		return widget.fallback
	end

	if widget.path and Boom.Config and Boom.profile then
		local value = Boom.Config:Get(widget.path)
		if value ~= nil then return value end
	end

	return widget.fallback
end

local function writeValue(widget, value)
	if widget.setter then
		local ok, err = pcall(widget.setter, value, widget)
		if not ok then Boom:ReportError("a settings control", err) end
	elseif widget.path and Boom.Config and Boom.profile then
		Boom.Config:Set(widget.path, value)
	end

	if widget.onChange then
		local ok, err = pcall(widget.onChange, value, widget)
		if not ok then Boom:ReportError("a settings control", err) end
	end
end

--- Give a widget SetEnabled/SetDisabled that dims it and stops its interactive
--- pieces from reacting. `parts` are the frames that actually take input.
local function installEnable(widget, parts)
	widget.disabled = false

	function widget:SetEnabled(enabled)
		enabled = enabled and true or false
		self.disabled = not enabled
		self:SetAlpha(enabled and 1 or 0.4)

		for i = 1, #parts do
			local part = parts[i]
			if part.EnableMouse then part:EnableMouse(enabled) end
			if part.EnableMouseWheel then part:EnableMouseWheel(enabled) end
		end

		if self.Refresh then self:Refresh() end
	end

	function widget:SetDisabled(disabled)
		self:SetEnabled(not disabled)
	end
end

--------------------------------------------------------------------------------
-- Heading
--------------------------------------------------------------------------------

--- A section title with a rule running out to the right edge. The rule is what
--- makes a long options page scan as groups instead of one wall of controls.
function Widgets:CreateHeading(parent, text)
	parent = parent or UIParent

	local heading = CreateFrame("Frame", nil, parent)
	local width = parent:GetWidth()
	heading:SetSize((width and width > 0) and width or M.width, 26)

	local label = fontString(heading, 13, "LEFT")
	label:SetPoint("BOTTOMLEFT", 0, 7)
	label:SetText(text or "")
	accentUse(label, 1)

	local line = Skin:CreateSeparator(heading, true)
	line:SetPoint("LEFT", label, "RIGHT", 8, 0)
	line:SetPoint("RIGHT", heading, "RIGHT", 0, 0)

	heading.isBoomWidget = true
	heading.label = label
	heading.line  = line

	function heading:SetText(newText)
		self.label:SetText(newText or "")
	end

	function heading:Refresh() end

	return heading
end

--------------------------------------------------------------------------------
-- Button
--------------------------------------------------------------------------------

local function buttonPaint(btn)
	if btn.disabled then
		setFill(btn, Skin.colors.bgRaised, 0.5)
		setBorder(btn, Skin.colors.border)
		btn.label:SetTextColor(Util.Unpack(Skin.colors.textFaint))
		return
	end

	if btn.primary then
		-- Primary buttons are a solid accent slab with dark text: exactly one
		-- of these per panel, so the eye knows where the commit action is.
		local r, g, b = Skin:Accent()
		if btn.boomFill then
			btn.boomFill:SetVertexColor(r, g, b, btn.hovered and 1 or 0.88)
		end
		setAccentBorder(btn)
		btn.label:SetTextColor(0.04, 0.05, 0.07, 1)
	else
		setFill(btn, btn.hovered and Skin.colors.bgHover or Skin.colors.bgRaised)
		setBorder(btn, btn.hovered and Skin.colors.borderLit or Skin.colors.border)
		btn.label:SetTextColor(Util.Unpack(Skin.colors.text))
	end
end

function Widgets:CreateButton(parent, opts)
	opts = opts or {}
	parent = parent or UIParent

	local btn = CreateFrame("Button", nil, parent)
	btn:SetSize(opts.width or 120, opts.height or M.controlHeight)
	btn:RegisterForClicks("LeftButtonUp")

	Skin:CreateBackdrop(btn, { color = Skin.colors.bgRaised, borderColor = Skin.colors.border })

	-- LEFT+RIGHT (not CENTER) so the string spans the button and centres its own
	-- text; that leaves the vertical offset free for the press animation.
	local label = fontString(btn, opts.fontSize or 12, "CENTER")
	label:SetPoint("LEFT", 6, 0)
	label:SetPoint("RIGHT", -6, 0)
	label:SetText(opts.label or "")

	local function labelOffset(dy)
		label:SetPoint("LEFT", 6, dy)
		label:SetPoint("RIGHT", -6, dy)
	end

	btn.isBoomWidget = true
	btn.label   = label
	btn.primary = opts.primary and true or false
	btn.onClick = opts.onClick

	btn:SetScript("OnEnter", function(self)
		self.hovered = true
		buttonPaint(self)
	end)
	btn:SetScript("OnLeave", function(self)
		self.hovered = false
		buttonPaint(self)
	end)
	btn:SetScript("OnMouseDown", function(self)
		if self.disabled then return end
		labelOffset(-1)
	end)
	btn:SetScript("OnMouseUp", function()
		labelOffset(0)
	end)
	btn:SetScript("OnClick", function(self)
		if self.disabled then return end
		if PlaySound and SOUNDKIT then PlaySound(SOUNDKIT.IG_MAINMENU_OPTION) end
		if self.onClick then
			-- Reported, never swallowed: a button that silently does nothing is
			-- the single most confusing thing an options window can do.
			local ok, err = pcall(self.onClick, self)
			if not ok then
				Boom:ReportError("button '" .. tostring(self.label and self.label:GetText() or "?") .. "'", err)
			end
		end
	end)

	function btn:SetLabel(text)
		self.label:SetText(text or "")
	end

	function btn:SetPrimary(isPrimary)
		self.primary = isPrimary and true or false
		buttonPaint(self)
	end

	function btn:Refresh()
		buttonPaint(self)
	end

	installEnable(btn, { btn })
	buttonPaint(btn)

	Widgets:AttachTooltip(btn, opts.label, opts.tooltip)
	return btn
end

--------------------------------------------------------------------------------
-- Check box
--------------------------------------------------------------------------------

function Widgets:CreateCheckBox(parent, opts)
	opts = opts or {}
	parent = parent or UIParent

	local cb = CreateFrame("Button", nil, parent)
	cb:SetSize(opts.width or M.width, M.rowHeight)
	cb:RegisterForClicks("LeftButtonUp")
	bind(cb, opts)

	local box = CreateFrame("Frame", nil, cb)
	box:SetSize(M.boxSize, M.boxSize)
	box:SetPoint("LEFT", 0, 0)
	Skin:CreateBackdrop(box, { color = Skin.colors.bgRaised, borderColor = Skin.colors.border })

	-- A filled accent square instead of a check glyph: no font can fail to
	-- render it, and it reads at a glance on a dense options page.
	local mark = plainTexture(box, "ARTWORK")
	mark:SetPoint("TOPLEFT", 3, -3)
	mark:SetPoint("BOTTOMRIGHT", -3, 3)
	mark:Hide()
	accentUse(mark, 1)

	local label = fontString(cb, 12, "LEFT")
	label:SetPoint("LEFT", box, "RIGHT", 8, 0)
	label:SetPoint("RIGHT", cb, "RIGHT", 0, 0)
	label:SetText(opts.label or "")

	cb.box   = box
	cb.mark  = mark
	cb.label = label

	function cb:GetChecked()
		return self.checked and true or false
	end

	function cb:SetChecked(checked, fire)
		checked = checked and true or false
		self.checked = checked
		if checked then mark:Show() else mark:Hide() end

		if self.disabled then
			label:SetTextColor(Util.Unpack(Skin.colors.textFaint))
		elseif checked then
			label:SetTextColor(Util.Unpack(Skin.colors.text))
		else
			label:SetTextColor(Util.Unpack(Skin.colors.textDim))
		end

		if fire then writeValue(self, checked) end
	end

	function cb:Refresh()
		self:SetChecked(readValue(self))
	end

	cb:SetScript("OnEnter", function(self)
		if self.disabled then return end
		setBorder(self.box, Skin.colors.borderLit)
	end)
	cb:SetScript("OnLeave", function(self)
		setBorder(self.box, Skin.colors.border)
	end)
	cb:SetScript("OnClick", function(self)
		if self.disabled then return end
		local newValue = not self:GetChecked()
		if PlaySound and SOUNDKIT then
			PlaySound(newValue and SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON
			                  or SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_OFF)
		end
		self:SetChecked(newValue, true)
	end)

	installEnable(cb, { cb })
	cb:Refresh()

	Widgets:AttachTooltip(cb, opts.label, opts.tooltip)
	return cb
end

--------------------------------------------------------------------------------
-- Slider
--------------------------------------------------------------------------------

local function sliderSnap(sl, value)
	local step = sl.step
	if step and step > 0 then
		value = floor((value - sl.minValue) / step + 0.5) * step + sl.minValue
	end
	value = Util.Round(value, sl.decimals)
	return Util.Clamp(value, sl.minValue, sl.maxValue)
end

local function sliderFormat(sl, value)
	local text
	if sl.decimals and sl.decimals > 0 then
		text = format("%." .. sl.decimals .. "f", value)
	else
		text = format("%d", floor(value + 0.5))
	end
	return sl.suffix and (text .. sl.suffix) or text
end

function Widgets:CreateSlider(parent, opts)
	opts = opts or {}
	parent = parent or UIParent

	local width = opts.width or M.width

	local sl = CreateFrame("Frame", nil, parent)
	sl:SetSize(width, 42)
	sl:EnableMouse(true)
	bind(sl, opts)

	sl.minValue = opts.min or 0
	sl.maxValue = opts.max or 100
	sl.step     = opts.step or 1
	sl.suffix   = opts.suffix
	sl.decimals = opts.decimals
		or (sl.step >= 1 and 0)
		or (sl.step >= 0.1 and 1)
		or 2

	local label = fontString(sl, 12, "LEFT")
	label:SetPoint("TOPLEFT", 0, 0)
	label:SetText(opts.label or "")
	label:SetTextColor(Util.Unpack(Skin.colors.textDim))

	-- The number doubles as a button: clicking it swaps in an edit box, which
	-- is the only sane way to hit an exact value on a 0.01-step slider.
	local valueBtn = CreateFrame("Button", nil, sl)
	valueBtn:SetSize(58, 16)
	valueBtn:SetPoint("TOPRIGHT", 0, 1)

	local valueText = fontString(valueBtn, 12, "RIGHT")
	valueText:SetAllPoints()
	accentUse(valueText, 1)

	local slider = CreateFrame("Slider", nil, sl)
	slider:SetOrientation("HORIZONTAL")
	slider:SetPoint("TOPLEFT", 0, -22)
	slider:SetPoint("TOPRIGHT", 0, -22)
	slider:SetHeight(14)
	slider:SetHitRectInsets(0, 0, -3, -3)
	slider:SetMinMaxValues(sl.minValue, sl.maxValue)
	slider:SetValueStep(sl.step)
	if slider.SetObeyStepOnDrag then slider:SetObeyStepOnDrag(true) end

	local track = plainTexture(slider, "BACKGROUND", Skin.colors.bgRaised)
	track:SetHeight(4)
	track:SetPoint("LEFT", 0, 0)
	track:SetPoint("RIGHT", 0, 0)

	local trackFill = plainTexture(slider, "ARTWORK")
	trackFill:SetHeight(4)
	trackFill:SetPoint("LEFT", track, "LEFT", 0, 0)
	accentUse(trackFill, 1)

	slider:SetThumbTexture(WHITE)
	local thumb = slider:GetThumbTexture()
	if thumb then
		thumb:SetSize(8, 14)
		accentUse(thumb, 1)
	end

	local function updateFill()
		local range = sl.maxValue - sl.minValue
		local trackWidth = slider:GetWidth()
		if range <= 0 or not trackWidth or trackWidth <= 0 then
			trackFill:Hide()
			return
		end

		local fraction = Util.Clamp((slider:GetValue() - sl.minValue) / range, 0, 1)
		if fraction <= 0 then
			trackFill:Hide()
		else
			trackFill:SetWidth(max(1, trackWidth * fraction))
			trackFill:Show()
		end
	end

	local edit = CreateFrame("EditBox", nil, sl)
	edit:SetSize(58, 18)
	edit:SetPoint("TOPRIGHT", 0, 2)
	edit:SetAutoFocus(false)
	edit:SetJustifyH("RIGHT")
	edit:SetTextInsets(4, 4, 0, 0)
	edit:Hide()
	if _G.GameFontHighlight then edit:SetFontObject(_G.GameFontHighlight) end
	Util.SetFont(edit, Media:Font("Boom Default"), 12, "NONE")
	edit:SetTextColor(Skin:Accent())
	Skin:CreateBackdrop(edit, { color = Skin.colors.bgDeep, borderColor = Skin.colors.borderLit })

	sl.label  = label
	sl.value  = valueText
	sl.slider = slider
	sl.editBox = edit

	function sl:GetValue()
		return self.current or self.minValue
	end

	function sl:SetValue(value, fire)
		if type(value) ~= "number" then value = self.minValue end
		value = sliderSnap(self, value)
		self.current = value

		self.suppress = true
		slider:SetValue(value)
		self.suppress = false

		valueText:SetText(sliderFormat(self, value))
		updateFill()

		if fire then writeValue(self, value) end
	end

	function sl:SetMinMax(newMin, newMax)
		self.minValue, self.maxValue = newMin, newMax
		slider:SetMinMaxValues(newMin, newMax)
		self:Refresh()
	end

	function sl:Refresh()
		local value = readValue(self)
		if type(value) ~= "number" then value = self.minValue end
		self:SetValue(value)
		label:SetTextColor(Util.Unpack(self.disabled and Skin.colors.textFaint or Skin.colors.textDim))
	end

	slider:SetScript("OnValueChanged", function(self, raw)
		if sl.suppress then return end
		local value = sliderSnap(sl, raw)
		sl.current = value
		valueText:SetText(sliderFormat(sl, value))
		updateFill()
		writeValue(sl, value)
	end)

	slider:SetScript("OnSizeChanged", updateFill)

	slider:EnableMouseWheel(true)
	slider:SetScript("OnMouseWheel", function(self, delta)
		if sl.disabled then return end
		sl:SetValue(sl:GetValue() + delta * sl.step, true)
	end)

	local function commitEdit()
		local typed = tonumber(edit:GetText())
		edit:Hide()
		valueBtn:Show()
		if typed then
			sl:SetValue(typed, true)
		end
	end

	valueBtn:SetScript("OnClick", function()
		if sl.disabled then return end
		valueBtn:Hide()
		edit:Show()
		edit:SetText(sliderFormat(sl, sl:GetValue()))
		edit:SetFocus()
		edit:HighlightText()
	end)
	valueBtn:SetScript("OnEnter", function()
		if sl.disabled then return end
		valueText:SetTextColor(Util.Unpack(Skin.colors.text))
	end)
	valueBtn:SetScript("OnLeave", function()
		applyAccent(valueText, 1)
	end)

	edit:SetScript("OnEnterPressed", function(self)
		self:ClearFocus()
	end)
	edit:SetScript("OnEscapePressed", function(self)
		self:SetText(sliderFormat(sl, sl:GetValue()))
		self:ClearFocus()
	end)
	edit:SetScript("OnEditFocusLost", commitEdit)

	installEnable(sl, { slider, valueBtn, edit })
	sl:Refresh()

	Widgets:AttachTooltip(sl, opts.label, opts.tooltip)
	Widgets:AttachTooltip(slider, opts.label, opts.tooltip)
	return sl
end

--------------------------------------------------------------------------------
-- Scroll frame
--
-- Hand-built because UIPanelScrollFrameTemplate drags Blizzard's chrome in with
-- it. The scroll bar overlays the right edge of the viewport and the content is
-- kept narrower by a fixed reserve, so nothing reflows when the bar appears.
--------------------------------------------------------------------------------

function Widgets:CreateScrollFrame(parent, opts)
	opts = opts or {}
	parent = parent or UIParent

	local reserve = opts.reserve or (M.scrollBarW + 8)

	local scroll = CreateFrame("ScrollFrame", nil, parent)
	scroll:SetSize(max(1, parent:GetWidth()), max(1, parent:GetHeight()))

	local content = CreateFrame("Frame", nil, scroll)
	content:SetSize(max(1, scroll:GetWidth() - reserve), 1)
	scroll:SetScrollChild(content)

	-- Parented to the outer frame: a ScrollFrame clips its scroll child, and a
	-- bar living inside that clip would vanish as soon as the user scrolls.
	local bar = CreateFrame("Frame", nil, parent)
	bar:SetWidth(M.scrollBarW)
	bar:SetPoint("TOPRIGHT", scroll, "TOPRIGHT", 0, 0)
	bar:SetPoint("BOTTOMRIGHT", scroll, "BOTTOMRIGHT", 0, 0)
	bar:SetFrameLevel(scroll:GetFrameLevel() + 5)
	bar:Hide()

	local track = plainTexture(bar, "BACKGROUND", Skin.colors.bgRaised)
	track:SetAllPoints()

	local thumb = CreateFrame("Button", nil, bar)
	thumb:SetWidth(M.scrollBarW)
	thumb:SetHeight(24)
	thumb:SetPoint("TOP", bar, "TOP", 0, 0)

	local thumbTex = plainTexture(thumb, "ARTWORK")
	thumbTex:SetAllPoints()
	accentUse(thumbTex, 0.55)

	local function updateThumb()
		local range = scroll:GetVerticalScrollRange() or 0
		local barHeight = bar:GetHeight()

		if range <= 0.5 or barHeight <= 0 then
			bar:Hide()
			return
		end
		bar:Show()

		local visible = scroll:GetHeight()
		local total = visible + range
		local size = Util.Clamp(total > 0 and (visible / total) * barHeight or barHeight, 20, barHeight)
		thumb:SetHeight(size)

		local fraction = Util.Clamp(scroll:GetVerticalScroll() / range, 0, 1)
		thumb:ClearAllPoints()
		thumb:SetPoint("TOP", bar, "TOP", 0, -fraction * (barHeight - size))
	end

	scroll.isBoomWidget = true
	scroll.content      = content
	scroll.scrollBar    = bar
	scroll.scrollThumb  = thumb
	scroll.reserve      = reserve

	--- Measure the tallest child/region and size the scroll child to match, so
	--- panels can just stack widgets and never track a running Y offset total.
	function scroll:AutoSizeContent(padding)
		local top = content:GetTop()
		if not top then return end

		local lowest = 0

		local children = { content:GetChildren() }
		for i = 1, #children do
			local child = children[i]
			if child:IsShown() then
				local bottom = child:GetBottom()
				if bottom then lowest = max(lowest, top - bottom) end
			end
		end

		local regions = { content:GetRegions() }
		for i = 1, #regions do
			local region = regions[i]
			if region.IsShown and region:IsShown() and region.GetBottom then
				local bottom = region:GetBottom()
				if bottom then lowest = max(lowest, top - bottom) end
			end
		end

		content:SetHeight(max(1, lowest + (padding or self.contentPadding or 12)))
	end

	function scroll:SetContentHeight(height)
		self.autoSize = false
		content:SetHeight(max(1, height or 1))
		self:Refresh()
	end

	function scroll:Refresh()
		if self.autoSize ~= false then self:AutoSizeContent() end
		self:UpdateScrollChildRect()

		local range = self:GetVerticalScrollRange() or 0
		if range <= 0.5 then
			self:SetVerticalScroll(0)
		elseif self:GetVerticalScroll() > range then
			self:SetVerticalScroll(range)
		end
		updateThumb()
	end

	function scroll:ScrollTo(offset)
		local range = self:GetVerticalScrollRange() or 0
		self:SetVerticalScroll(Util.Clamp(offset or 0, 0, range))
		updateThumb()
	end

	scroll:EnableMouseWheel(true)
	scroll:SetScript("OnMouseWheel", function(self, delta)
		local range = self:GetVerticalScrollRange() or 0
		if range > 0.5 then
			self:SetVerticalScroll(Util.Clamp(self:GetVerticalScroll() - delta * M.scrollStep, 0, range))
			updateThumb()
			return
		end

		-- Nothing to scroll here. There is no wheel propagation in the widget
		-- API, so a short inner list would otherwise swallow the wheel and leave
		-- a dead patch in the middle of a page that CAN scroll. Hand it up.
		local parent = self:GetParent()
		while parent do
			local handler = parent:IsMouseWheelEnabled() and parent:GetScript("OnMouseWheel")
			if handler then return handler(parent, delta) end
			parent = parent:GetParent()
		end
	end)

	scroll:SetScript("OnVerticalScroll", function() updateThumb() end)
	scroll:SetScript("OnScrollRangeChanged", function() updateThumb() end)
	scroll:SetScript("OnSizeChanged", function(self)
		content:SetWidth(max(1, self:GetWidth() - reserve))
		self:Refresh()
	end)

	thumb:SetScript("OnEnter", function() applyAccent(thumbTex, 0.85) end)
	thumb:SetScript("OnLeave", function() applyAccent(thumbTex, 0.55) end)
	thumb:SetScript("OnMouseDown", function(self)
		local _, y = GetCursorPosition()
		self.dragStartY = y / self:GetEffectiveScale()
		self.dragStartScroll = scroll:GetVerticalScroll()
		self.dragging = true
	end)
	thumb:SetScript("OnMouseUp", function(self)
		self.dragging = false
	end)
	thumb:SetScript("OnUpdate", function(self)
		if not self.dragging then return end

		local range = scroll:GetVerticalScrollRange() or 0
		local travel = bar:GetHeight() - self:GetHeight()
		if range <= 0 or travel <= 0 then return end

		local _, y = GetCursorPosition()
		y = y / self:GetEffectiveScale()

		local moved = (self.dragStartY - y) * (range / travel)
		scroll:SetVerticalScroll(Util.Clamp(self.dragStartScroll + moved, 0, range))
		updateThumb()
	end)

	return scroll
end

--------------------------------------------------------------------------------
-- Dropdown
--
-- Deliberately not UIDropDownMenu: that API has been rewritten twice in as many
-- expansions and taints easily. One shared popup serves every dropdown in the
-- addon - a button, a scrollable row list, close on pick or on click-away.
--------------------------------------------------------------------------------

local popup

local function popupRow(index)
	local row = popup.rows[index]
	if row then return row end

	row = CreateFrame("Button", nil, popup.scroll.content)
	row:SetHeight(M.popupRow)
	row:RegisterForClicks("LeftButtonUp")

	row.highlight = plainTexture(row, "BACKGROUND", Skin.colors.bgHover)
	row.highlight:SetAllPoints()
	row.highlight:Hide()

	row.mark = plainTexture(row, "ARTWORK")
	row.mark:SetSize(2, M.popupRow - 6)
	row.mark:SetPoint("LEFT", 0, 0)
	row.mark:Hide()
	accentUse(row.mark, 1)

	row.text = fontString(row, 12, "LEFT")
	row.text:SetPoint("LEFT", 10, 0)
	row.text:SetPoint("RIGHT", -8, 0)

	row:SetScript("OnEnter", function(self) self.highlight:Show() end)
	row:SetScript("OnLeave", function(self) self.highlight:Hide() end)
	row:SetScript("OnClick", function(self)
		local owner = popup.owner
		popup:Hide()
		if owner then owner:SetValue(self.entryValue, true) end
	end)

	popup.rows[index] = row
	return row
end

local function ensurePopup()
	if popup then return popup end

	popup = CreateFrame("Frame", nil, UIParent)
	popup:SetFrameStrata("FULLSCREEN_DIALOG")
	popup:SetToplevel(true)
	popup:SetClampedToScreen(true)
	popup:Hide()
	popup.rows = {}

	Skin:CreateBackdrop(popup, { color = Skin.colors.bgDeep, borderColor = Skin.colors.borderLit })
	popup.shadow = Skin:CreateShadow(popup, 4, 0.5)

	popup.scroll = Widgets:CreateScrollFrame(popup, { reserve = M.scrollBarW + 4 })
	popup.scroll:SetPoint("TOPLEFT", 4, -4)
	popup.scroll:SetPoint("BOTTOMRIGHT", -4, 4)

	-- Click-away. GLOBAL_MOUSE_DOWN fires before the owning button's OnClick,
	-- so re-clicking the button closes rather than instantly reopens.
	pcall(popup.RegisterEvent, popup, "GLOBAL_MOUSE_DOWN")
	popup:SetScript("OnEvent", function(self)
		if self:IsMouseOver() then return end
		if self.owner and self.owner.button and self.owner.button:IsMouseOver() then return end
		self:Hide()
	end)

	popup:SetScript("OnHide", function(self)
		if self.owner then
			self.owner.open = false
			if self.owner.Repaint then self.owner:Repaint() end
		end
		self.owner = nil
	end)

	return popup
end

local function dropdownPaint(dd)
	local button = dd.button
	if dd.disabled then
		setFill(button, Skin.colors.bgRaised, 0.5)
		setBorder(button, Skin.colors.border)
		dd.value:SetTextColor(Util.Unpack(Skin.colors.textFaint))
		dd.caret:SetColor(Util.Unpack(Skin.colors.textFaint))
		return
	end

	if dd.open or dd.hovered then
		setFill(button, Skin.colors.bgHover)
		setAccentBorder(button)
		dd.caret:SetColor(Skin:Accent())
	else
		setFill(button, Skin.colors.bgRaised)
		setBorder(button, Skin.colors.border)
		dd.caret:SetColor(Util.Unpack(Skin.colors.textDim))
	end
	dd.value:SetTextColor(Util.Unpack(Skin.colors.text))
end

local function openDropdown(dd)
	local pop = ensurePopup()

	if pop:IsShown() and pop.owner == dd then
		pop:Hide()
		return
	end

	local entries = dd:GetValues()
	local content = pop.scroll.content

	local width = max(dd.button:GetWidth(), 140)
	local visible = min(max(#entries, 1), M.popupMaxRows)
	local height = visible * M.popupRow + 8

	pop.owner = dd
	pop:ClearAllPoints()
	pop:SetSize(width, height)

	-- Flip above the button when there is not enough room underneath.
	local bottom = dd.button:GetBottom()
	if bottom and (bottom - height - 6) < 0 then
		pop:SetPoint("BOTTOMLEFT", dd.button, "TOPLEFT", 0, 2)
	else
		pop:SetPoint("TOPLEFT", dd.button, "BOTTOMLEFT", 0, -2)
	end

	local current = dd.selected
	for i = 1, #entries do
		local entry = entries[i]
		local row = popupRow(i)
		row:ClearAllPoints()
		row:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -(i - 1) * M.popupRow)
		row:SetPoint("RIGHT", content, "RIGHT", 0, 0)
		row.entryValue = entry.value
		row.text:SetText(entry.text or tostring(entry.value))

		if entry.value == current then
			row.mark:Show()
			applyAccent(row.text, 1)
		else
			row.mark:Hide()
			row.text:SetTextColor(Util.Unpack(Skin.colors.text))
		end

		row.highlight:Hide()
		row:Show()
	end

	for i = #entries + 1, #pop.rows do
		pop.rows[i]:Hide()
	end

	pop.scroll:SetContentHeight(#entries * M.popupRow)
	pop.scroll:ScrollTo(0)
	pop:Show()

	dd.open = true
	dropdownPaint(dd)
end

function Widgets:CreateDropdown(parent, opts)
	opts = opts or {}
	parent = parent or UIParent

	local width = opts.width or M.width
	local hasLabel = opts.label and opts.label ~= ""

	local dd = CreateFrame("Frame", nil, parent)
	dd:SetSize(width, hasLabel and (M.controlHeight + 16 + M.labelGap) or M.controlHeight)
	dd:EnableMouse(true)
	bind(dd, opts)

	dd.rawValues = opts.values or {}

	local label
	if hasLabel then
		label = fontString(dd, 12, "LEFT")
		label:SetPoint("TOPLEFT", 0, 0)
		label:SetText(opts.label)
		label:SetTextColor(Util.Unpack(Skin.colors.textDim))
	end

	local button = CreateFrame("Button", nil, dd)
	button:SetHeight(M.controlHeight)
	button:SetPoint("BOTTOMLEFT", 0, 0)
	button:SetPoint("BOTTOMRIGHT", 0, 0)
	button:RegisterForClicks("LeftButtonUp")
	Skin:CreateBackdrop(button, { color = Skin.colors.bgRaised, borderColor = Skin.colors.border })

	local caret = createCaret(button)
	caret:SetPoint("RIGHT", -8, 0)

	local valueText = fontString(button, 12, "LEFT")
	valueText:SetPoint("LEFT", 8, 0)
	valueText:SetPoint("RIGHT", caret, "LEFT", -6, 0)

	dd.label  = label
	dd.value  = valueText
	dd.button = button
	dd.caret  = caret

	function dd:GetValues()
		local values = self.rawValues
		if type(values) == "function" then
			local ok, resolved = pcall(values, self)
			values = ok and resolved or nil
		end
		if type(values) ~= "table" then return {} end

		-- Tolerate a plain string list as well as { value = , text = } entries.
		local out = {}
		for i = 1, #values do
			local entry = values[i]
			if type(entry) == "table" then
				out[#out + 1] = entry
			else
				out[#out + 1] = { value = entry, text = tostring(entry) }
			end
		end
		return out
	end

	function dd:SetValues(values)
		self.rawValues = values or {}
		if popup and popup:IsShown() and popup.owner == self then
			popup:Hide()
		end
		self:Refresh()
	end

	function dd:GetSelected()
		return self.selected
	end

	function dd:SetValue(value, fire)
		self.selected = value

		local text
		local entries = self:GetValues()
		for i = 1, #entries do
			if entries[i].value == value then
				text = entries[i].text
				break
			end
		end

		valueText:SetText(text or (value ~= nil and tostring(value)) or L["None"])
		if fire then writeValue(self, value) end
	end

	function dd:Repaint()
		dropdownPaint(self)
	end

	function dd:Refresh()
		self:SetValue(readValue(self))
		if label then
			label:SetTextColor(Util.Unpack(self.disabled and Skin.colors.textFaint or Skin.colors.textDim))
		end
		dropdownPaint(self)
	end

	button:SetScript("OnEnter", function()
		if dd.disabled then return end
		dd.hovered = true
		dropdownPaint(dd)
	end)
	button:SetScript("OnLeave", function()
		dd.hovered = false
		dropdownPaint(dd)
	end)
	button:SetScript("OnClick", function()
		if dd.disabled then return end
		openDropdown(dd)
	end)
	button:SetScript("OnHide", function()
		if popup and popup.owner == dd then popup:Hide() end
	end)

	installEnable(dd, { button })
	dd:Refresh()

	Widgets:AttachTooltip(dd, opts.label, opts.tooltip)
	Widgets:AttachTooltip(button, opts.label, opts.tooltip)
	return dd
end

--------------------------------------------------------------------------------
-- Edit box
--------------------------------------------------------------------------------

function Widgets:CreateEditBox(parent, opts)
	opts = opts or {}
	parent = parent or UIParent

	local width = opts.width or M.width
	local hasLabel = opts.label and opts.label ~= ""

	local eb = CreateFrame("Frame", nil, parent)
	eb:SetSize(width, hasLabel and (M.controlHeight + 16 + M.labelGap) or M.controlHeight)
	eb:EnableMouse(true)
	bind(eb, opts)

	eb.numeric = opts.numeric and true or false

	local label
	if hasLabel then
		label = fontString(eb, 12, "LEFT")
		label:SetPoint("TOPLEFT", 0, 0)
		label:SetText(opts.label)
		label:SetTextColor(Util.Unpack(Skin.colors.textDim))
	end

	local box = CreateFrame("EditBox", nil, eb)
	box:SetHeight(M.controlHeight)
	box:SetPoint("BOTTOMLEFT", 0, 0)
	box:SetPoint("BOTTOMRIGHT", 0, 0)
	box:SetAutoFocus(false)
	box:SetTextInsets(8, 8, 0, 0)
	box:SetMaxLetters(opts.maxLetters or 0)
	if _G.GameFontHighlight then box:SetFontObject(_G.GameFontHighlight) end
	Util.SetFont(box, Media:Font("Boom Default"), 12, "NONE")
	box:SetTextColor(Util.Unpack(Skin.colors.text))
	Skin:CreateBackdrop(box, { color = Skin.colors.bgDeep, borderColor = Skin.colors.border })

	-- Placeholder text lives on top of the box and hides the moment there is
	-- content, which is cheaper than clearing/restoring the real text.
	local hint = fontString(box, 12, "LEFT", Skin.colors.textFaint)
	hint:SetPoint("LEFT", 8, 0)
	hint:SetPoint("RIGHT", -8, 0)
	hint:SetText(opts.placeholder or "")
	hint:Hide()

	eb.label   = label
	eb.editBox = box
	eb.hint    = hint

	local function updateHint()
		if opts.placeholder and (box:GetText() or "") == "" and not box:HasFocus() then
			hint:Show()
		else
			hint:Hide()
		end
	end

	function eb:GetText()
		return box:GetText() or ""
	end

	function eb:SetText(text)
		box:SetText(text ~= nil and tostring(text) or "")
		box:SetCursorPosition(0)
		updateHint()
	end

	function eb:Commit()
		local text = box:GetText() or ""
		local value = text

		if self.numeric then
			value = tonumber(text)
			if value == nil then
				-- Refuse silently and snap back: an options field is no place
				-- for an error dialog.
				self:Refresh()
				return nil
			end
		end

		writeValue(self, value)
		return value
	end

	function eb:Refresh()
		-- Never overwrite a field the user is typing in. A bulk refresh can be
		-- triggered by anything (a profile switch, a preset, another control's
		-- onChange) and wiping half-typed text under the cursor is maddening.
		if box:HasFocus() then return end

		local value = readValue(self)
		box:SetText(value ~= nil and tostring(value) or "")
		box:SetCursorPosition(0)
		if label then
			label:SetTextColor(Util.Unpack(self.disabled and Skin.colors.textFaint or Skin.colors.textDim))
		end
		box:SetTextColor(Util.Unpack(self.disabled and Skin.colors.textFaint or Skin.colors.text))
		updateHint()
	end

	box:SetScript("OnEditFocusGained", function(self)
		setAccentBorder(self)
		hint:Hide()
	end)
	box:SetScript("OnEditFocusLost", function(self)
		setBorder(self, Skin.colors.border)
		self:HighlightText(0, 0)
		eb:Commit()
		updateHint()
	end)
	box:SetScript("OnTextChanged", function()
		updateHint()
	end)
	box:SetScript("OnEnterPressed", function(self)
		local value = eb:Commit()
		self:ClearFocus()
		if opts.onEnter then
			local ok, err = pcall(opts.onEnter, value, eb)
			if not ok then Boom:ReportError("a settings text field", err) end
		end
	end)
	box:SetScript("OnEscapePressed", function(self)
		eb:Refresh()
		self:ClearFocus()
	end)
	box:SetScript("OnEnter", function(self)
		if eb.disabled or self:HasFocus() then return end
		setBorder(self, Skin.colors.borderLit)
	end)
	box:SetScript("OnLeave", function(self)
		if self:HasFocus() then return end
		setBorder(self, Skin.colors.border)
	end)

	function eb:SetEnabled(enabled)
		enabled = enabled and true or false
		self.disabled = not enabled
		self:SetAlpha(enabled and 1 or 0.5)
		box:EnableMouse(enabled)
		-- EditBox gained SetEnabled late; Enable/Disable is the older spelling.
		if box.SetEnabled then
			box:SetEnabled(enabled)
		elseif enabled then
			box:Enable()
		else
			box:Disable()
		end
		self:Refresh()
	end

	function eb:SetDisabled(disabled)
		self:SetEnabled(not disabled)
	end

	eb:Refresh()

	Widgets:AttachTooltip(eb, opts.label, opts.tooltip)
	Widgets:AttachTooltip(box, opts.label, opts.tooltip)
	return eb
end

--------------------------------------------------------------------------------
-- Colour picker
--------------------------------------------------------------------------------

local function pickerAlpha()
	local frame = _G.ColorPickerFrame
	if frame and frame.GetColorAlpha then
		return frame:GetColorAlpha()
	end
	-- Pre-10.2 clients expressed the slider as opacity, i.e. inverted alpha.
	if _G.OpacitySliderFrame then
		return 1 - _G.OpacitySliderFrame:GetValue()
	end
	return 1
end

function Widgets:CreateColorPicker(parent, opts)
	opts = opts or {}
	parent = parent or UIParent

	local cp = CreateFrame("Button", nil, parent)
	cp:SetSize(opts.width or M.width, M.rowHeight)
	cp:RegisterForClicks("LeftButtonUp")
	bind(cp, opts)

	cp.hasAlpha = opts.hasAlpha and true or false

	local swatch = CreateFrame("Frame", nil, cp)
	swatch:SetSize(26, M.boxSize)
	swatch:SetPoint("LEFT", 0, 0)
	Skin:CreateBackdrop(swatch, { color = Skin.colors.bgDeep, borderColor = Skin.colors.border })

	local fill = swatch:CreateTexture(nil, "ARTWORK")
	fill:SetPoint("TOPLEFT", 2, -2)
	fill:SetPoint("BOTTOMRIGHT", -2, 2)
	fill:SetColorTexture(1, 1, 1, 1)

	local label = fontString(cp, 12, "LEFT")
	label:SetPoint("LEFT", swatch, "RIGHT", 8, 0)
	label:SetPoint("RIGHT", cp, "RIGHT", 0, 0)
	label:SetText(opts.label or "")

	cp.swatch = swatch
	cp.fill   = fill
	cp.label  = label

	function cp:GetColor()
		local color = readValue(self)
		if type(color) ~= "table" then return 1, 1, 1, 1 end
		return Util.Unpack(color, 1)
	end

	function cp:SetColor(r, g, b, a, fire)
		if not self.hasAlpha then a = 1 end
		fill:SetColorTexture(r, g, b, a or 1)
		if fire then
			writeValue(self, { r, g, b, a or 1 })
		end
	end

	function cp:Refresh()
		local r, g, b, a = self:GetColor()
		fill:SetColorTexture(r, g, b, a)
		label:SetTextColor(Util.Unpack(self.disabled and Skin.colors.textFaint or Skin.colors.text))

		local hex = Util.ToHex(r, g, b)
		local tip = opts.tooltip
		if self.hasAlpha then
			tip = (tip and tip .. "\n" or "") .. "#" .. hex .. "  " .. format("%d%%", floor(a * 100 + 0.5))
		elseif tip then
			tip = tip .. "\n#" .. hex
		else
			tip = "#" .. hex
		end
		Widgets:AttachTooltip(self, opts.label, tip)
	end

	cp:SetScript("OnEnter", function(self)
		if self.disabled then return end
		setBorder(swatch, Skin.colors.borderLit)
	end)
	cp:SetScript("OnLeave", function()
		setBorder(swatch, Skin.colors.border)
	end)

	cp:SetScript("OnClick", function(self)
		if self.disabled then return end

		local picker = _G.ColorPickerFrame
		if not picker then
			Boom:Debug("ColorPickerFrame missing")
			return
		end

		local oldR, oldG, oldB, oldA = self:GetColor()

		local function onUpdate()
			local r, g, b = picker:GetColorRGB()
			local a = self.hasAlpha and pickerAlpha() or 1
			self:SetColor(r, g, b, a, true)
		end

		local info = {
			r = oldR, g = oldG, b = oldB,
			hasOpacity = self.hasAlpha,
			opacity    = oldA,
			swatchFunc = onUpdate,
			opacityFunc = onUpdate,
			cancelFunc = function()
				self:SetColor(oldR, oldG, oldB, oldA, true)
			end,
		}

		if picker.SetupColorPickerAndShow then
			picker:SetupColorPickerAndShow(info)
		else
			-- Very old shape, kept so a downlevel client degrades instead of
			-- erroring out of the options window entirely.
			picker.func         = info.swatchFunc
			picker.opacityFunc  = info.opacityFunc
			picker.cancelFunc   = info.cancelFunc
			picker.hasOpacity   = info.hasOpacity
			picker.opacity      = info.opacity
			picker.previousValues = { r = oldR, g = oldG, b = oldB, opacity = oldA }
			picker:SetColorRGB(oldR, oldG, oldB)
			picker:Show()
		end
	end)

	installEnable(cp, { cp })
	cp:Refresh()

	Widgets:AttachTooltip(cp, opts.label, opts.tooltip)
	return cp
end

--------------------------------------------------------------------------------
-- Tab list (the vertical rail down the left of the settings window)
--------------------------------------------------------------------------------

local function tabPaint(tab)
	local selected = tab.selected

	if selected then
		setFill(tab, Skin.colors.bgRaised)
		tab.mark:Show()
		applyAccent(tab.label, 1)
		if tab.icon then tab.icon:SetAlpha(1) end
	elseif tab.hovered then
		setFill(tab, Skin.colors.bgHover, 0.7)
		tab.mark:Hide()
		tab.label:SetTextColor(Util.Unpack(Skin.colors.text))
		if tab.icon then tab.icon:SetAlpha(0.9) end
	else
		setFill(tab, Skin.colors.bg, 0)
		tab.mark:Hide()
		tab.label:SetTextColor(Util.Unpack(Skin.colors.textDim))
		if tab.icon then tab.icon:SetAlpha(0.6) end
	end
end

function Widgets:CreateTabList(parent, opts)
	opts = opts or {}
	parent = parent or UIParent

	local list = CreateFrame("Frame", nil, parent)
	list:SetSize(opts.width or 150, M.tabHeight)

	list.isBoomWidget = true
	list.buttons  = {}
	list.order    = {}
	list.onSelect = opts.onSelect

	local function createTab(index)
		local tab = list.buttons[index]
		if tab then return tab end

		tab = CreateFrame("Button", nil, list)
		tab:SetHeight(M.tabHeight)
		tab:SetPoint("TOPLEFT", list, "TOPLEFT", 0, -(index - 1) * (M.tabHeight + 2))
		tab:SetPoint("TOPRIGHT", list, "TOPRIGHT", 0, -(index - 1) * (M.tabHeight + 2))
		tab:RegisterForClicks("LeftButtonUp")
		Skin:CreateBackdrop(tab, { color = Skin.colors.bg, borderSize = 0 })

		tab.mark = plainTexture(tab, "ARTWORK")
		tab.mark:SetWidth(2)
		tab.mark:SetPoint("TOPLEFT", 0, 0)
		tab.mark:SetPoint("BOTTOMLEFT", 0, 0)
		tab.mark:Hide()
		accentUse(tab.mark, 1)

		tab.icon = tab:CreateTexture(nil, "ARTWORK")
		tab.icon:SetSize(16, 16)
		tab.icon:SetPoint("LEFT", 10, 0)
		tab.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		tab.icon:Hide()

		tab.label = fontString(tab, 12, "LEFT")
		tab.label:SetPoint("LEFT", 12, 0)
		tab.label:SetPoint("RIGHT", -8, 0)

		tab:SetScript("OnEnter", function(self)
			self.hovered = true
			tabPaint(self)
		end)
		tab:SetScript("OnLeave", function(self)
			self.hovered = false
			tabPaint(self)
		end)
		tab:SetScript("OnClick", function(self)
			list:SetSelected(self.key)
		end)

		list.buttons[index] = tab
		return tab
	end

	function list:SetEntries(entries)
		entries = entries or {}
		wipe(self.order)

		for i = 1, #entries do
			local entry = entries[i]
			local tab = createTab(i)

			tab.key   = entry.key
			tab.entry = entry
			tab.label:SetText(entry.label or entry.key or "")

			if entry.icon then
				tab.icon:SetTexture(entry.icon)
				tab.icon:Show()
				tab.label:SetPoint("LEFT", tab.icon, "RIGHT", 8, 0)
			else
				tab.icon:Hide()
				tab.label:SetPoint("LEFT", tab, "LEFT", 12, 0)
			end

			tab.selected = (entry.key == self.selectedKey)
			tab:Show()
			tabPaint(tab)

			self.order[i] = entry.key
		end

		for i = #entries + 1, #self.buttons do
			self.buttons[i]:Hide()
		end

		self:SetHeight(max(M.tabHeight, #entries * (M.tabHeight + 2) - 2))
	end

	function list:GetSelected()
		return self.selectedKey
	end

	function list:SetSelected(key, silent)
		self.selectedKey = key

		local target
		for i = 1, #self.buttons do
			local tab = self.buttons[i]
			tab.selected = (tab.key == key)
			if tab.selected then target = tab end
			tabPaint(tab)
		end

		if not silent and self.onSelect then
			local ok, err = pcall(self.onSelect, key, target and target.entry)
			if not ok then Boom:Debug("tab onSelect failed:", err) end
		end
	end

	function list:Refresh()
		for i = 1, #self.buttons do
			tabPaint(self.buttons[i])
		end
	end

	list:SetEntries(opts.entries)
	if opts.entries and opts.entries[1] then
		list:SetSelected(opts.entries[1].key, true)
	end

	return list
end

--------------------------------------------------------------------------------
-- List box
--------------------------------------------------------------------------------

local function listRowPaint(row)
	if row.selected then
		row.highlight:SetVertexColor(Util.Unpack(Skin.colors.bgHover))
		row.highlight:Show()
		row.mark:Show()
		applyAccent(row.text, 1)
	elseif row.hovered then
		local r, g, b = unpack(Skin.colors.bgHover)
		row.highlight:SetVertexColor(r, g, b, 0.6)
		row.highlight:Show()
		row.mark:Hide()
		row.text:SetTextColor(Util.Unpack(Skin.colors.text))
	else
		row.highlight:Hide()
		row.mark:Hide()
		row.text:SetTextColor(Util.Unpack(Skin.colors.text))
	end
end

function Widgets:CreateListBox(parent, opts)
	opts = opts or {}
	parent = parent or UIParent

	local rowHeight = opts.rowHeight or M.listRow
	-- Two-line rows only work once there is vertical room for them; below that
	-- the subtext moves to the right of the row instead of under the title.
	local stacked = rowHeight >= 32

	local box = CreateFrame("Frame", nil, parent)
	box:SetSize(opts.width or M.width, opts.height or 180)
	Skin:CreateBackdrop(box, { color = Skin.colors.bgDeep, borderColor = Skin.colors.border })

	local scroll = Widgets:CreateScrollFrame(box)
	scroll:SetPoint("TOPLEFT", 2, -2)
	scroll:SetPoint("BOTTOMRIGHT", -2, 2)

	box.isBoomWidget = true
	box.scroll   = scroll
	box.rows     = {}
	box.items    = {}
	box.onSelect = opts.onSelect

	local empty = fontString(box, 12, "CENTER", Skin.colors.textFaint)
	empty:SetPoint("CENTER", 0, 0)
	empty:SetText(opts.emptyText or L["None"])
	empty:Hide()
	box.emptyText = empty

	local function createRow(index)
		local row = box.rows[index]
		if row then return row end

		row = CreateFrame("Button", nil, scroll.content)
		row:SetHeight(rowHeight)
		row:RegisterForClicks("LeftButtonUp")

		row.highlight = plainTexture(row, "BACKGROUND", Skin.colors.bgHover)
		row.highlight:SetAllPoints()
		row.highlight:Hide()

		row.mark = plainTexture(row, "ARTWORK")
		row.mark:SetWidth(2)
		row.mark:SetPoint("TOPLEFT", 0, -1)
		row.mark:SetPoint("BOTTOMLEFT", 0, 1)
		row.mark:Hide()
		accentUse(row.mark, 1)

		row.icon = row:CreateTexture(nil, "ARTWORK")
		row.icon:SetSize(rowHeight - 8, rowHeight - 8)
		row.icon:SetPoint("LEFT", 6, 0)
		row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		row.icon:Hide()

		row.text = fontString(row, 12, "LEFT")
		row.subtext = fontString(row, 10, stacked and "LEFT" or "RIGHT", Skin.colors.textDim)

		row:SetScript("OnEnter", function(self)
			self.hovered = true
			listRowPaint(self)
		end)
		row:SetScript("OnLeave", function(self)
			self.hovered = false
			listRowPaint(self)
		end)
		row:SetScript("OnClick", function(self)
			box:SetSelected(self.key)
		end)

		box.rows[index] = row
		return row
	end

	function box:SetItems(items)
		self.items = items or {}

		local content = scroll.content
		for i = 1, #self.items do
			local item = self.items[i]
			local row = createRow(i)

			row:ClearAllPoints()
			row:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -(i - 1) * rowHeight)
			row:SetPoint("RIGHT", content, "RIGHT", 0, 0)

			row.key  = item.key
			row.item = item

			local textLeft = 8
			if item.icon then
				row.icon:SetTexture(item.icon)
				row.icon:Show()
				textLeft = rowHeight + 4
			else
				row.icon:Hide()
			end

			row.text:ClearAllPoints()
			row.subtext:ClearAllPoints()
			row.text:SetText(item.text or tostring(item.key or ""))
			row.subtext:SetText(item.subtext or "")

			if stacked and item.subtext then
				row.text:SetPoint("TOPLEFT", textLeft, -5)
				row.text:SetPoint("RIGHT", -8, 0)
				row.subtext:SetPoint("BOTTOMLEFT", textLeft, 5)
				row.subtext:SetPoint("RIGHT", -8, 0)
				row.subtext:Show()
			elseif item.subtext then
				row.text:SetPoint("LEFT", textLeft, 0)
				row.subtext:SetPoint("RIGHT", -8, 0)
				row.text:SetPoint("RIGHT", row.subtext, "LEFT", -6, 0)
				row.subtext:Show()
			else
				row.text:SetPoint("LEFT", textLeft, 0)
				row.text:SetPoint("RIGHT", -8, 0)
				row.subtext:Hide()
			end

			row.selected = (item.key == self.selected)
			row.hovered = false
			listRowPaint(row)
			row:Show()
		end

		for i = #self.items + 1, #self.rows do
			self.rows[i]:Hide()
		end

		scroll:SetContentHeight(#self.items * rowHeight)
		if #self.items == 0 then empty:Show() else empty:Hide() end
	end

	function box:GetItem(key)
		for i = 1, #self.items do
			if self.items[i].key == key then return self.items[i] end
		end
	end

	function box:GetSelected()
		return self.selected
	end

	function box:SetSelected(key, silent)
		self.selected = key

		for i = 1, #self.rows do
			local row = self.rows[i]
			row.selected = (row.key == key)
			listRowPaint(row)
		end

		if not silent and self.onSelect then
			local ok, err = pcall(self.onSelect, key, self:GetItem(key))
			if not ok then Boom:Debug("listbox onSelect failed:", err) end
		end
	end

	function box:Refresh()
		for i = 1, #self.rows do
			listRowPaint(self.rows[i])
		end
		scroll:Refresh()
	end

	box:SetItems(opts.items)
	return box
end

--------------------------------------------------------------------------------
-- Bulk refresh
--------------------------------------------------------------------------------

--- Walk a container and refresh every Boom widget under it. Panels therefore
--- never have to remember what they built - after a profile swap they call this
--- once and every control re-reads its own setting.
function Widgets:RefreshAll(frame)
	if not frame then return end

	if frame.isBoomWidget and frame.Refresh then
		local ok, err = pcall(frame.Refresh, frame)
		if not ok then Boom:Debug("widget refresh failed:", err) end
	end

	if not frame.GetChildren then return end

	local children = { frame:GetChildren() }
	for i = 1, #children do
		self:RefreshAll(children[i])
	end
end

--------------------------------------------------------------------------------
-- Lifecycle
--------------------------------------------------------------------------------

function Widgets:OnEnable()
	-- The accent colour is a setting; every widget that painted with it gets
	-- repainted rather than rebuilt.
	self:RegisterMessage("BOOM_SKIN_REFRESH", function()
		for obj, alpha in pairs(accentUsers) do
			applyAccent(obj, alpha)
		end
	end)

	-- A profile swap replaces every value behind every path at once.
	self:RegisterMessage("BOOM_PROFILE_CHANGED", function()
		if popup and popup:IsShown() then popup:Hide() end
	end)
end
