Improve pfUI dropdowns and texture previews

Enable bounded scrolling for ClassicAPI dropdowns, open menus near the selected entry, and lazily load texture previews for visible rows. Devirtualize menu scroll views to prevent invalid ranges and apply pfUI-style scrollbar theming.
This commit is contained in:
Mr_Rosh
2026-11-02 14:04:59 +02:00
parent 82febfa4fe
commit f0933ae751
+278 -157
View File
@@ -1,181 +1,302 @@
-- Add a bounded, scrollable viewport to pfUI's custom dropdown widget.
-- This lives in CustomMedia so the shared pfUI addon files stay untouched.
if not pfUI or type(pfUI.api) ~= "table" or type(pfUI.api.CreateDropDownButton) ~= "function" then return end
local originalCreateDropDownButton = pfUI.api.CreateDropDownButton
if pfUI.api.customMediaDropdownScrollable then return end
-- ClassicAPI dropdown integration for pfUI-CustomMedia. This lives here so the
-- shared pfUI files stay untouched.
--
-- pfUI (brues-code fork) builds its dropdowns with ClassicAPI's modern
-- DropdownButton widget (CreateFrame("DropdownButton", ..., "pfUIDropdownTemplate")
-- + SetupMenu), and that widget brings its own menu system with it:
-- * a menu only scrolls when its root description opts in through
-- SetScrollMode(maxHeightPixels), so long lists (bar textures, fonts,
-- buttons, gryphons) are one giant list otherwise;
-- * a menu always opens scrolled to its first row;
-- * the rows of a texture picker give no hint of what they pick.
--
-- All three are handled from here.
if not DropdownButtonMixin or type(DropdownButtonMixin.CreateDefaultRootMenuDescription) ~= "function" then
return end
if DropdownButtonMixin.customMediaDropdowns then return end
local ROW_HEIGHT = 20
local MENU_PADDING = 4
local MAX_VISIBLE_ROWS = 12
local SCROLLBAR_WIDTH = 6
local MAX_VISIBLE_ROWS = 14
local MIN_VISIBLE_ROWS = 4
local function SetupScrollableMenu(dropdown)
if dropdown.customMediaScroll then return end
-- Texture previews.
--
-- pfUI's texture pickers pass the texture path as the radio's `data`
-- (gui.lua: CreateRadio(text, IsSelected, SetSelected, value)), so the path is
-- known per row. It cannot simply be SetTexture'd from the row initializer,
-- though: ClassicAPI builds and initializes *every* row when it generates a
-- menu, virtualized or not, and the bar texture list alone is ~400 files and
-- 20 MB. 1.12 loads textures on the main thread, so that would freeze the
-- client on every open and push the whole folder into texture memory.
--
-- So the initializer only attaches an empty swatch and records its path, and
-- the path is loaded once the row is near the visible window.
local PREVIEW_LEFT = 18
local PREVIEW_RIGHT = 4
local PREVIEW_INSET = 3
local PREVIEW_ALPHA = 0.6
-- rows loaded past each edge of the window, so scrolling a line or two never
-- reaches a row that is still bare
local PREVIEW_MARGIN = 3
local menu = dropdown.menuframe
if not menu then return end
local function IsTexturePath(value)
if type(value) ~= "string" then return false end
local extension = value:lower():match("%.([%w_]+)$")
return extension == "tga" or extension == "blp"
end
local state = { offset = 0, count = 0 }
dropdown.customMediaScroll = state
local function EnsurePreview(frame)
if not frame or frame.customMediaPreviewLoaded then return end
local texture, path = frame.customMediaPreviewTex, frame.customMediaPreviewPath
if not texture or not path then return end
texture:SetTexture(path)
frame.customMediaPreviewLoaded = true
end
local track = menu:CreateTexture(nil, "OVERLAY")
track:SetTexture(0, 0, 0, .35)
track:SetPoint("TOPRIGHT", menu, "TOPRIGHT", -3, -3)
track:SetPoint("BOTTOMRIGHT", menu, "BOTTOMRIGHT", -3, 3)
track:SetWidth(SCROLLBAR_WIDTH)
track:Hide()
-- The menu inserts one row frame per element into the scroll box's data
-- provider and lays all rows out at a common height, so the visible window is
-- plain division of the scroll offset. The offset is measured the way
-- ScrollToElementDataIndex measures it, against the rows' extents from the top.
local function PreviewScrollWindow(scrollBox)
local size = scrollBox:GetDataProviderSize()
if not size or size == 0 then return end
local slider = CreateFrame("Slider", nil, menu)
slider:SetOrientation("VERTICAL")
slider:SetFrameStrata("FULLSCREEN")
slider:SetFrameLevel(menu:GetFrameLevel() + 20)
slider:SetPoint("TOPLEFT", track, "TOPLEFT", 0, 0)
slider:SetPoint("BOTTOMRIGHT", track, "BOTTOMRIGHT", 0, 0)
slider:SetMinMaxValues(0, 0)
slider:SetValueStep(1)
slider:SetThumbTexture(pfUI.media["img:col"])
slider:SetWidth(SCROLLBAR_WIDTH)
local thumb = slider:GetThumbTexture()
if thumb then
thumb:SetTexture(.3, 1, .8, .5)
thumb:SetWidth(SCROLLBAR_WIDTH)
local rowExtent = scrollBox:GetElementExtent(1)
if type(rowExtent) ~= "number" or rowExtent <= 0 then
rowExtent = ROW_HEIGHT
end
slider:Hide()
state.slider = slider
local function RefreshRows()
local visibleRows = state.visibleRows or 0
local itemRight = state.count > visibleRows and -(SCROLLBAR_WIDTH + 4) or -2
-- The scroll offset the view measures rows against starts at the upper
-- padding, so subtract it to keep the division in row units.
local offset = (scrollBox:GetDerivedScrollOffset() or 0) - (scrollBox:GetUpperPadding() or 0)
if offset < 0 then offset = 0 end
for _, entry in pairs(menu.elements or {}) do
local index = entry.id
if index and index >= 1 and index <= state.count then
entry:ClearAllPoints()
entry:SetPoint("TOPLEFT", menu, "TOPLEFT", 2, -(index - state.offset - 1) * ROW_HEIGHT - 2)
entry:SetPoint("TOPRIGHT", menu, "TOPRIGHT", itemRight, -(index - state.offset - 1) * ROW_HEIGHT - 2)
entry:SetHeight(ROW_HEIGHT)
if index > state.offset and index <= state.offset + visibleRows then
entry:Show()
else
entry:Hide()
end
local visible = scrollBox:GetVisibleExtent() or (MAX_VISIBLE_ROWS * rowExtent)
local first = math.floor(offset / rowExtent) + 1 - PREVIEW_MARGIN
if first < 1 then first = 1 end
local last = first + math.ceil(visible / rowExtent) + PREVIEW_MARGIN * 2
if last > size then last = size end
for index = first, last do
EnsurePreview(scrollBox:FindElementData(index))
end
end
-- A menu that fits on screen gets no scroll box, so all of its rows show.
local function PreviewAllRows(menuFrame)
for _, child in ipairs({ menuFrame:GetChildren() }) do
EnsurePreview(child)
end
end
local function OnMenuScroll(scrollBox)
PreviewScrollWindow(scrollBox)
end
-- The scroll box is a pooled frame that outlives every menu it has hosted, so
-- one registration per box covers all later menus and nothing needs cleaning up
-- when a menu closes.
local function ArmScrollPreview(menuFrame)
local scrollBox = menuFrame and menuFrame.ScrollBox
if not scrollBox or scrollBox.customMediaPreviewArmed then return end
scrollBox:RegisterCallback(BaseScrollBoxEvents.OnScroll, OnMenuScroll, scrollBox)
scrollBox.customMediaPreviewArmed = true
end
-- Index of the selected entry among the root description's elements. They come
-- out in the order the menu fed their frames to the data provider, so this is
-- also the data index.
local function GetSelectedIndex(rootDescription)
for index, description in rootDescription:EnumerateElementDescriptions() do
if description:IsRadio() and description:IsSelected() then
return index
end
end
return nil
end
-- Centering rather than the minimum scroll puts the entries around the current
-- selection in view as well, which is what a picker list is for.
local function OpenAtSelection(menuFrame, rootDescription)
local scrollBox = menuFrame and menuFrame.ScrollBox
if not scrollBox or not scrollBox:IsShown() then return end
if not rootDescription or type(rootDescription.EnumerateElementDescriptions) ~= "function" then return end
local index = GetSelectedIndex(rootDescription)
if not index then return end
scrollBox:ScrollToElementDataIndex(index, ScrollBoxConstants.AlignCenter, 0, ScrollBoxConstants.NoScrollInterpolation)
end
local function PreviewMenu(menuFrame, rootDescription)
if not rootDescription or not rootDescription.customMediaPreviews then return end
local scrollBox = menuFrame and menuFrame.ScrollBox
if scrollBox and scrollBox:IsShown() then
ArmScrollPreview(menuFrame)
PreviewScrollWindow(scrollBox)
elseif menuFrame then
PreviewAllRows(menuFrame)
end
end
local originalOnMenuOpened = DropdownButtonMixin.OnMenuOpened
function DropdownButtonMixin:OnMenuOpened(menu)
originalOnMenuOpened(self, menu)
-- the manager has finished laying the menu out by now, which is what the
-- extents and the scroll offset below are measured against
local rootDescription = self:GetMenuDescription()
OpenAtSelection(menu, rootDescription)
PreviewMenu(menu, rootDescription)
end
-- ClassicAPI's virtualized stride math can compute begin indices past the end of the
-- data provider ([size+1, size] ranges), erroring out of the menu layout so reopened
-- menus show nothing. Menu scrollboxes already instantiate every item frame regardless
-- of virtualization, so run them non-virtualized: the view then uses the trivially
-- safe full range (1..size).
local function DevirtualizeMenuScroll(view)
if view and type(view.SetVirtualized) == "function" then
view:SetVirtualized(false)
end
end
-- pfUI-style scrollbar: thin, flat, accent-green thumb, no steppers. The stock
-- ClassicAPI bar re-applies Blizzard atlas art on every enable/disable state
-- change and its thumb sizes itself off atlas info, so those handlers are
-- stubbed per instance before blanking the art.
local function ThemeScrollBar(bar)
if not bar or bar.customMediaThemed then return end
bar.customMediaThemed = true
bar:SetWidth(12)
if bar.Background then bar.Background:Hide() end
for _, key in ipairs({ "Back", "Forward" }) do
local stepper = bar[key]
if stepper then
stepper.OnButtonStateChanged = function() end
stepper:Hide()
end
end
local track = bar.Track
if track then
track:ClearAllPoints()
track:SetPoint("TOPLEFT", bar, "TOPLEFT", 2, -2)
track:SetPoint("BOTTOMRIGHT", bar, "BOTTOMRIGHT", -2, 2)
local thumb = track.Thumb
if thumb then
thumb.OnButtonStateChanged = function() end
thumb.OnSizeChanged = function() end
for _, key in ipairs({ "Begin", "Middle", "End" }) do
local tex = thumb[key]
if tex then tex:SetTexture(0, 0, 0, 0) end
end
thumb:SetWidth(8)
if thumb.thumbTexture then
thumb.thumbTexture:ClearAllPoints()
thumb.thumbTexture:SetPoint("TOPLEFT", thumb, "TOPLEFT", 0, 0)
thumb.thumbTexture:SetPoint("BOTTOMRIGHT", thumb, "BOTTOMRIGHT", 0, 0)
thumb.thumbTexture:SetTexture(0.3, 1, 0.8, 0.8)
end
end
slider:SetValue(state.offset)
end
state.RefreshRows = RefreshRows
end
slider:SetScript("OnValueChanged", function()
state.offset = math.floor((this:GetValue() or 0) + .5)
RefreshRows()
end)
menu:EnableMouseWheel(1)
menu:SetScript("OnMouseWheel", function()
-- arg1 is the wheel delta; capture it into a local (Classic 1.12-safe).
local delta = arg1 or 0
local maxOffset = math.max(0, state.count - (state.visibleRows or 0))
state.offset = math.max(0, math.min(maxOffset, state.offset - delta))
slider:SetValue(state.offset)
RefreshRows()
end)
local originalShowMenu = dropdown.ShowMenu
dropdown.ShowMenu = function(self)
if type(originalShowMenu) ~= "function" then return end
originalShowMenu(self)
state.count = #(self.menu or {})
state.visibleRows = math.min(state.count, MAX_VISIBLE_ROWS)
state.offset = math.min(state.offset, math.max(0, state.count - state.visibleRows))
-- Cache screen height once per open instead of querying every frame.
local screenHeight = GetScreenHeight and GetScreenHeight() or 768
local maxMenuHeight = math.max(ROW_HEIGHT + MENU_PADDING, screenHeight - 80)
state.visibleRows = math.min(state.visibleRows, math.max(1, math.floor((maxMenuHeight - MENU_PADDING) / ROW_HEIGHT)))
local visibleHeight = state.visibleRows * ROW_HEIGHT + MENU_PADDING
self.menuframe:SetHeight(visibleHeight)
state.slider:SetMinMaxValues(0, math.max(0, state.count - state.visibleRows))
if state.count > state.visibleRows then
track:Show()
slider:Show()
local thumb = slider:GetThumbTexture()
if thumb then
thumb:SetTexture(.3, 1, .8, .5)
thumb:SetWidth(SCROLLBAR_WIDTH)
thumb:SetHeight(math.max(18, visibleHeight * state.visibleRows / state.count))
end
state.offset = math.min(state.offset, math.max(0, state.count - state.visibleRows))
else
track:Hide()
slider:Hide()
state.offset = 0
if MenuProxyMixin and type(MenuProxyMixin.OnLoad) == "function" and not MenuProxyMixin.customMediaDevirtualized then
local originalMenuOnLoad = MenuProxyMixin.OnLoad
function MenuProxyMixin:OnLoad()
originalMenuOnLoad(self)
if self.ScrollBox and self.ScrollBox.view then
DevirtualizeMenuScroll(self.ScrollBox.view)
end
ThemeScrollBar(self.ScrollBar)
end
MenuProxyMixin.customMediaDevirtualized = true
end
-- Flip upward if opening below the control would run off the screen.
local buttonBottom = self:GetBottom()
local buttonTop = self:GetTop()
if buttonBottom and buttonBottom < visibleHeight and buttonTop and buttonTop + visibleHeight < screenHeight then
self.menuframe:ClearAllPoints()
self.menuframe:SetPoint("BOTTOMLEFT", self, "TOPLEFT", 0, 2)
self.menuframe:SetPoint("BOTTOMRIGHT", self, "TOPRIGHT", 0, 2)
else
self.menuframe:ClearAllPoints()
self.menuframe:SetPoint("TOPLEFT", self, "BOTTOMLEFT", 0, -2)
self.menuframe:SetPoint("TOPRIGHT", self, "TOPRIGHT", 0, 2)
end
RefreshRows()
-- One closure per texture path, cached: a menu regenerates on every open and
-- each row would otherwise allocate a new function.
local swatchInitializers = {}
local function GetSwatchInitializer(path)
local initializer = swatchInitializers[path]
if initializer then return initializer end
initializer = function(frame)
-- The row frame is a compositor target, so CreateTexture is disallowed and
-- the swatch has to be attached. Rows draw their own art, so this sits
-- below the row's hover highlight (BACKGROUND, level -1) and below the
-- text, which the font string pool already layers above the row textures.
if type(frame.AttachTexture) ~= "function" then return end
local tex = frame:AttachTexture()
tex:SetDrawLayer("BACKGROUND", -1)
tex:SetAlpha(PREVIEW_ALPHA)
-- Inward offsets: negative y from TOPLEFT and positive y from BOTTOMRIGHT,
-- so the swatch stays inside the row instead of bleeding over its neighbours.
tex:SetPoint("TOPLEFT", frame, "TOPLEFT", PREVIEW_LEFT, -PREVIEW_INSET)
tex:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -PREVIEW_RIGHT, PREVIEW_INSET)
frame.customMediaPreviewTex = tex
frame.customMediaPreviewPath = path
end
swatchInitializers[path] = initializer
return initializer
end
-- Rows are pooled and reused by whichever menu acquires them, so a row that
-- carried a swatch hands its state back when it leaves.
local function ClearSwatch(frame)
frame.customMediaPreviewTex = nil
frame.customMediaPreviewPath = nil
frame.customMediaPreviewLoaded = nil
end
-- Opt a freshly generated root description into a scrollable menu and give each
-- of its radios a swatch. Initializers run in insertion order, after the stock
-- radio art has been laid down.
local function ThemeRootDescription(description)
if type(description) ~= "table" then return end
if type(description.SetScrollMode) == "function" then
local screenHeight = (UIParent and UIParent:GetHeight()) or 768
local maxExtent = math.min(MAX_VISIBLE_ROWS * ROW_HEIGHT, math.max(MIN_VISIBLE_ROWS * ROW_HEIGHT, screenHeight - 120))
description:SetScrollMode(maxExtent)
end
local originalUpdateMenu = dropdown.UpdateMenu
dropdown.UpdateMenu = function(self)
if type(originalUpdateMenu) ~= "function" then return end
originalUpdateMenu(self)
if self.menuframe:IsShown() then
state.count = #(self.menu or {})
state.visibleRows = math.min(state.count, MAX_VISIBLE_ROWS)
state.offset = math.min(state.offset, math.max(0, state.count - state.visibleRows))
self.menuframe:SetHeight(state.visibleRows * ROW_HEIGHT + MENU_PADDING)
state.slider:SetMinMaxValues(0, math.max(0, state.count - state.visibleRows))
if state.count > state.visibleRows then track:Show(); slider:Show() else track:Hide(); slider:Hide() end
RefreshRows()
-- Every generation hands out a new description proxy, so marking it is
-- enough to keep this from chaining over itself.
if type(description.CreateRadio) ~= "function" or description.customMediaRadioPatched then return end
local originalCreateRadio = description.CreateRadio
description.CreateRadio = function(self, text, isSelected, setSelected, data)
local radio = originalCreateRadio(self, text, isSelected, setSelected, data)
if radio and type(radio.AddInitializer) == "function" and IsTexturePath(data) then
radio:AddInitializer(GetSwatchInitializer(data))
radio:AddResetter(ClearSwatch)
-- counts rows carrying a swatch, so a menu without any never goes
-- through the preview path
description.customMediaPreviews = (description.customMediaPreviews or 0) + 1
end
return radio
end
description.customMediaRadioPatched = true
end
local function CreateScrollableDropDown(name, parent)
local dropdown = originalCreateDropDownButton(name, parent)
SetupScrollableMenu(dropdown)
return dropdown
local originalCreateDefaultRootMenuDescription = DropdownButtonMixin.CreateDefaultRootMenuDescription
function DropdownButtonMixin:CreateDefaultRootMenuDescription()
local description = originalCreateDefaultRootMenuDescription(self)
ThemeRootDescription(description)
return description
end
pfUI.api.CreateDropDownButton = CreateScrollableDropDown
pfUI.api.customMediaDropdownScrollable = true
-- pfUI exposes API functions in its environment for unqualified calls in modules.
if type(pfUI.env) == "table" then
pfUI.env.CreateDropDownButton = CreateScrollableDropDown
end
-- pfUI builds its configuration window while its own addon is loading, before
-- this dependent addon is loaded. Retrofit those already-created dropdowns.
local function PatchExistingDropdowns(frame)
if not frame then return end
if type(frame.SetMenu) == "function" and type(frame.ShowMenu) == "function" and frame.menuframe then
SetupScrollableMenu(frame)
end
local children = { frame:GetChildren() }
for _, child in pairs(children) do
PatchExistingDropdowns(child)
end
end
if pfUI.gui then
PatchExistingDropdowns(pfUI.gui)
end
DropdownButtonMixin.customMediaDropdowns = true