mirror of
https://github.com/mr-rosh/pfUI-CustomMedia.git
synced 2026-11-10 23:57:32 +00:00
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.
303 lines
12 KiB
Lua
303 lines
12 KiB
Lua
-- 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 MAX_VISIBLE_ROWS = 14
|
|
local MIN_VISIBLE_ROWS = 4
|
|
|
|
-- 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 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 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
|
|
|
|
-- 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 rowExtent = scrollBox:GetElementExtent(1)
|
|
if type(rowExtent) ~= "number" or rowExtent <= 0 then
|
|
rowExtent = ROW_HEIGHT
|
|
end
|
|
|
|
-- 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
|
|
|
|
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
|
|
end
|
|
end
|
|
|
|
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
|
|
|
|
-- 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
|
|
|
|
-- 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 originalCreateDefaultRootMenuDescription = DropdownButtonMixin.CreateDefaultRootMenuDescription
|
|
|
|
function DropdownButtonMixin:CreateDefaultRootMenuDescription()
|
|
local description = originalCreateDefaultRootMenuDescription(self)
|
|
ThemeRootDescription(description)
|
|
return description
|
|
end
|
|
|
|
DropdownButtonMixin.customMediaDropdowns = true
|