Files
Narcasung 63e179e96e Keep the scroll arrows skinned when the list refreshes
The scroll frame re-shows the button's own native art whenever it
recalculates, and one skinning pass can't hold against it: ElvUI's
HandleNextPrevButton guards on isSkinned, so a second call does nothing.

The talent frame already carried the answer -- re-hide the art by file
from OnUpdate and pin the three state textures -- so it moves into the
shared layer as Skin:ScrollArrow and the Challenges list scrolls take it
too. Routing the talent frame through it also picks up the
disabled-hover fix every other button already has.
2026-08-22 16:59:59 +02:00

835 lines
34 KiB
Lua

local E, L, V, P, G = unpack(ElvUI)
local S = E:GetModule("Skins")
local CoA = E:GetModule("CoA")
-- Shared skinning helpers for the CoA frames (talent, vanity, wardrobe).
--
-- Those three are built from the same handful of widget templates, so every
-- element that appears in more than one of them lives here rather than being
-- copied per module. The copies had already drifted -- different backdrop
-- insets, different close button sizes, tabs grown in one frame and not the
-- other -- and the drift is exactly what reads as "the same control looks
-- different depending on which tab I'm on".
local Skin = {}
CoA.Skin = Skin
-- Every native close button on these frames is a different size (the vanity
-- store's is visibly larger than the talent frame's), and HandleCloseButton
-- keeps whatever size it's given -- it only centres a fixed 12px X inside the
-- box. Normalised here so the X lands on the same grid in all three, and the
-- click target is the same everywhere too.
local CLOSE_BUTTON_SIZE = 32
function Skin:CloseButton(close)
if not close then return end
-- Strictly once: HandleCloseButton strips the button on every call, which
-- blanks the X texture it added on the first pass, and its own guard won't
-- rebuild it because the field still points at that blanked texture.
if not close.CoASkinned then
close.CoASkinned = true
S:HandleCloseButton(close)
end
close:Size(CLOSE_BUTTON_SIZE)
end
-- Scale is a per-frame setting rather than one shared number: the three frames
-- don't ship at the same scale (the vanity store's is about 9% larger than the
-- other two, measured), and that's the server's choice, so the slider is a
-- multiplier on whatever each frame was given rather than an absolute. A
-- setting of 1 therefore leaves every frame exactly as it came.
--
-- Scale is applied to Collections, the container all three windows and the tab
-- row are children of (confirmed by probe), rather than to a window itself.
-- Collections is also the only one of them with mouse enabled -- it's what the
-- player drags -- so scaling a window directly shrank the art while leaving the
-- drag target at full size, which is why a shrunk window had to be grabbed by
-- clicking outside itself. Scaling the container moves its hit area with it,
-- and the tabs come along as its children.
--
-- The setting is still per-window: only one of them is ever open, and each
-- re-applies its own on show. The native scale is captured before anything here
-- has written one, so re-applying can't compound.
local CONTAINER_NAME = "Collections"
local scaleKey
local function GetScaleSetting(key)
local db = CoA.db and CoA.db.profile.skins.collections
return (db and db[key]) or 1
end
function Skin:ApplyWindowScale(key)
local container = _G[CONTAINER_NAME]
if not container then return end
if not container.CoANativeScale then
container.CoANativeScale = container:GetScale()
end
-- Remembered so the slider can re-apply for whichever window is open.
scaleKey = key
local scale = container.CoANativeScale * GetScaleSetting(key)
if container:GetScale() ~= scale then
container:SetScale(scale)
end
end
-- Live update from the slider: a window only re-runs its own skin pass on show,
-- and a scale change should land while one is open.
function CoA:UpdateFrameScales()
if scaleKey then
Skin:ApplyWindowScale(scaleKey)
end
end
-- The talent and wardrobe frames title in Arial Narrow, the vanity store in
-- Friz Quadrata (measured live). Arial Narrow is the one that matches the rest
-- of the layout, so it's pinned here rather than left to the frames -- and
-- pinned by name through LSM rather than as a raw path, so it stays the same
-- asset ElvUI itself would resolve.
--
-- Size is written straight through, with no correction for the frames running
-- at different scales: the vanity store's is about 9% larger than the other
-- two (measured), so its title renders slightly bigger than theirs. Left as the
-- server has it for now.
--
-- FontTemplate stashes the font and size it was handed on the fontstring, so an
-- ElvUI font change later re-applies these rather than resetting them.
local TITLE_FONT = "Arial Narrow"
local TITLE_SIZE = 13
function Skin:Title(title)
if not title then return end
title:FontTemplate(E.Libs.LSM:Fetch("font", TITLE_FONT), TITLE_SIZE, "OUTLINE")
end
-- Strip first, THEN template: SetTemplate adds its own backdrop as texture
-- regions on this same frame, so stripping afterwards wipes it straight back
-- off -- which is how the wardrobe's outer panel once came out fully invisible.
--
-- keepTextures is for frames whose own regions aren't all decorative: the
-- vanity store draws its currency counters as regions of the frame itself, and
-- a blind strip blanks the counters along with the panel art.
function Skin:Panel(frame, keepTextures)
if not frame or frame.CoAPanelSkinned then return end
frame.CoAPanelSkinned = true
if not keepTextures then
frame:StripTextures()
end
frame:SetTemplate("Transparent")
end
-- Ornate art with no flat equivalent (portrait medallions, nine-slice borders,
-- shadow overlays): it goes rather than getting reskinned. Hidden as well as
-- stripped, since some of these put their own art back.
function Skin:HideArt(frame)
if not frame then return end
frame:StripTextures()
frame:Hide()
end
-- Buttons ---------------------------------------------------------------------
--
-- IsEnabled hands back 0 and 1 on this client rather than false and true
-- (probed), and 0 is truthy in Lua, so every enabled check has to normalise
-- what it gets back before comparing it.
function Skin:IsEnabled(button)
local state = button.IsEnabled and button:IsEnabled()
return state ~= nil and state ~= false and state ~= 0
end
-- The target is resolved the way ElvUI's own SetModifiedBackdrop and
-- SetOriginalBackdrop resolve it -- the backdrop when the button has one, the
-- button itself otherwise -- or the colour lands on something that isn't the
-- object drawing the border.
local function SetButtonBorder(button, lit)
local backdrop = button.backdrop or button
if not backdrop.SetBackdropBorderColor then return end
backdrop:SetBackdropBorderColor(unpack(lit and E.media.rgbvaluecolor or E.media.bordercolor))
end
-- S:HandleButton ends with an unconditional pair of OnEnter/OnLeave hooks that
-- swap the border to the value colour and back, and neither of them looks at
-- IsEnabled. A disabled button still fires both scripts on this client, so a
-- dead button lights up under the cursor and reads as clickable -- the greyed
-- Activate button, Save changes with nothing pending, Purchase with nothing
-- selected. HookScript can't be undone, but handlers fire in registration
-- order, so a hook registered after HandleButton runs last and has the final
-- say on the colour.
--
-- This one deliberately doesn't consult IsMouseOver: OnEnter can fire a pixel
-- before IsMouseOver turns true (ElvUI's own aura code pads around the same
-- quirk), and reading it here would drop the highlight off a live button.
local function OnButtonEnter(button)
if Skin:IsEnabled(button) then return end
SetButtonBorder(button, false)
end
-- For a button that can be disabled while the cursor is already on it: OnEnter
-- has been and gone by then, so nothing corrects the border until the pointer
-- leaves. Only worth wiring up where something already watches the button's
-- state -- it does not justify an OnUpdate of its own.
function Skin:RefreshButtonBorder(button)
SetButtonBorder(button, self:IsEnabled(button) and button:IsMouseOver())
end
-- Every S:HandleButton call site in the plugin goes through here, so the
-- disabled-hover correction is inherited rather than repeated per module. The
-- extra arguments are HandleButton's own (strip, isDeclineButton,
-- useCreateBackdrop, noSetTemplate) and are passed straight through.
function Skin:Button(button, ...)
if not button then return end
S:HandleButton(button, ...)
-- Unguarded: a pass over a handful of regions is cheap, and re-running it
-- catches a region added to the button after the first skin pass.
self:StripHighlightArt(button)
-- Guarded separately from HandleButton's own isSkinned flag: these frames
-- re-run their skin pass on every show, and HookScript stacks handlers
-- rather than replacing them, so an unguarded re-hook adds another copy
-- per show for the life of the session.
if not button.CoAButtonSkinned then
button.CoAButtonSkinned = true
button:HookScript("OnEnter", OnButtonEnter)
end
end
-- S:HandleNextPrevButton routes through S:HandleButton, so it inherits exactly
-- the lit-border-on-a-dead-button behaviour OnButtonEnter is here to correct,
-- and scroll arrows spend a good deal of their life disabled at one end of their
-- list or the other. Same separate guard as Skin:Button, and for the same
-- reason: these frames re-run their skin passes, and HookScript stacks.
function Skin:NextPrevButton(button, direction)
if not button then return end
S:HandleNextPrevButton(button, direction)
if not button.CoAButtonSkinned then
button.CoAButtonSkinned = true
button:HookScript("OnEnter", OnButtonEnter)
end
end
-- Several of these controls draw their art as anonymous regions of one atlas
-- file rather than through the named Left/Middle/Right fields or the Normal/
-- Pushed/Disabled set S:HandleButton knows how to clear, so its own clearing
-- can't reach them -- and a blind StripTextures would take the caret or the
-- label with it. Those are cleared by file instead.
--
-- SetTexture is noop'd per region rather than just cleared: whatever re-arts
-- the control on a state change (a mouse-down swapping in the pressed variant,
-- a tex coord swap between two variants of the same file) is free to re-set the
-- file as well, and a cleared texture would come straight back.
function Skin:StripArtByFile(frame, pattern)
if not frame then return end
for i = 1, frame:GetNumRegions() do
local region = select(i, frame:GetRegions())
local texture = region.GetTexture and region:GetTexture()
if texture and tostring(texture):find(pattern) then
region:SetTexture(nil)
region.SetTexture = E.noop
end
end
end
-- S:HandleButton clears the button's own Normal/Highlight/Pushed/Disabled
-- textures and the named Left/Middle/Right pieces, and nothing else -- so a
-- native highlight drawn as a plain region of the button survives it and lights
-- up under the cursor against the flat ElvUI backdrop. That was the wardrobe
-- Cancel button's red glow, and clearing it by file there fixed exactly that
-- one button: probing the layer across everything the plugin skins turned up
-- the Save outfit button wearing the same red file, and Disable transmog and
-- Disable spell visuals wearing the dialog-box glow, none of which any
-- clear-by-file call site was ever going to reach.
--
-- The whole layer goes rather than named files, because on these frames it only
-- ever carries native hover art. Every button probed has exactly one HIGHLIGHT
-- region, and on the ones that already looked right it is blank -- so there is
-- nothing else living there to lose. ElvUI's own hover treatment isn't caught:
-- it's the backdrop border swap HandleButton hooks, and a backdrop is drawn
-- through SetBackdrop plus two child border frames, not as a region of the
-- button at all.
--
-- Noop'd rather than only cleared, as in StripArtByFile: a state change that
-- re-arts the button would otherwise bring the glow straight back.
function Skin:StripHighlightArt(frame)
if not frame then return end
for i = 1, frame:GetNumRegions() do
local region = select(i, frame:GetRegions())
if region:GetObjectType() == "Texture" and region:GetDrawLayer() == "HIGHLIGHT" then
region:SetTexture(nil)
region.SetTexture = E.noop
end
end
end
-- The pill behind the talent frame's Activate button and the wardrobe's Apply
-- and Cancel buttons. Two files rather than one: "128GoldRedButton" carries the
-- green and grey variants as tex coords, and Cancel alone is drawn from
-- "128RedButton" (probed). The shared suffix matches both, and nothing else on
-- these frames uses either. Only the pill itself is this pattern's job now --
-- the hover art on both files goes with the rest of the HIGHLIGHT layer.
Skin.RedButtonArt = "RedButton"
-- The dropdown pills on the vanity and wardrobe frames are the same widget:
-- nine anonymous "Silver-Button" slices.
local DROPDOWN_ART = "Silver%-Button"
local CARET_ART = "ChatFrameExpandArrow"
local CARET_SIZE = 14
-- The caret is retextured after HandleButton, not before: HandleButton strips
-- the pill, and a caret replaced ahead of that gets cleared straight back off.
local function SkinDropdownCaret(dropdown)
for i = 1, dropdown:GetNumRegions() do
local region = select(i, dropdown:GetRegions())
local texture = region.GetTexture and region:GetTexture()
if texture and tostring(texture):find(CARET_ART) then
-- The caret is a plain OVERLAY texture on the dropdown itself rather
-- than a separate button, so it can't go through
-- HandleNextPrevButton and is retextured directly. No ArrowDown
-- asset exists -- every other direction in ElvUI is ArrowUp rotated.
region:SetTexture(E.Media.Textures.ArrowUp)
region:SetVertexColor(1, 1, 1)
region:SetTexCoord(0, 1, 0, 1)
region:SetRotation(S.ArrowRotation.down)
region:SetSize(CARET_SIZE, CARET_SIZE)
end
end
end
-- menu is the popout list this dropdown owns; it gets the panel only, matching
-- how the talent frame's own popup menus are treated. The option rows inside
-- are a later pass.
function Skin:Dropdown(dropdown, menu)
if not dropdown or dropdown.CoASkinned then return end
dropdown.CoASkinned = true
self:StripArtByFile(dropdown, DROPDOWN_ART)
self:Button(dropdown)
SkinDropdownCaret(dropdown)
self:Panel(menu)
end
-- The CoA search boxes draw their border and magnifier from the stock
-- "CommonSearch" file, tinted to the server's own colour, as anonymous regions
-- -- so S:HandleEditBox can't reach them: it only alpha-0s the named Left/
-- Middle/Right pieces a stock Blizzard edit box has.
--
-- Cleared by file rather than by a blind strip, which is what a first pass did:
-- an EditBox draws its own caret and its selection highlight as plain solid
-- textures of its own (probed: four of them, sub-pixel wide and text-high, shown
-- only while the box has focus), and StripTextures takes those with the border,
-- leaving a search box you can type in with no cursor in it.
local SEARCH_BOX_ART = "CommonSearch"
function Skin:SearchBox(box)
if not box or box.CoASearchSkinned then return end
box.CoASearchSkinned = true
self:StripArtByFile(box, SEARCH_BOX_ART)
S:HandleEditBox(box)
end
-- Scroll bars ------------------------------------------------------------------
--
-- S:HandleScrollBar can't be used on these: the CoA bars are hand-built rather
-- than the stock Slider widget, so the thumb is a Button with art of its own
-- (probed) where a stock bar has a thumb *texture*, and HandleScrollBar reaches
-- for the thumb through GetThumbTexture and then calls SetTexture on what it
-- finds. It also looks the arrow buttons up as children of the bar, and these
-- are named off the scroll frame instead. The treatment below is the same one it
-- applies, only with each piece handed in by the caller.
--
-- The art on both pieces is applied lazily, and that is what defeated two
-- earlier passes at this: at skin time the thumb's textures read back nil, so
-- matching them by file found nothing to clear, and the scroll code arts them a
-- moment later when it first measures the list. Clearing, noop'ing the regions'
-- SetTexture, and alpha'ing them all failed the same way -- not because the art
-- came back, but because it was never there yet to be taken.
--
-- So it's handled from OnUpdate, the same answer the tab art needed and for the
-- same reason: nothing fires when it happens, and the check is a three-region
-- alpha compare that only writes when something has actually re-arted the
-- widget. Blanket rather than matched by file: neither the track nor the thumb
-- carries anything but its own art -- no label, no counter -- which is the only
-- thing StripArtByFile's file matching is there to protect.
local function HideScrollArt(frame)
for i = 1, frame:GetNumRegions() do
local region = select(i, frame:GetRegions())
if region:GetObjectType() == "Texture" and region:GetAlpha() ~= 0 then
region:SetAlpha(0)
end
end
end
-- Colours and states taken from ElvUI's own proportional scroll bars
-- (S:HandleProportionalScroll), which is what every other scroll bar in this UI
-- looks like: the value colour at a quarter alpha, three quarters while hovered
-- or held. Its handlers are locals in ElvUI's file, so the four scripts are
-- written out here rather than reached for.
--
-- The active flag is what keeps a thumb lit while it's being dragged: the
-- pointer routinely leaves a thin thumb mid-drag, and OnLeave would otherwise
-- dim it while the list is still moving under it.
local SCROLL_BUTTON_GAP = 1
local SCROLL_THUMB_ALPHA = 0.25
local SCROLL_THUMB_ACTIVE_ALPHA = 0.75
local function SetThumbColor(thumb, alpha)
if not thumb.backdrop then return end
local r, g, b = unpack(E.media.rgbvaluecolor)
thumb.backdrop:SetBackdropColor(r, g, b, alpha)
end
local function ThumbOnEnter(thumb)
SetThumbColor(thumb, SCROLL_THUMB_ACTIVE_ALPHA)
end
local function ThumbOnLeave(thumb)
if thumb.CoAThumbActive then return end
SetThumbColor(thumb, SCROLL_THUMB_ALPHA)
end
local function ThumbOnMouseDown(thumb)
thumb.CoAThumbActive = true
SetThumbColor(thumb, SCROLL_THUMB_ACTIVE_ALPHA)
end
local function ThumbOnMouseUp(thumb)
thumb.CoAThumbActive = nil
SetThumbColor(thumb, SCROLL_THUMB_ALPHA)
end
-- The arrows re-art themselves the same way the track and thumb do, and one
-- pass can't hold against it: S:HandleNextPrevButton guards on its own
-- isSkinned flag, so calling it a second time does nothing at all. What comes
-- back isn't the ElvUI arrow being overwritten either -- that texture is still
-- in place underneath (probed: the native "scrollbarproportional" region shown
-- alongside ArrowUp). It's the button's own art, which StripTextures only hid,
-- and the scroll frame shows it again whenever it recalculates.
--
-- So the native art is re-hidden by file from OnUpdate, the same answer the
-- track, the thumb and the tab art all needed and for the same reason: nothing
-- fires when it happens. The three state textures are captured and their
-- setters noop'd as well -- still worth closing off, since whatever refreshes
-- these is free to swap in a fresh texture object and orphan the three.
--
-- Matched by file rather than as "any region that isn't one of ours": ElvUI's
-- own panel and border pieces are regions of the button too, so hiding
-- everything unrecognised would take the ElvUI square with it. Compared
-- case-insensitively -- the client hands paths back from GetTexture in whatever
-- case it stored them, not the case they were set in.
local ARROW_TEXTURE = E.Media.Textures.ArrowUp:lower()
local NATIVE_SCROLL_ART = "scrollbar"
local function RestoreArrow(button)
for i = 1, button:GetNumRegions() do
local region = select(i, button:GetRegions())
local texture = region.GetTexture and region:GetTexture()
if texture and tostring(texture):lower():find(NATIVE_SCROLL_ART) and region:IsShown() then
region:Hide()
end
end
for _, texture in ipairs(button.CoAArrowTextures) do
local current = texture:GetTexture()
if not current or tostring(current):lower() ~= ARROW_TEXTURE then
texture:SetTexture(E.Media.Textures.ArrowUp)
texture:SetInside(button)
texture:SetTexCoord(0, 1, 0, 1)
texture:SetRotation(S.ArrowRotation[button.CoAArrowDirection])
end
end
end
function Skin:ScrollArrow(button, direction)
if not button or button.CoAArrowSkinned then return end
button.CoAArrowSkinned = true
self:NextPrevButton(button, direction)
button.CoAArrowDirection = direction
button.CoAArrowTextures = {
button:GetNormalTexture(),
button:GetPushedTexture(),
button:GetDisabledTexture(),
}
button.SetNormalTexture = E.noop
button.SetPushedTexture = E.noop
button.SetDisabledTexture = E.noop
button.SetHighlightTexture = E.noop
button:HookScript("OnUpdate", RestoreArrow)
end
function Skin:ScrollBar(bar, thumb, up, down)
if not bar or bar.CoASkinned then return end
bar.CoASkinned = true
-- The track gets no backdrop of its own, only its art taken off, which is
-- what ElvUI's own proportional scroll bars do: against these panels an empty
-- channel reads cleaner than a second filled block behind the thumb, and the
-- thumb is legible on its own at the value colour.
bar:HookScript("OnUpdate", HideScrollArt)
if thumb then
thumb:HookScript("OnUpdate", HideScrollArt)
-- Filling the thumb rather than inset inside it: the thumb is already
-- narrower than the track it runs in, so an inset backdrop reads as a thin
-- line rather than as a grip. The track's own gutter is the separation.
--
-- Levelled off the thumb rather than off the track: the thumb sits a level
-- above the track already, so a backdrop built at the track's level would
-- land underneath the thumb's own regions.
thumb:CreateBackdrop("Transparent")
thumb.backdrop:SetFrameLevel(thumb:GetFrameLevel() + 1)
thumb.backdrop:SetAllPoints()
SetThumbColor(thumb, SCROLL_THUMB_ALPHA)
thumb:HookScript("OnEnter", ThumbOnEnter)
thumb:HookScript("OnLeave", ThumbOnLeave)
thumb:HookScript("OnMouseDown", ThumbOnMouseDown)
thumb:HookScript("OnMouseUp", ThumbOnMouseUp)
end
-- Re-anchored onto the track's ends after skinning, and cleared first rather
-- than pointed on top of what's there: HandleNextPrevButton resizes them to
-- ElvUI's own 18px, so whatever the native layout anchored them by no longer
-- lines up with the track, and a second point left in place would stretch the
-- button between the two.
if up then
self:ScrollArrow(up, "up")
up:ClearAllPoints()
up:Point("BOTTOM", bar, "TOP", 0, SCROLL_BUTTON_GAP)
end
if down then
self:ScrollArrow(down, "down")
down:ClearAllPoints()
down:Point("TOP", bar, "BOTTOM", 0, -SCROLL_BUTTON_GAP)
end
end
-- Tabs -----------------------------------------------------------------------
--
-- S:HandleTab still isn't used on any of these. On the server-authored tabs it
-- can't be: it clears the tab body by name, looking for a "Middle" piece, and
-- those name theirs "Center", so the body survives and the ElvUI backdrop just
-- lands behind the old art. The Challenges window's tabs are stock Blizzard
-- ones that do use "Middle" (probed), so HandleTab would reach them -- they go
-- through the same strip anyway, because one code path is what keeps every tab
-- row in this UI looking alike.
--
-- One list covers all three tab flavours: the talent frame's tabs carry the
-- Disabled variants as well, the wardrobe's category tabs don't, the Challenges
-- tabs use Blizzard's Left/Middle/Right plus the Disabled set Blizzard swaps in
-- to draw the selected tab, and the lookups for the ones that don't exist
-- simply come back nil.
local TAB_TEXTURES = {
"Left", "Center", "Middle", "Right",
"LeftDisabled", "CenterDisabled", "MiddleDisabled", "RightDisabled",
}
-- Blizzard's own tab code re-sets these textures both when a frame reopens and,
-- separately, on every tab switch -- and a switch never fires the owning
-- frame's OnShow, only SetChecked. So this has to be idempotent and re-run from
-- everywhere rather than skinned once behind a guard.
function Skin:StripTabArt(tab)
local name = tab:GetName()
if name then
for _, suffix in ipairs(TAB_TEXTURES) do
local tex = _G[name..suffix]
if tex then tex:SetTexture(nil) end
end
end
local highlight = tab.GetHighlightTexture and tab:GetHighlightTexture()
if highlight then highlight:SetTexture(nil) end
local checked = tab.GetCheckedTexture and tab:GetCheckedTexture()
if checked then checked:SetTexture(nil) end
end
-- Exposed: a module that re-anchors a tab row against the frame it belongs to
-- has to know how far inside its tab the backdrop actually starts, or it lines
-- the tab up flush and leaves the visible edge floating by this much.
local TAB_BACKDROP_INSET = 3
Skin.TabBackdropInset = TAB_BACKDROP_INSET
-- Native tab sizing is a retail leftover -- every other ElvUI tab row in this
-- client reads bigger -- so labelled tabs are grown, off the font's own current
-- size rather than a hardcoded number, and stay in step with the user's font
-- settings. Icon-only tabs (the wardrobe's category strip) have no label to
-- grow around and keep their native size; growing them would only push the
-- icons off their own layout.
local TAB_GROWTH = 8
local TAB_FONT_GROWTH = 3
local TAB_LABEL_OFFSET = 12
-- Grown height self-heals: on a plain /reload a tab's native height isn't
-- settled yet at skin time (Blizzard lays it out asynchronously), so a one-shot
-- SetHeight caught a stale value and left a shorter tab until something let the
-- native layout finish.
--
-- Compared by equality rather than "did it shrink": the late layout can land on
-- a height larger than the stale one this grew from, and a shrink-only check
-- would then see current > target and never regrow. GetHeight doesn't read back
-- byte-exact after SetHeight either -- UI scale rounds it to a slightly
-- different float -- so a strict ~= compare never held and this grew every tick
-- without bound. Half a pixel of slack absorbs the rounding while still
-- catching a genuine Blizzard-driven change, which is always a full tab's worth
-- of height. The same slack covers the width below.
local TAB_SIZE_EPSILON = 0.5
local function UpdateTabSize(tab)
if not tab.CoAGrowTab then return end
local height = tab:GetHeight()
if not tab.CoAGrownHeight or math.abs(height - tab.CoAGrownHeight) > TAB_SIZE_EPSILON then
tab.CoAGrownHeight = height + TAB_GROWTH
tab:SetHeight(tab.CoAGrownHeight)
end
end
-- Native panel tabs interlock: each is anchored back into the one before it by
-- the width of the art's end cap, so the ornate ends overlap rather than butting
-- together. Strip the art, put a flat backdrop in its place, and that overlap
-- has nothing left to hide it -- the row reads as one merged bar, and the tabs'
-- hit rects overlap along with their backdrops.
--
-- The tab is narrowed by exactly what its anchor was pulled back by, rather than
-- being pushed along to make room: pushing grows the row by the overlap times
-- the number of tabs, which on the Challenges window overhangs the frame by four
-- times the slack the row has. Absorbing it instead leaves the row's total
-- extent exactly where the native layout put it.
--
-- Opt-in per row rather than detected from the anchor: a tab anchored onto its
-- neighbour with a negative offset is the signature of an interlocking row, but
-- it would also match any row that merely sits tight, and silently resizing the
-- server-authored rows on that guess isn't worth the convenience.
local function UnoverlapTab(tab)
local point, relativeTo, relativePoint, x, y = tab:GetPoint(1)
if not (point and x) or x >= 0 then return end
tab.CoATabOverlap = -x
tab:SetPoint(point, relativeTo, relativePoint, 0, y)
end
-- Re-checked every tick with the same self-healing shape as the grown height,
-- and for the same reason: the native tab code re-measures a tab off its own
-- label, and a width written once would be handed straight back.
local function UpdateTabWidth(tab)
local overlap = tab.CoATabOverlap
if not overlap then return end
local width = tab:GetWidth()
if not tab.CoAUnoverlappedWidth or math.abs(width - tab.CoAUnoverlappedWidth) > TAB_SIZE_EPSILON then
tab.CoAUnoverlappedWidth = width - overlap
tab:SetWidth(tab.CoAUnoverlappedWidth)
end
end
-- Tabs that aren't children of the frame they belong to (the talent frame's
-- are separately-placed siblings) sit behind its panel art once grown, so they
-- get bumped above it. Re-applied every tick rather than on show or on select:
-- bumping from those two alone still left them behind the panel, so whatever
-- resets their level isn't either of those events.
local function BumpTabLevel(tab)
local parent = tab.CoALevelParent
if not parent then return end
tab:SetFrameStrata(parent:GetFrameStrata())
tab:SetFrameLevel(parent:GetFrameLevel() + 20)
end
-- Only the tab being switched to fires anything at all -- the ones switched
-- away from never fire OnClick/OnShow/SetChecked again, yet their native art
-- still comes back, so there's no event to catch it from. Checked from OnUpdate
-- instead: a cheap GetTexture() compare that only pays for the full strip when
-- the art has actually reappeared.
--
-- Two probes rather than one: a stock Blizzard tab draws its selected state by
-- hiding the Left/Middle/Right set and showing the Disabled set in its place,
-- so watching only the inactive half would miss art returning on whichever tab
-- is currently open. The three pieces of a set are always re-arted together, so
-- one probe per set is enough.
local TAB_ART_PROBES = {"Left", "LeftDisabled"}
local function UpdateTabArt(tab)
BumpTabLevel(tab)
UpdateTabSize(tab)
UpdateTabWidth(tab)
local name = tab:GetName()
if not name then return end
for _, suffix in ipairs(TAB_ART_PROBES) do
local tex = _G[name..suffix]
if tex and tex:GetTexture() then
Skin:StripTabArt(tab)
return
end
end
end
-- No fill or border swap marks the open tab: the native tab code already turns
-- the label white on the checked one and leaves the rest their normal colour,
-- same as the Friends/Character tab rows. Only the art strip has to re-run.
local function OnTabChecked(tab)
Skin:StripTabArt(tab)
end
-- The tab row along the bottom of the talent window is one set of buttons
-- shared by all three windows it switches between, not one row per window,
-- which is why it lives here rather than in any single frame's module. It
-- doesn't need scaling of its own -- it's a child of Collections, so it follows
-- whatever ApplyWindowScale sets.
local COLLECTION_TAB = "CollectionsPoolFrameCollectionTabTemplate%d"
local MAX_COLLECTION_TABS = 10
function Skin:CollectionTabs(owner)
for i = 1, MAX_COLLECTION_TABS do
local tab = _G[COLLECTION_TAB:format(i)]
if not tab then break end
self:Tab(tab, owner)
end
end
-- levelParent is the frame the tab must draw above, and is only needed for tabs
-- that aren't its children (see BumpTabLevel). Pass nil for tabs parented to
-- the frame they belong to -- normal parent/child z-order already covers those.
--
-- unoverlap is for rows of interlocking native tabs (see UnoverlapTab). Only the
-- tabs anchored onto a neighbour take it; the first tab in a row has nothing to
-- pull back from and is left alone by the offset check.
function Skin:Tab(tab, levelParent, unoverlap)
if not tab then return end
-- Set on every call rather than only the first: the collection tabs are
-- shared, so the frame they have to draw above is whichever window is open.
if levelParent then
tab.CoALevelParent = levelParent
end
if not tab.CoASkinned then
tab.CoASkinned = true
self:StripTabArt(tab)
if unoverlap then
UnoverlapTab(tab)
end
-- Default rather than Transparent: tabs sit below their frame over the
-- open world, so a see-through panel reads as washed out instead of as
-- the solid tabs the retail layout has.
--
-- The backdrop's own level is left alone. CreateBackdrop keeps it level
-- with the tab, and regions render fine on top of a same-level child;
-- forcing it a level above the tab reproduces the
-- child-frame-covers-its-parent's-own-regions quirk (see StripRowArt in
-- TalentFrame), which blanks the label.
tab:CreateBackdrop("Default")
tab.backdrop:Point("TOPLEFT", TAB_BACKDROP_INSET, -TAB_BACKDROP_INSET)
tab.backdrop:Point("BOTTOMRIGHT", -TAB_BACKDROP_INSET, TAB_BACKDROP_INSET)
tab:SetHitRectInsets(TAB_BACKDROP_INSET, TAB_BACKDROP_INSET, TAB_BACKDROP_INSET, TAB_BACKDROP_INSET)
local fontString = tab.GetFontString and tab:GetFontString()
if fontString then
tab.CoAGrowTab = true
local font, size, flags = fontString:GetFont()
if font then
fontString:SetFont(font, size + TAB_FONT_GROWTH, flags)
end
-- Only for tabs that actually carry an icon. On those (the talent
-- window's) the label's native anchor sits right off the icon, sized
-- for the smaller native font, so grown text collides with it, and
-- the label is nudged off whatever point the native layout gave it
-- rather than a hardcoded anchor that would fight that layout. The
-- wardrobe's category tabs have no icon and centre their label, so
-- the same nudge just pushed every one of them off-centre to the
-- right.
local name = tab:GetName()
local icon = name and _G[name.."Icon"]
if icon and icon.GetTexture and icon:GetTexture() and icon:IsShown() then
local point, relTo, relPoint, x, y = fontString:GetPoint(1)
if point then
fontString:SetPoint(point, relTo, relPoint, x + TAB_LABEL_OFFSET, y)
end
end
end
-- Guarded: the talent frame's tabs are CheckButtons, the wardrobe's
-- category tabs aren't guaranteed to be, and hooksecurefunc errors
-- outright on a method that doesn't exist.
if tab.SetChecked then
hooksecurefunc(tab, "SetChecked", OnTabChecked)
end
tab:HookScript("OnUpdate", UpdateTabArt)
end
self:StripTabArt(tab)
BumpTabLevel(tab)
end
-- These frames are created on-demand by their owning addon, the instant the
-- player first opens one -- a poll can't catch that before the native art gets
-- a paint. ADDON_LOADED fires (synchronously, before control returns to
-- whatever code calls :Show()) the moment that addon finishes loading, so
-- skinning from it lands before the first-ever :Show(), killing the one-frame
-- flicker a poll-based catch can't avoid. Confirmed in-game.
function Skin:OnFrameAvailable(tryHook)
if tryHook() then return end
local loader = CreateFrame("Frame")
loader:RegisterEvent("ADDON_LOADED")
loader:SetScript("OnEvent", function(self)
if tryHook() then
self:UnregisterEvent("ADDON_LOADED")
end
end)
end