Files
ElvUI_CoA/Modules/TalentFrame.lua
T
Narcasung 62a6a2cc57 Restyle CoA talent frame tabs to match ElvUI's flat tab rows
Bigger font and plate, tighter gap between tabs, taller tabs for real
vertical padding around the label. Selection now relies on Blizzard's
own white-text-on-checked behaviour instead of an ElvUI border/fill
swap, matching the Friends/Character tab rows elsewhere in ElvUI.

Growth and frame-level bump are self-healing from the tabs' OnUpdate
hook rather than one-shot: the tab's native height isn't settled yet
when it's first skinned, and something outside our control resets the
frame level on a plain tab switch, so both need to keep correcting
themselves rather than being set once behind a guard.
2026-08-17 20:59:11 +02:00

880 lines
32 KiB
Lua

local E, L, V, P, G = unpack(ElvUI)
local S = E:GetModule("Skins")
local CoA = E:GetModule("CoA")
local FRAME_NAME = "CoATalentFrame"
local TAB_NAME = "CollectionsPoolFrameCollectionTabTemplate%d"
local MAX_TABS = 10
-- The talent tree is the one part of the frame we must not touch: every node
-- is an icon button whose border/overlay textures encode rank and
-- availability, so ElvUI's generic button handling would flatten the
-- information out of them. The nodes and connectors live in pools under
-- SpecTree, with the scene art in FXFrame, so those subtrees are skipped by
-- name. TreeView itself is walked, because the bottom bar is inside it.
local EXCLUDED = {"PoolFrame", "SpecTree", "ClassTree", "FXFrame", "ShadowOverlay"}
local function IsTreeSubtree(name)
if not name then return false end
for _, pattern in ipairs(EXCLUDED) do
if name:find(pattern) then return true end
end
return false
end
local function IsDropDown(frame, name)
return name ~= nil and _G[name.."Button"] ~= nil and _G[name.."Text"] ~= nil
end
-- The bottom bar's widgets aren't at a fixed depth (some sit directly on the
-- frame, some are nested one or two containers deep), and their names aren't
-- documented anywhere, so skinning is driven by walking the frame instead of
-- by a hardcoded name list. Depth is capped so a container we didn't expect
-- can't drag us down into the tree's pooled widgets.
local MAX_DEPTH = 5
local SkinChildren
function SkinChildren(frame, depth)
if depth > MAX_DEPTH then return end
for i = 1, frame:GetNumChildren() do
local child = select(i, frame:GetChildren())
local name = child.GetName and child:GetName()
if child.GetObjectType and not IsTreeSubtree(name) then
local objType = child:GetObjectType()
if objType == "EditBox" then
S:HandleEditBox(child)
elseif objType == "Button" then
-- Close buttons are handled explicitly -- the frame's in
-- SkinFrame, where it needs an anchor and a frame level the walk
-- can't supply, and each menu's in SkinBottomBar. Matched on
-- "Close" rather than "CloseButton": the menus name theirs just
-- "...MenuClose", and it was coming out of the walk as an
-- ordinary templated square with the X stripped off it.
if not (name and name:find("Close")) then
S:HandleButton(child)
end
elseif objType == "Frame" then
if IsDropDown(child, name) then
S:HandleDropDownBox(child)
else
SkinChildren(child, depth + 1)
end
end
end
end
end
-- TreeView is a sibling drawn above the close button's own frame level, so
-- without the bump the ElvUI close texture ends up behind the tree panel and
-- the corner just looks empty. Anchored explicitly because the native corner
-- position was set relative to the NineSlice art we hide.
local function SkinCloseButton(frame)
local close = _G[FRAME_NAME.."CloseButton"]
if not close then return end
-- Strictly once: HandleCloseButton strips the button 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
-- Re-applied on every pass, not once at skin time. The X is drawn on the
-- close button's own frame, so it needs to outrank TreeView, and TreeView
-- rides along whenever the talent frame is raised while the close button,
-- being a sibling, keeps whatever absolute level it was given -- which is
-- how the X ends up buried behind the tree panel with the button still
-- clickable.
close:SetFrameStrata(frame:GetFrameStrata())
close:SetFrameLevel(frame:GetFrameLevel() + 20)
-- HandleCloseButton centres a 12px X inside the button's native 32px box,
-- so anchoring the box to the frame corner drops the X well below the
-- title. Centring the button on the title's own vertical midpoint puts the
-- X on the title line whatever height the bar turns out to be.
local title = _G[FRAME_NAME.."TitleText"]
local top = frame:GetTop()
local titleY = title and select(2, title:GetCenter())
close:ClearAllPoints()
if top and titleY then
close:Point("CENTER", frame, "TOPRIGHT", -16, titleY - top)
else
close:Point("TOPRIGHT", frame, "TOPRIGHT", -4, -4)
end
end
local BOTTOM_BAR = FRAME_NAME.."TreeViewBottomBar"
local BOTTOM_BAR_DROPDOWNS = {"SpecDropDown", "BuildDropDown"}
-- The two menus don't name their list alike: the spec menu's inset and scroll
-- frame hang off "List", the build creator's off "BuildList". Assuming they
-- shared the layout is why nothing on the build creator's scrollbar was ever
-- being found.
local BOTTOM_BAR_MENUS = {
{menu = "SpecializationMenu", list = "List"},
{menu = "BuildCreatorMenu", list = "BuildList"}
}
-- Rows are pooled, so this walks by index until it runs out rather than
-- tracking how many the list currently holds.
local MENU_ROW_LIMIT = 50
-- Each row's icon sits in a rounded empty-slot ring, which reads as a raised
-- bevel against flat panels. The ring goes and the icon gets the usual ElvUI
-- treatment: cropped edges and a backdrop sized to it.
local function SkinMenuRowIcon(iconFrame)
if not iconFrame or iconFrame.CoASkinned then return end
iconFrame.CoASkinned = true
for i = 1, iconFrame:GetNumRegions() do
local region = select(i, iconFrame:GetRegions())
local texture = region.GetTexture and region:GetTexture()
if texture and texture:find("EmptySlot") then
region:SetTexture(nil)
end
end
local name = iconFrame:GetName()
local icon = name and _G[name..".Icon"]
if icon then
S:HandleIcon(icon, iconFrame)
end
end
local ROW_BORDER_EDGES = {"TOP", "BOTTOM", "LEFT", "RIGHT"}
-- The active row is edged rather than filled, and the edge is four textures on
-- the row for the same reason the fill is a texture: a backdrop frame is a
-- child, so it either covers the row's art or is covered by the neighbouring
-- rows, which overlap far enough that only its bottom line came through.
local function CreateRowBorder(row)
local border = {}
for _, edge in ipairs(ROW_BORDER_EDGES) do
local line = row:CreateTexture(nil, "OVERLAY")
line:Hide()
border[edge] = line
end
border.TOP:SetPoint("TOPLEFT", row, "TOPLEFT")
border.TOP:SetPoint("TOPRIGHT", row, "TOPRIGHT")
border.TOP:SetHeight(E.mult)
border.BOTTOM:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT")
border.BOTTOM:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT")
border.BOTTOM:SetHeight(E.mult)
border.LEFT:SetPoint("TOPLEFT", row, "TOPLEFT")
border.LEFT:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT")
border.LEFT:SetWidth(E.mult)
border.RIGHT:SetPoint("TOPRIGHT", row, "TOPRIGHT")
border.RIGHT:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT")
border.RIGHT:SetWidth(E.mult)
row.CoABorder = border
end
-- Selection is read off the native overlay rather than tracked here: the row's
-- own code decides which entry is active and there's no event for it, so the
-- overlay is left in place as the signal and only its art is cleared.
local function UpdateRowSelection(row)
local border = row.CoABorder
if not border then return end
local overlay = row.CoASelected
-- Alpha as well as visibility, because it isn't known which of the two the
-- row uses to turn the overlay on.
local selected = overlay ~= nil and overlay:IsShown() and overlay:GetAlpha() > 0
if selected == row.CoASelectionShown then return end
row.CoASelectionShown = selected
local r, g, b = unpack(E.media.rgbvaluecolor)
for _, edge in ipairs(ROW_BORDER_EDGES) do
local line = border[edge]
line:SetTexture(r, g, b, 1)
if selected then line:Show() else line:Hide() end
end
end
-- The row's frame is a rounded plate from the PvP queue art, with a matching
-- background behind it. Both come off in favour of a flat ElvUI panel, and the
-- hover and selection plates have to go with them -- they're cut to the same
-- rounded shape, so they read as bevels once the plate underneath is flat.
-- Hover becomes the flat white wash ElvUI puts on list rows; selection moves
-- onto the 1px edge from CreateRowBorder, which is how the active entry is
-- marked everywhere else in ElvUI.
--
-- Hidden as well as cleared, and re-run from both the row's show and its
-- update: a single pass didn't hold, the rows put their art back as they're
-- refilled from the pool.
local function StripRowArt(row)
local name = row:GetName()
local border = name and _G[name..".Border"]
if border then
border:SetTexture(nil)
border:Hide()
end
-- ".H" is the row's highlight texture, so retexturing it is enough to
-- replace the hover state; there's no separate object to chase.
local hover = name and _G[name..".H"]
if hover and hover.SetTexture then
hover:SetTexture(1, 1, 1, 0.15)
hover:ClearAllPoints()
hover:SetInside(row)
row.CoAHover = hover
end
local selected = name and _G[name..".Selected"]
if selected and selected.SetTexture then
selected:SetTexture(nil)
row.CoASelected = selected
end
for i = 1, row:GetNumRegions() do
local region = select(i, row:GetRegions())
local texture = region.GetTexture and region:GetTexture()
if texture and texture:find("GuildFrame") then
region:SetTexture(nil)
region:Hide()
end
end
end
-- Scrolling refills the pooled rows and puts their native hover and selection
-- art back, and a pooled row doesn't fire OnShow when it's refilled, so there's
-- no event to strip on -- which is why the glow only ever appeared after a
-- scroll. The art is checked from the row's update instead. A colour-set
-- texture reads back as "SolidTexture", so the check stays a string compare
-- rather than a blind re-skin every frame.
local function UpdateRowArt(row)
local hover, selected = row.CoAHover, row.CoASelected
if (hover and hover:GetTexture() ~= "SolidTexture")
or (selected and selected:GetTexture() ~= nil) then
StripRowArt(row)
end
end
local function UpdateRow(row)
UpdateRowArt(row)
UpdateRowSelection(row)
end
local SkinMenuRow
function SkinMenuRow(row)
if not row then return end
if not row.CoASkinned then
row.CoASkinned = true
-- The fill is painted on the row itself rather than through
-- CreateBackdrop. The backdrop is a child frame, and here it covered
-- the row's name, icon and status text at every frame level tried,
-- including one below the row's own. A texture on the row's BACKGROUND
-- layer is ordered against those regions inside a single frame instead,
-- so it can't outrank them. Opaque rather than the transparent template,
-- since the menu panel behind is already see-through and a second
-- see-through layer left the rows reading as holes onto the talent scene.
local background = row:CreateTexture(nil, "BACKGROUND")
background:SetInside(row)
background:SetTexture(unpack(E.media.backdropcolor))
CreateRowBorder(row)
-- Nooped so a refill can't swap the highlight for a fresh texture
-- object, which would leave the retextured one orphaned and the check in
-- UpdateRowArt reading a state nothing draws from any more.
row.SetHighlightTexture = E.noop
row:HookScript("OnShow", SkinMenuRow)
-- No event fires when the active spec changes or when a scroll refills
-- the row, so both are resynced from the row's update. The comparisons
-- in UpdateRowArt and UpdateRowSelection make every tick that changes
-- nothing a no-op.
row:HookScript("OnUpdate", UpdateRow)
end
StripRowArt(row)
UpdateRowSelection(row)
end
-- These children are named with a literal dot ("Button1.SpecIcon"), so they
-- only come out of _G by string key, never as plain identifiers.
local function SkinMenuRows(listName)
for i = 1, MENU_ROW_LIMIT do
local rowName = listName.."ScrollFrameButton"..i
local row = _G[rowName]
if not row then break end
SkinMenuRow(row)
SkinMenuRowIcon(_G[rowName..".SpecIcon"])
end
end
-- S:HandleScrollBar can't be used on these: it assumes the thumb is a texture
-- and calls SetTexture on it, but this thumb is a button, so the call errors
-- and takes the rest of the OnShow skinning down with it.
--
-- It's a proportional scrollbar: the thumb is drawn as three slices that the
-- widget re-applies whenever it resizes, so stripping them doesn't hold. The
-- slices are made transparent instead and a value-coloured panel is stretched
-- from the first to the last, which is how ElvUI handles the same widget in
-- HandleProportionalScroll. Slices are looked up as fields and as globals,
-- since only the global names are confirmed here.
local function SkinScrollThumb(thumb, thumbName)
if not thumb or thumb.backdrop then return end
local first = thumb.Begin or _G[thumbName.."Begin"]
local last = thumb.End or _G[thumbName.."End"]
local middle = thumb.Middle or _G[thumbName.."Middle"]
if first then first:SetAlpha(0) end
if last then last:SetAlpha(0) end
if middle then middle:SetAlpha(0) end
local r, g, b = unpack(E.media.rgbvaluecolor)
thumb:CreateBackdrop("Transparent")
thumb.backdrop:SetFrameLevel(thumb:GetFrameLevel() + 1)
thumb.backdrop:SetBackdropColor(r, g, b, 0.25)
if first and last then
thumb.backdrop:Point("TOPLEFT", first)
thumb.backdrop:Point("BOTTOMRIGHT", last)
end
thumb:HookScript("OnEnter", function(self)
if self.backdrop then self.backdrop:SetBackdropColor(r, g, b, 0.75) end
end)
thumb:HookScript("OnLeave", function(self)
if self.backdrop then self.backdrop:SetBackdropColor(r, g, b, 0.25) end
end)
end
-- The scroll arrows come back as Blizzard chevrons after the list refreshes,
-- and it isn't the ElvUI arrow being overwritten -- that texture is still in
-- place underneath. The button's native art is a region StripTextures hid, and
-- the scroll frame shows it again whenever it recalculates.
--
-- Matched by file rather than as "any region that isn't one of ours": on this
-- client 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 texture:lower():find(NATIVE_SCROLL_ART) and region:IsShown() then
region:Hide()
end
end
-- The arrow itself is re-pointed as well, in case a refresh reaches the
-- state textures and not only the art it re-shows.
for _, texture in ipairs(button.CoAArrowTextures) do
local current = texture:GetTexture()
if not current or 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
local function SkinArrow(button, direction)
if not button or button.CoASkinned then return end
button.CoASkinned = true
S:HandleNextPrevButton(button, direction)
button.CoAArrowDirection = direction
button.CoAArrowTextures = {
button:GetNormalTexture(),
button:GetPushedTexture(),
button:GetDisabledTexture()
}
-- Still worth closing off: whatever refreshes these would otherwise be free
-- to swap in a fresh texture object and orphan the three above.
button.SetNormalTexture = E.noop
button.SetPushedTexture = E.noop
button.SetDisabledTexture = E.noop
button.SetHighlightTexture = E.noop
button:HookScript("OnUpdate", RestoreArrow)
end
-- The list inside each dropdown popup: a scroll frame with the framed inset
-- and overlay art around it. The arrows are named off the scroll frame rather
-- than off the scrollbar, so they're looked up here instead.
local function SkinMenuScroll(listName)
local scrollBar = _G[listName.."ScrollFrameScrollBar"]
if not scrollBar then return end
local inset = _G[listName.."Inset"]
if inset then inset:StripTextures() end
local overlay = _G[listName.."ScrollFrameArtOverlay"]
if overlay then overlay:StripTextures() end
if scrollBar.backdrop then return end
local frameLevel = scrollBar:GetFrameLevel()
scrollBar:Width(18)
scrollBar:StripTextures()
scrollBar:CreateBackdrop()
scrollBar.backdrop:SetAllPoints()
scrollBar.backdrop:SetFrameLevel(frameLevel)
local up = _G[listName.."ScrollFrameScrollUpButton"]
if up then
up:Point("BOTTOM", scrollBar, "TOP", 0, 1)
SkinArrow(up, "up")
end
local down = _G[listName.."ScrollFrameScrollDownButton"]
if down then
down:Point("TOP", scrollBar, "BOTTOM", 0, -1)
SkinArrow(down, "down")
end
local thumbName = listName.."ScrollFrameScrollBarThumb"
local thumb = (scrollBar.GetThumbTexture and scrollBar:GetThumbTexture()) or _G[thumbName]
SkinScrollThumb(thumb, thumbName)
end
-- The bar's plate art (BottomBarTexture) is the dark band under the buttons,
-- and it's the bar's only region, so a plain StripTextures clears it without
-- touching the scene art, which lives on TreeView one level up. The buttons,
-- dropdowns and search box sitting on the bar are picked up by the walk.
local function SkinBottomBar()
local bar = _G[BOTTOM_BAR]
if not bar then return end
bar:StripTextures()
-- Each dropdown's caret is a child button carrying the arrow as its own
-- art, so the walk was treating it as an ordinary button and giving it a
-- templated square. Skinned here, ahead of the walk, so it becomes the
-- ElvUI chevron; HandleNextPrevButton's isSkinned flag then makes the walk
-- leave it alone. noBackdrop keeps it a bare white arrow that takes the
-- value colour on hover, and "up" matches the way these menus open.
for _, suffix in ipairs(BOTTOM_BAR_DROPDOWNS) do
local dropdown = _G[BOTTOM_BAR..suffix]
local arrow = _G[BOTTOM_BAR..suffix.."Button"]
if arrow then
S:HandleNextPrevButton(arrow, "up", nil, true)
arrow:Size(20, 20)
end
-- These buttons are wider than they look: the label art only ever
-- covered the part up to the arrow, so templating the whole frame drew
-- a panel running under the icon buttons to their right. Skinned here
-- rather than in the walk so the backdrop can be built separately and
-- stopped at the arrow, and the dead space to its right is taken out
-- of the hit rect so it can't swallow clicks meant for those icons.
if dropdown and arrow then
S:HandleButton(dropdown, nil, nil, true)
if dropdown.backdrop then
dropdown.backdrop:ClearAllPoints()
dropdown.backdrop:Point("TOPLEFT", dropdown, "TOPLEFT", -1, 1)
dropdown.backdrop:Point("BOTTOMRIGHT", arrow, "BOTTOMRIGHT", 3, -1)
end
local dropdownRight, arrowRight = dropdown:GetRight(), arrow:GetRight()
if dropdownRight and arrowRight then
dropdown:SetHitRectInsets(0, math.max(0, dropdownRight - arrowRight - 3), 0, 0)
end
end
end
-- The two dropdown popups are plain frames, which the walk descends into
-- for their buttons but never templates, so they get their panel here.
for _, entry in ipairs(BOTTOM_BAR_MENUS) do
local menuName = BOTTOM_BAR..entry.menu
local listName = menuName..entry.list
local menu = _G[menuName]
if menu and not menu.CoASkinned then
menu.CoASkinned = true
menu:StripTextures()
menu:SetTemplate("Transparent")
-- Once only: HandleCloseButton strips the button on every call,
-- which blanks the X it added on the first pass.
local close = _G[menuName.."Close"]
if close then
S:HandleCloseButton(close)
end
-- Rows don't exist until the menu is first opened, which happens
-- long after the talent frame's own OnShow, so they're picked up
-- on the menu's.
menu:HookScript("OnShow", function()
SkinMenuRows(listName)
end)
end
SkinMenuScroll(listName)
SkinMenuRows(listName)
end
end
-- The scene art belongs to TreeView, which runs on behind the bottom bar, so
-- clearing the bar's own plate left the art showing through under the
-- buttons. The pieces are stretched over the whole of TreeView and show their
-- slice of the file through tex coords, which occupy only part of it -- the
-- rest of the file holds other art. So the coords have to be trimmed in
-- proportion to their existing values rather than replaced outright, or
-- unrelated regions of the file scroll into view.
--
-- Geometry comes from TreeView and the bar rather than from the texture's own
-- rect, so that a re-run reads the same numbers instead of measuring a rect
-- it already cropped. Coords are kept from the first pass for the same reason.
local function CropTexture(tex, bar, keep)
if not tex.CoAOriginalCoords then
tex.CoAOriginalCoords = {tex:GetTexCoord()}
end
-- GetTexCoord yields the four corners: UL, LL, UR, LR. These are all
-- axis-aligned, so the edges come off the corners that define them.
local coords = tex.CoAOriginalCoords
local left, top, bottom, right = coords[1], coords[2], coords[4], coords[5]
tex:SetTexCoord(left, right, top, top + (bottom - top) * keep)
tex:Point("BOTTOMRIGHT", bar, "TOPRIGHT", 0, 0)
end
local function CropBackground()
local treeView = _G[FRAME_NAME.."TreeView"]
local bar = _G[BOTTOM_BAR]
if not (treeView and bar) then return end
local treeTop, treeBottom, barTop = treeView:GetTop(), treeView:GetBottom(), bar:GetTop()
if not (treeTop and treeBottom and barTop) then return end
local height = treeTop - treeBottom
if height <= 0 then return end
local keep = (treeTop - barTop) / height
if keep <= 0 or keep >= 1 then return end
-- Scoped to TreeView's own regions: earlier this looked the names up as
-- globals over a fixed range, which pulled in same-named textures owned by
-- other frames and re-anchored those too.
for i = 1, treeView:GetNumRegions() do
local region = select(i, treeView:GetRegions())
local name = region.GetName and region:GetName()
if name and name:find("Background") and region:GetObjectType() == "Texture" then
CropTexture(region, bar, keep)
end
end
end
-- S:HandleTab can't be used here: it clears the tab background by name,
-- looking for a "Middle" piece, and these tabs name theirs "Center", so the
-- body of the tab survives and the ElvUI backdrop just lands behind the old
-- art. The unnamed ARTWORK region is the tab's icon and is left alone.
local TAB_TEXTURES = {"Left", "Center", "Right", "LeftDisabled", "CenterDisabled", "RightDisabled"}
-- Blizzard's own tab code re-sets these textures both when the frame reopens
-- and, separately, on every tab switch -- and a switch never fires the talent
-- frame's OnShow, only SetChecked. So this has to be idempotent and re-run
-- from both places rather than skinned once behind a guard.
local function StripTab(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
-- Same sibling problem as the close button (see SkinCloseButton): the tabs
-- aren't children of the talent frame, so growing one taller lets its top
-- edge poke up behind the frame's own panel art instead of in front of it.
--
-- The backdrop's own level is left alone -- CreateBackdrop keeps it level
-- with the tab by default, and regions render fine on top of a same-level
-- child. Forcing the backdrop a level *above* the tab (tried here once)
-- reproduces the child-frame-covers-parent's-own-regions quirk noted in
-- StripRowArt, which is why the label vanished that time. Raising the tab is
-- enough; the backdrop, clamped to the tab's new level automatically, rises
-- with it.
local function BumpTabLevel(tab)
local frame = _G[FRAME_NAME]
if not frame then return end
tab:SetFrameStrata(frame:GetFrameStrata())
tab:SetFrameLevel(frame:GetFrameLevel() + 20)
end
-- Grown height, self-healing the same way: on a plain /reload the tab's
-- native height isn't settled yet at SkinTab time (Blizzard lays it out
-- asynchronously), so a one-shot SetHeight there caught a stale value and
-- produced a shorter tab until something (a switch, a reopen) let Blizzard's
-- own layout finish and this caught the corrected height.
--
-- Compared by equality rather than "did it shrink": Blizzard's late layout
-- can land on a height *larger* than the stale one this originally grew from,
-- and a shrink-only check would then see current > target and never regrow,
-- leaving the tab at Blizzard's native size with none of the padding added.
-- Checking for any drift away from what was last written here catches that
-- direction too, but GetHeight doesn't read back byte-exact after SetHeight
-- -- UI scale rounds it to a slightly different float -- so a strict ~=
-- compare never held and this grew every single tick without bound. Half a
-- pixel of slack absorbs that rounding while still catching a genuine
-- Blizzard-driven change, which is always a full tab's worth of height, not
-- a rounding error's worth.
local TAB_GROWTH = 8
local TAB_HEIGHT_EPSILON = 0.5
local function UpdateTabSize(tab)
local height = tab:GetHeight()
if not tab.CoAGrownHeight or math.abs(height - tab.CoAGrownHeight) > TAB_HEIGHT_EPSILON then
tab.CoAGrownHeight = height + TAB_GROWTH
tab:SetHeight(tab.CoAGrownHeight)
end
end
-- Only the active tab gets a SetChecked call on every switch -- the other two
-- never fire it again after the first skin, but their art still comes back,
-- so there's no event to hook for them. Checked from OnUpdate instead, same
-- as RestoreArrow/UpdateRowArt: cheap GetTexture() compare, only pays for the
-- full strip when the native art has actually reappeared.
--
-- The frame-level bump is re-applied here too, every frame rather than only
-- on OnShow/SetChecked: the earlier fix (bumping only from those two spots)
-- still left the tabs behind the panel, which means whatever resets their
-- level on a switch isn't SetChecked either. OnUpdate is the one hook proven
-- to survive every path that reverts these tabs, so it's the catch-all.
local function UpdateTabArt(tab)
BumpTabLevel(tab)
UpdateTabSize(tab)
local name = tab:GetName()
local tex = name and _G[name.."Left"]
if tex and tex:GetTexture() then
StripTab(tab)
end
end
-- These are CheckButtons, but no fill or border swap is needed to mark the
-- open one -- Blizzard's own tab code already turns the label white on the
-- checked tab and leaves the rest their normal colour, same as the
-- Friends/Character tab rows. Only the art strip needs to re-run here.
local function UpdateTabSelection(tab)
StripTab(tab)
end
local function SkinTab(tab)
if tab.CoASkinned then return end
tab.CoASkinned = true
StripTab(tab)
-- Default rather than Transparent: these sit below the frame over open
-- world, so a see-through panel reads as washed out instead of as the
-- solid tabs the retail layout has.
--
-- Insets alone only resize the plate within the tab's native bounds --
-- padding around the (now bigger) label needs the tab itself taller.
-- Height is grown by UpdateTabSize (from the OnUpdate hook below) rather
-- than here, since the native height isn't settled yet at this point.
tab:CreateBackdrop("Default")
tab.backdrop:Point("TOPLEFT", 3, -3)
tab.backdrop:Point("BOTTOMRIGHT", -3, 3)
tab:SetHitRectInsets(3, 3, 3, 3)
-- Native size is a retail leftover -- every other ElvUI tab row in this
-- client reads bigger. Grown in place off the font's own current size
-- rather than a hardcoded number, so it still scales with the user's
-- font settings.
local fontString = tab.GetFontString and tab:GetFontString()
if fontString then
local font, size, flags = fontString:GetFont()
if font then
fontString:SetFont(font, size + 3, flags)
end
-- The label's native anchor sits right off the icon, sized for the
-- smaller native font. Grown text collides with the icon at that
-- offset, so it's nudged right off whatever point Blizzard anchored
-- it to rather than a hardcoded anchor that would fight the tab's
-- own layout.
local point, relTo, relPoint, x, y = fontString:GetPoint(1)
if point then
fontString:SetPoint(point, relTo, relPoint, x + 12, y)
end
end
hooksecurefunc(tab, "SetChecked", UpdateTabSelection)
UpdateTabSelection(tab)
tab:HookScript("OnUpdate", UpdateTabArt)
end
-- Same sibling problem as the close button (see SkinCloseButton): the tabs
-- aren't children of the talent frame, so growing one taller lets its top
-- edge poke up behind the frame's own panel art instead of in front of it.
-- Re-applied every pass rather than once, since whatever re-raises the frame
-- doesn't carry the tabs' absolute level along with it.
--
-- The backdrop's own level is left alone -- CreateBackdrop keeps it level
-- with the tab by default, and regions render fine on top of a same-level
-- child. Forcing the backdrop a level *above* the tab (tried here once)
-- reproduces the child-frame-covers-parent's-own-regions quirk noted in
-- StripRowArt, which is why the label vanished. Raising the tab is enough;
-- the backdrop, clamped to the tab's new level automatically, rises with it.
local function SkinTabs()
for i = 1, MAX_TABS do
local tab = _G[TAB_NAME:format(i)]
if not tab then break end
SkinTab(tab)
StripTab(tab)
BumpTabLevel(tab)
end
end
local SPEC_CHOICE = FRAME_NAME.."SpecViewPoolFrameCoASpecChoiceTemplate%d"
local MAX_SPEC_CHOICES = 10
-- Only each card's action button. The cards themselves keep their art: the
-- portraits and the gold wash on the active spec are the whole point of the
-- view, and there's nothing flat that would say the same thing.
--
-- Skinned by name because the walk can't get here -- the cards hang off a
-- PoolFrame, which EXCLUDED skips so the tree's pooled nodes stay untouched.
-- Stripped as well as templated: the red plate isn't one of the Left/Middle/
-- Right pieces HandleButton clears on its own.
local function SkinSpecChoices()
for i = 1, MAX_SPEC_CHOICES do
local cardName = SPEC_CHOICE:format(i)
if not _G[cardName] then break end
S:HandleButton(_G[cardName.."SelectButton"], true)
end
end
local function SkinFrame(frame)
if not frame.CoASkinned then
frame.CoASkinned = true
frame:StripTextures()
local nineSlice = _G[FRAME_NAME.."NineSlice"]
if nineSlice then
nineSlice:StripTextures()
nineSlice:Hide()
end
-- The round portrait medallion overhangs the top-left corner and has
-- no flat equivalent, so it goes rather than getting reskinned.
local portrait = _G[FRAME_NAME.."PortraitFrame"]
if portrait then
portrait:StripTextures()
portrait:Hide()
end
frame:SetTemplate("Transparent")
-- The choice cards are pulled from the pool as the view opens, so the
-- talent frame's own show is too early to catch them. Run on the view's
-- show, and again a tick later in case the pool is filled after it.
local specView = _G[FRAME_NAME.."SpecView"]
if specView then
specView:HookScript("OnShow", function()
SkinSpecChoices()
E:Delay(0.1, SkinSpecChoices)
end)
end
-- Children are created lazily as tabs are visited, so the walk has to
-- run again on every show rather than once at hook time. The isSkinned
-- / backdrop guards inside ElvUI's handlers make re-runs cheap.
frame:HookScript("OnShow", function(self)
SkinCloseButton(self)
SkinBottomBar()
CropBackground()
SkinChildren(self, 1)
SkinTabs()
SkinSpecChoices()
end)
end
SkinCloseButton(frame)
SkinBottomBar()
CropBackground()
SkinChildren(frame, 1)
SkinTabs()
SkinSpecChoices()
end
local function TryHook()
local frame = _G[FRAME_NAME]
if not frame then return false end
SkinFrame(frame)
return true
end
function CoA:InitializeTalentFrame()
if not E.private.skins.blizzard.enable then return end
if TryHook() then return end
self.talentFrameTimer = self:ScheduleRepeatingTimer(function()
if TryHook() then
self:CancelTimer(self.talentFrameTimer)
self.talentFrameTimer = nil
end
end, 0.5)
end