Compare commits

..

7 Commits

Author SHA1 Message Date
Narcasung 13f36e7817 Skin the Clear invalid slots button and grey the dead ones
A third button sits on the wardrobe model alongside Apply and Cancel and
appears under the same condition: Clear invalid slots, shown with the warning
that an appearance has not been unlocked yet. It was simply not in the list,
so it kept its native green pill against the two now-flat buttons beside it.

It is drawn from the same atlas as Apply -- the green variant of the gold file
-- so Skin.RedButtonArt already matches it and the existing path covers it
whole; probing its regions after the pass comes back nil for all of them. With
three names the list is read in a loop rather than repeated call by call.

Apply is disabled for as long as an invalid slot is pending, and with the pill
gone nothing said so any more: the ElvUI panel is drawn the same either way,
and the atlas was the only thing carrying the distinction. The label is greyed
instead, the same yellow/grey pair the specialization menu's Activate button
uses. There is no event for the state change -- clearing the invalid slots
enables Apply on the spot -- so it is driven from the button's update and
compared against the cached state, again as Activate does it, and the border
goes with it: Apply comes alive under the very cursor that just clicked Clear
invalid slots, and OnEnter has been and gone by then.
2026-08-18 16:44:09 +02:00
Narcasung 919feed322 Strip the native hover glow from every skinned button
HandleButton clears a button's own Normal/Highlight/Pushed/Disabled textures
and the named Left/Middle/Right pieces, so a native highlight drawn as a plain
region of the button survives it and lights up under the cursor against the
flat backdrop. Clearing the wardrobe Cancel button's by file fixed that one
button and nothing else: probing the HIGHLIGHT layer across everything the
plugin skins turned up Save outfit 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. On these frames it only ever
carries native hover art -- every button probed has exactly one HIGHLIGHT
region, blank on the ones that already looked right, so there is nothing else
there to lose. ElvUI's own hover treatment is untouched: it is the backdrop
border swap HandleButton hooks, and a backdrop is drawn through SetBackdrop
plus two child border frames rather than as a region of the button.

Cleared from Skin:Button, so every call site in the plugin inherits it, and
unguarded so a region added after the first skin pass is caught too. SetTexture
is noop'd per region as StripArtByFile does, or a state change that re-arts the
button would bring the glow straight back.
2026-08-18 16:43:31 +02:00
Narcasung d2e55eb3cb Stop disabled buttons taking the hover border colour
S:HandleButton ends with an unconditional OnEnter/OnLeave pair that swaps
the border to the value colour and back, and neither handler looks at
IsEnabled. A disabled button still fires both scripts on this client, so
the greyed Activate button, Save changes with nothing pending and Purchase
with nothing selected all lit up under the cursor and read as clickable.

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. Skin:Button wraps HandleButton with that hook, and every call site
in the plugin now goes through it, so the correction is inherited rather
than repeated per module.

The Activate button carries the border from its own update as well:
activating a spec disables it under the very cursor that just clicked it,
and OnEnter has been and gone by then, so nothing else would put the border
back until the pointer moved off.

Skin:IsEnabled hoists the 0/1-to-boolean normalisation this client needs
out of UpdateActivateState, which held the only copy of it.
2026-08-18 16:30:27 +02:00
Narcasung cddfc7e7af Skin the Apply and Cancel buttons on the wardrobe model
Both only exist while a transmog change is pending, which is why every
earlier pass over the frame missed them -- it was always skinned in its idle
state -- so they still wore their native green and red pills against an
otherwise flat window.

They are the same widget as the specialization menu's Activate button: no
Normal/Pushed texture for HandleButton to clear, the pill drawn across plain
regions from an atlas. Two files, not one -- Apply takes the green variant of
128GoldRedButton, Cancel is drawn from 128RedButton, HIGHLIGHT region and all,
which is what left a red glow under the cursor once only the gold file was
being cleared. The shared "RedButton" suffix matches both. Their labels take
ElvUI's yellow rather than carrying the atlas's green/red split over: the two
sit side by side and read apart by their text.

The buttons exist from the start rather than being created on the first
pending change, so the normal skin pass reaches them, but the art is only
there to be cleared once a change is pending -- and nothing fires the frame's
OnShow at that point, since the window is already open. The buttons' own
OnShow catches it instead.

The clear-by-file loop the Activate button and the dropdown pills had a copy
of each now lives in Skinning.lua as Skin:StripArtByFile, with the atlas name
alongside it.
2026-08-18 16:29:12 +02:00
Narcasung 58fae4418a Skin the Activate button in the specialization menu
The one button in the Character Advancement frame the skin never reached:
the Activate button inside an expanded specialization row still wore its
native pill against otherwise flat rows.

It has no Normal/Pushed/Disabled textures for HandleButton to clear -- the
pill is three BACKGROUND regions plus a HIGHLIGHT one, all drawn from the
128GoldRedButton atlas, switching between the green and grey variant by tex
coord with the vertex colour left white throughout. So the art is cleared by
file and the regions' SetTexture noop'd, as the dropdown pills already are,
and the enabled/disabled distinction the atlas was carrying is re-created on
the label: ElvUI's yellow when live, grey when dead.

The expanded content exists from the moment the menu is built rather than
being created when its row is expanded, so it is picked up with the rest of
the row.
2026-08-18 16:15:05 +02:00
Narcasung cc6cd4028b Create movers eagerly so they always show in Toggle Anchors
The Extra Action Button, Instance Swap, and class resource frame
movers only got built once their native frame had already appeared
on screen with a resolvable position -- so if the frame never showed
up in a session (e.g. a class resource bar the current class doesn't
use), Toggle Anchors had nothing to display for it. Movers are now
created up front from a default size/position, and just get resized
and anchored once the real frame shows up. Also fixes each mover's
right-click config shortcut, which pointed at a pre-restructure
options path.
2026-08-18 16:00:49 +02:00
Narcasung 6bf3864891 Refresh the dispel talents when the specialization changes
Switching to another saved specialization never confirms a build, so
CHARACTER_ADVANCEMENT_UPDATE_ENTRIES_RESULT doesn't fire for it and the
cached talent state stayed stale for the rest of the session. That path
only fires PENDING_BUILD_UPDATED and SUGGESTIONS_UPDATED, so listen to
both. PENDING_BUILD_UPDATED also fires on every click inside the talent
UI, so the handler recomputes the cached rank and rebuilds the
unitframes only when the answer actually changed.

Also fall back to matching the talent by name among the learned entries
when the entry ID reports nothing, so an ID gone stale after a server
rebalance doesn't silently switch the feature off.
2026-08-18 15:59:29 +02:00
8 changed files with 496 additions and 99 deletions
+44 -25
View File
@@ -1,13 +1,18 @@
local E, L, V, P, G = unpack(ElvUI) local E, L, V, P, G = unpack(ElvUI)
local CoA = E:GetModule("CoA") local CoA = E:GetModule("CoA")
-- defaultY staggers the movers' first-run default position (LEFT of screen,
-- spaced vertically) so they don't stack on top of each other before the
-- player has ever dragged them.
local FRAMES = { local FRAMES = {
{name = "CoAResourceSegmentBar", moverText = "Resource Segment Bar", hideKey = "hideResourceSegmentBar"}, {name = "CoAResourceSegmentBar", moverText = "Resource Segment Bar", hideKey = "hideResourceSegmentBar", defaultY = 40},
{name = "CoAResourceOrb", moverText = "Resource Orb", hideKey = "hideResourceOrb"}, {name = "CoAResourceOrb", moverText = "Resource Orb", hideKey = "hideResourceOrb", defaultY = 100},
{name = "CoAResourceBar", moverText = "Resource Bar", hideKey = "hideResourceBar"}, {name = "CoAResourceBar", moverText = "Resource Bar", hideKey = "hideResourceBar", defaultY = 160},
{name = "CoAMultiCastActionBarFrame", moverText = "Multi Cast Action Bar", hideKey = "hideMultiCastActionBar"}, {name = "CoAMultiCastActionBarFrame", moverText = "Multi Cast Action Bar", hideKey = "hideMultiCastActionBar", defaultY = 220},
} }
local DEFAULT_WIDTH, DEFAULT_HEIGHT = 150, 30
local FRAME_NAMES = {} local FRAME_NAMES = {}
for _, def in ipairs(FRAMES) do for _, def in ipairs(FRAMES) do
FRAME_NAMES[def.name] = true FRAME_NAMES[def.name] = true
@@ -179,30 +184,42 @@ local function LockPosition(frame, holder)
end) end)
end end
local function SetupMover(frame, name, moverText) -- Created eagerly (independent of the native frame ever showing up -- several
if frame.CoAMoverCreated then return true end -- of these are class-specific and may never appear for a given character) so
-- every mover always appears in Toggle Anchors. The default point is only
-- used until the player drags it once; after that E:CreateMover restores the
-- saved position from E.db.movers.
local function CreateMoverHolder(def)
if CoA.classResourceMoversCreated and CoA.classResourceMoversCreated[def.name] then return end
CoA.classResourceMoversCreated = CoA.classResourceMoversCreated or {}
CoA.classResourceMoversCreated[def.name] = true
local holder = CreateFrame("Frame", "CoA_"..def.name.."Holder", E.UIParent)
holder:Size(DEFAULT_WIDTH, DEFAULT_HEIGHT)
holder:Point("LEFT", E.UIParent, "LEFT", 150, def.defaultY)
E:CreateMover(holder, "CoA_"..def.name.."Mover", def.moverText, nil, nil, nil, "ALL,COA", nil, "CoA,classResources")
holder:SetAllPoints(_G["CoA_"..def.name.."Mover"])
end
local function SyncHolderSize(frame, name)
local holder = _G["CoA_"..name.."Holder"]
if not holder then return end
local width, height = frame:GetSize() local width, height = frame:GetSize()
if width == 0 or height == 0 then return false end if width > 0 and height > 0 then
holder:Size(width, height)
end
end
local left, bottom = frame:GetLeft(), frame:GetBottom() local function AnchorFrame(frame, name)
if not left or not bottom then return false end local holder = _G["CoA_"..name.."Holder"]
if not holder then return end
frame.CoAMoverCreated = true
local holder = CreateFrame("Frame", "CoA_"..name.."Holder", E.UIParent)
holder:Size(width, height)
holder:Point("BOTTOMLEFT", E.UIParent, "BOTTOMLEFT", left, bottom)
E:CreateMover(holder, "CoA_"..name.."Mover", moverText, nil, nil, nil, "ALL,COA", nil, "CoA,skin,classResources")
holder:SetAllPoints(_G["CoA_"..name.."Mover"])
QueueAnchor(function() QueueAnchor(function()
AnchorToHolder(frame, holder) AnchorToHolder(frame, holder)
LockPosition(frame, holder) LockPosition(frame, holder)
end) end)
return true
end end
-- The server never Show()s a frame the current class doesn't use, so its -- The server never Show()s a frame the current class doesn't use, so its
@@ -259,12 +276,10 @@ local function TryHookAll()
DisableDrag(frame) DisableDrag(frame)
SetupVisibility(frame, def.hideKey) SetupVisibility(frame, def.hideKey)
FixTooltip(frame) FixTooltip(frame)
SyncHolderSize(frame, def.name)
AnchorFrame(frame, def.name)
if SetupMover(frame, def.name, def.moverText) then hooked[def.name] = true
hooked[def.name] = true
else
allHooked = false
end
else else
allHooked = false allHooked = false
end end
@@ -275,6 +290,10 @@ local function TryHookAll()
end end
function CoA:InitializeClassResources() function CoA:InitializeClassResources()
for _, def in ipairs(FRAMES) do
CreateMoverHolder(def)
end
if TryHookAll() then return end if TryHookAll() then return end
self.classResourcesTimer = self:ScheduleRepeatingTimer(function() self.classResourcesTimer = self:ScheduleRepeatingTimer(function()
+56 -16
View File
@@ -22,9 +22,9 @@ local CLASS_DISPEL_TYPES = {
-- every class tree, so querying one from a class that can't take it simply -- every class tree, so querying one from a class that can't take it simply
-- reports rank 0 rather than erroring. -- reports rank 0 rather than erroring.
local TALENT_DISPEL_TYPES = { local TALENT_DISPEL_TYPES = {
PROPHET = {talentID = 6324, types = {Curse = true}}, -- Blight Antidote PROPHET = {talentID = 6324, talentName = "Blight Antidote", types = {Curse = true}},
CULTIST = {talentID = 12982, types = {Curse = true}}, -- Devour Curse CULTIST = {talentID = 12982, talentName = "Devour Curse", types = {Curse = true}},
PYROMANCER = {talentID = 31276, types = {Magic = true, Disease = true, Bleed = true}}, -- Burn Impurities PYROMANCER = {talentID = 31276, talentName = "Burn Impurities", types = {Magic = true, Disease = true, Bleed = true}},
} }
-- Cached result of the talent lookup for the player's current build. -- Cached result of the talent lookup for the player's current build.
@@ -35,20 +35,45 @@ local hasTalentDispel = false
-- C_CharacterAdvancement is a server addition with no API documentation, so -- C_CharacterAdvancement is a server addition with no API documentation, so
-- every entry point is guarded and a failed call reads as "talent not taken", -- every entry point is guarded and a failed call reads as "talent not taken",
-- which degrades to the same behaviour as before auto-detection existed. -- which degrades to the same behaviour as before auto-detection existed.
local function HasTalent(talentID) local function HasTalent(talent)
local api = C_CharacterAdvancement local api = C_CharacterAdvancement
if not (api and type(api.GetTalentRankByID) == "function") then return false end if not api then return false end
local ok, rank = pcall(api.GetTalentRankByID, talentID) if type(api.GetTalentRankByID) == "function" then
local ok, rank = pcall(api.GetTalentRankByID, talent.talentID)
if ok and type(rank) == "number" and rank > 0 then return true end
end
return ok and type(rank) == "number" and rank > 0 -- Fall back to matching the talent by name, so an entry ID gone stale after
-- a server rebalance doesn't silently switch the feature off. Names are the
-- more fragile key of the two, but GetKnownTalentEntries only ever lists
-- entries the player has actually learned, which makes this a genuinely
-- independent check rather than a second opinion on the same ID. The list
-- has been seen with holes in it, so it's walked with pairs.
if type(api.GetKnownTalentEntries) == "function" then
local ok, entries = pcall(api.GetKnownTalentEntries)
if ok and type(entries) == "table" then
for _, entry in pairs(entries) do
if type(entry) == "table" and entry.Name == talent.talentName then return true end
end
end
end
return false
end end
-- Returns whether the talent state actually moved, so callers reacting to the
-- chattier advancement events can skip the unitframe rebuild when it didn't.
local function RefreshTalentState() local function RefreshTalentState()
local _, class = UnitClass("player") local _, class = UnitClass("player")
local talent = TALENT_DISPEL_TYPES[class] local talent = TALENT_DISPEL_TYPES[class]
local hasTalent = (talent and HasTalent(talent)) or false
hasTalentDispel = (talent and HasTalent(talent.talentID)) or false if hasTalent == hasTalentDispel then return false end
hasTalentDispel = hasTalent
return true
end end
function CoA:CanDispel(debuffType) function CoA:CanDispel(debuffType)
@@ -100,6 +125,12 @@ function CoA:UpdateDispelHighlight()
UF:Update_AllFrames() UF:Update_AllFrames()
end end
function CoA:RefreshDispelTalents()
if RefreshTalentState() then
UF:Update_AllFrames()
end
end
-- On Ascension, UnitClass("player")'s second return only reliably reports the -- On Ascension, UnitClass("player")'s second return only reliably reports the
-- real custom class (CULTIST, PYROMANCER, ...) a short while after login -- -- real custom class (CULTIST, PYROMANCER, ...) a short while after login --
-- immediately at ADDON_LOADED/PLAYER_LOGIN it can still read back the generic -- immediately at ADDON_LOADED/PLAYER_LOGIN it can still read back the generic
@@ -109,17 +140,26 @@ end
-- next aura change. Force one extra refresh shortly after entering the world -- next aura change. Force one extra refresh shortly after entering the world
-- so the very first debuff isn't judged too early. -- so the very first debuff isn't judged too early.
-- --
-- CHARACTER_ADVANCEMENT_UPDATE_ENTRIES_RESULT fires when a build is confirmed, -- CHARACTER_ADVANCEMENT_UPDATE_ENTRIES_RESULT fires when a build is confirmed
-- which is the point the talent ranks actually change. The advancement UI also -- in the talent UI, but switching to another saved specialization doesn't
-- fires CHARACTER_ADVANCEMENT_PENDING_BUILD_UPDATED while talents are being -- confirm anything -- that path only fires PENDING_BUILD_UPDATED and
-- clicked around, but ranks don't move until the build is saved, so listening -- SUGGESTIONS_UPDATED, so both are needed or the ranks read stale for the rest
-- to it would only cost refreshes that read back the unchanged state. -- of the session. PENDING_BUILD_UPDATED also fires on every click inside the
-- talent UI and SUGGESTIONS_UPDATED is only incidentally related, which is why
-- they go through RefreshDispelTalents: it costs one rank lookup and rebuilds
-- the unitframes only when the answer changed.
function CoA:InitializeDispelHighlight() function CoA:InitializeDispelHighlight()
CoA:RegisterEvent("PLAYER_ENTERING_WORLD", function() CoA:RegisterEvent("PLAYER_ENTERING_WORLD", function()
CoA:ScheduleTimer("UpdateDispelHighlight", 2) CoA:ScheduleTimer("UpdateDispelHighlight", 2)
end) end)
CoA:RegisterEvent("CHARACTER_ADVANCEMENT_UPDATE_ENTRIES_RESULT", function() for _, event in ipairs({
CoA:UpdateDispelHighlight() "CHARACTER_ADVANCEMENT_UPDATE_ENTRIES_RESULT",
end) "CHARACTER_ADVANCEMENT_PENDING_BUILD_UPDATED",
"CHARACTER_ADVANCEMENT_SUGGESTIONS_UPDATED",
}) do
CoA:RegisterEvent(event, function()
CoA:RefreshDispelTalents()
end)
end
end end
+33 -18
View File
@@ -53,6 +53,11 @@ end
function CoA:UpdateExtraActionButtonSize() function CoA:UpdateExtraActionButtonSize()
UpdateSize() UpdateSize()
local holder = _G["CoA_ExtraActionBarHolder"]
if holder then
holder:Size(CoA.db.profile.extraActionButtonSize or 52)
end
end end
local function UpdateHotkeyText(button) local function UpdateHotkeyText(button)
@@ -157,26 +162,34 @@ local function SkinButton(button, container)
SkinGlow(button, name) SkinGlow(button, name)
end end
local function SetupMover(container, button) -- Created eagerly (independent of the native ExtraActionBar ever showing up)
if CoA.extraActionBarMoverCreated then return true end -- so the mover always appears in Toggle Anchors. The default point below is
-- only used until the player drags it once; after that E:CreateMover restores
local width, height = button:GetSize() -- the saved position from E.db.movers.
if width == 0 or height == 0 then return false end local function CreateMoverHolder()
if CoA.extraActionBarMoverCreated then return end
local left, bottom = button:GetLeft(), button:GetBottom()
if not left or not bottom then return false end
CoA.extraActionBarMoverCreated = true CoA.extraActionBarMoverCreated = true
local size = CoA.db.profile.extraActionButtonSize or 52
local holder = CreateFrame("Frame", "CoA_ExtraActionBarHolder", E.UIParent)
holder:Size(size, size)
holder:Point("BOTTOM", E.UIParent, "BOTTOM", 0, 260)
E:CreateMover(holder, "CoA_ExtraActionBarMover", "Extra Action Bar", nil, nil, nil, "ALL,COA", nil, "CoA,skins,extraActionButton")
holder:SetAllPoints(_G["CoA_ExtraActionBarMover"])
end
local function SetupContainer(container)
if container.CoAContainerSetup then return end
container.CoAContainerSetup = true
container:StripTextures(true) container:StripTextures(true)
container:EnableMouse(false) container:EnableMouse(false)
end
local holder = CreateFrame("Frame", "CoA_ExtraActionBarHolder", E.UIParent) local function AnchorContainer(container)
holder:Size(width, height) local holder = _G["CoA_ExtraActionBarHolder"]
holder:Point("BOTTOMLEFT", E.UIParent, "BOTTOMLEFT", left, bottom) if not holder then return end
E:CreateMover(holder, "CoA_ExtraActionBarMover", "Extra Action Bar", nil, nil, nil, "ALL,COA", nil, "CoA,skin,extraActionBar")
holder:SetAllPoints(_G["CoA_ExtraActionBarMover"])
local function Anchor() local function Anchor()
container:ClearAllPoints() container:ClearAllPoints()
@@ -191,8 +204,6 @@ local function SetupMover(container, button)
else else
Anchor() Anchor()
end end
return true
end end
local function TryHook() local function TryHook()
@@ -201,13 +212,17 @@ local function TryHook()
if container and button then if container and button then
SkinButton(button, container) SkinButton(button, container)
return SetupMover(container, button) SetupContainer(container)
AnchorContainer(container)
return true
end end
return false return false
end end
function CoA:InitializeExtraActionBar() function CoA:InitializeExtraActionBar()
CreateMoverHolder()
if TryHook() then return end if TryHook() then return end
self.extraActionBarTimer = self:ScheduleRepeatingTimer(function() self.extraActionBarTimer = self:ScheduleRepeatingTimer(function()
+28 -15
View File
@@ -19,8 +19,17 @@ local function UpdateFont(button)
) )
end end
local function SyncHolderSize(button)
local holder = _G["CoA_LayerPickerHolder"]
button = button or _G[BUTTON_NAME]
if holder and button then
holder:Size(button:GetSize())
end
end
function CoA:UpdateInstanceButtonFont() function CoA:UpdateInstanceButtonFont()
UpdateFont() UpdateFont()
SyncHolderSize()
end end
-- Hooked at file scope, so unlike the rest of the skin it stays live even when -- Hooked at file scope, so unlike the rest of the skin it stays live even when
@@ -69,24 +78,26 @@ local function DisableDrag(button)
end) end)
end end
local function SetupMover(button) -- Created eagerly (independent of the native LayerPickerFrame ever showing
if CoA.layerPickerMoverCreated then return true end -- up) so the mover always appears in Toggle Anchors. The default point below
-- is only used until the player drags it once; after that E:CreateMover
local width, height = button:GetSize() -- restores the saved position from E.db.movers.
if width == 0 or height == 0 then return false end local function CreateMoverHolder()
if CoA.layerPickerMoverCreated then return end
local left, bottom = button:GetLeft(), button:GetBottom()
if not left or not bottom then return false end
CoA.layerPickerMoverCreated = true CoA.layerPickerMoverCreated = true
local holder = CreateFrame("Frame", "CoA_LayerPickerHolder", E.UIParent) local holder = CreateFrame("Frame", "CoA_LayerPickerHolder", E.UIParent)
holder:Size(width, height) holder:Size(MIN_WIDTH, MIN_HEIGHT)
holder:Point("BOTTOMLEFT", E.UIParent, "BOTTOMLEFT", left, bottom) holder:Point("TOPLEFT", E.UIParent, "TOPLEFT", 250, -250)
E:CreateMover(holder, "CoA_LayerPickerMover", "Instance", nil, nil, nil, "ALL,COA", nil, "CoA,skin,instance") E:CreateMover(holder, "CoA_LayerPickerMover", "Instance", nil, nil, nil, "ALL,COA", nil, "CoA,skins,instanceSwap")
holder:SetAllPoints(_G["CoA_LayerPickerMover"]) holder:SetAllPoints(_G["CoA_LayerPickerMover"])
_G["CoA_LayerPickerMover"]:SetFrameStrata("FULLSCREEN") _G["CoA_LayerPickerMover"]:SetFrameStrata("FULLSCREEN")
end
local function AnchorButton(button)
local holder = _G["CoA_LayerPickerHolder"]
if not holder then return end
local function Anchor() local function Anchor()
button:ClearAllPoints() button:ClearAllPoints()
@@ -101,8 +112,6 @@ local function SetupMover(button)
else else
Anchor() Anchor()
end end
return true
end end
local function SkinButton(button) local function SkinButton(button)
@@ -122,13 +131,17 @@ local function TryHook()
if button then if button then
DisableDrag(button) DisableDrag(button)
SkinButton(button) SkinButton(button)
return SetupMover(button) SyncHolderSize(button)
AnchorButton(button)
return true
end end
return false return false
end end
function CoA:InitializeLayerPicker() function CoA:InitializeLayerPicker()
CreateMoverHolder()
if TryHook() then return end if TryHook() then return end
self.layerPickerTimer = self:ScheduleRepeatingTimer(function() self.layerPickerTimer = self:ScheduleRepeatingTimer(function()
+135 -17
View File
@@ -138,31 +138,149 @@ function Skin:HideArt(frame)
frame:Hide() frame:Hide()
end end
-- The dropdown pills on the vanity and wardrobe frames are the same widget: -- Buttons ---------------------------------------------------------------------
-- nine anonymous "Silver-Button" slices rather than 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 and the label with them.
-- --
-- The native mouse-down handler re-arts one of these regions with a pressed -- IsEnabled hands back 0 and 1 on this client rather than false and true
-- variant of the same file on every click, which is why the pill came back -- (probed), and 0 is truthy in Lua, so every enabled check has to normalise
-- skinless while held. SetTexture is noop'd per region after clearing. -- what it gets back before comparing it.
local DROPDOWN_ART = "Silver%-Button" function Skin:IsEnabled(button)
local CARET_ART = "ChatFrameExpandArrow" local state = button.IsEnabled and button:IsEnabled()
local CARET_SIZE = 14
local function StripDropdownArt(dropdown) return state ~= nil and state ~= false and state ~= 0
for i = 1, dropdown:GetNumRegions() do end
local region = select(i, dropdown:GetRegions())
-- 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
-- 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() local texture = region.GetTexture and region:GetTexture()
if texture and tostring(texture):find(DROPDOWN_ART) then if texture and tostring(texture):find(pattern) then
region:SetTexture(nil) region:SetTexture(nil)
region.SetTexture = E.noop region.SetTexture = E.noop
end end
end 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 caret is retextured after HandleButton, not before: HandleButton strips
-- the pill, and a caret replaced ahead of that gets cleared straight back off. -- the pill, and a caret replaced ahead of that gets cleared straight back off.
local function SkinDropdownCaret(dropdown) local function SkinDropdownCaret(dropdown)
@@ -191,8 +309,8 @@ function Skin:Dropdown(dropdown, menu)
if not dropdown or dropdown.CoASkinned then return end if not dropdown or dropdown.CoASkinned then return end
dropdown.CoASkinned = true dropdown.CoASkinned = true
StripDropdownArt(dropdown) self:StripArtByFile(dropdown, DROPDOWN_ART)
S:HandleButton(dropdown) self:Button(dropdown)
SkinDropdownCaret(dropdown) SkinDropdownCaret(dropdown)
self:Panel(menu) self:Panel(menu)
+109 -3
View File
@@ -56,7 +56,7 @@ function SkinChildren(frame, depth)
-- "...MenuClose", and it was coming out of the walk as an -- "...MenuClose", and it was coming out of the walk as an
-- ordinary templated square with the X stripped off it. -- ordinary templated square with the X stripped off it.
if not (name and name:find("Close")) then if not (name and name:find("Close")) then
S:HandleButton(child) Skin:Button(child)
end end
elseif objType == "Frame" then elseif objType == "Frame" then
if IsDropDown(child, name) then if IsDropDown(child, name) then
@@ -311,6 +311,111 @@ function SkinMenuRow(row)
UpdateRowSelection(row) UpdateRowSelection(row)
end end
-- The Activate button inside an expanded specialization row draws its pill from
-- a single atlas across three BACKGROUND regions plus a HIGHLIGHT one, and
-- switches between the green and the grey variant by tex coord. There are no
-- Normal/Pushed/Disabled textures for HandleButton to clear (probed: both come
-- back nil, and every region's vertex colour is white in either state), so the
-- art is cleared by file the way the dropdown pills are (Skin:StripArtByFile,
-- which noops SetTexture per region as well -- whatever swaps the tex coord on
-- a state change is free to re-art the region), and the enabled/disabled
-- distinction the atlas was carrying has to be re-created on the label.
-- GetFontString covers the templated case; the scan is for a label that was
-- added as a plain region rather than set as the button's own font string.
local function ActivateLabel(button)
if button.CoALabel then return button.CoALabel end
local text = button.GetFontString and button:GetFontString()
if not text then
for i = 1, button:GetNumRegions() do
local region = select(i, button:GetRegions())
if region:GetObjectType() == "FontString" then
text = region
break
end
end
end
button.CoALabel = text
return text
end
-- Yellow live, grey dead: the same pair ElvUI's own templated buttons use, so
-- these read as buttons rather than as labels on a panel.
local ACTIVATE_COLOR = {1, 0.82, 0}
local ACTIVATE_DISABLED_COLOR = {0.55, 0.55, 0.55}
-- The active spec's button is disabled, and with the pill gone nothing else
-- says so -- the ElvUI panel is drawn the same either way. The label is greyed
-- instead, which is how ElvUI marks a dead button everywhere else.
--
-- The enabled state is compared against the cached one rather than written
-- blind because this runs from the button's update: there's no event for a spec
-- becoming active. Skin:IsEnabled does the 0/1-to-boolean normalisation this
-- client needs (see Skinning.lua).
local function UpdateActivateState(button)
local enabled = Skin:IsEnabled(button)
if enabled == button.CoAActivateEnabled then return end
button.CoAActivateEnabled = enabled
-- Activating a spec disables this button under the very cursor that just
-- clicked it, so its hover border would stay lit until the pointer moved
-- off. This update already watches the state, so it carries the border too.
Skin:RefreshButtonBorder(button)
local text = ActivateLabel(button)
if not text then return end
text:SetTextColor(unpack(enabled and ACTIVATE_COLOR or ACTIVATE_DISABLED_COLOR))
end
-- Stripped before templating, as with the dropdown pills: HandleButton adds its
-- backdrop as regions of this same button, so a strip afterwards takes the
-- backdrop with the pill.
local function SkinActivateButton(button)
if not button then return end
if not button.CoASkinned then
button.CoASkinned = true
Skin:StripArtByFile(button, Skin.RedButtonArt)
Skin:Button(button)
button:HookScript("OnUpdate", UpdateActivateState)
end
UpdateActivateState(button)
end
-- Unlike the row's icon these are named without a literal dot
-- ("Button1ExpandedContent", "...ExpandedContentActivateButton"), read off the
-- frame stack.
--
-- The expanded content exists from the moment the menu is built rather than
-- being created when its row is expanded (probed: present, hidden, before any
-- row had been opened), so it's picked up with the rest of the row. The show
-- hook is there for a row whose content is filled in later than this first pass.
local function SkinRowExpansion(rowName)
local content = _G[rowName.."ExpandedContent"]
if not content then return end
local buttonName = rowName.."ExpandedContentActivateButton"
SkinActivateButton(_G[buttonName])
if content.CoASkinned then return end
content.CoASkinned = true
content:HookScript("OnShow", function()
SkinActivateButton(_G[buttonName])
end)
end
-- These children are named with a literal dot ("Button1.SpecIcon"), so they -- These children are named with a literal dot ("Button1.SpecIcon"), so they
-- only come out of _G by string key, never as plain identifiers. -- only come out of _G by string key, never as plain identifiers.
local function SkinMenuRows(listName) local function SkinMenuRows(listName)
@@ -321,6 +426,7 @@ local function SkinMenuRows(listName)
SkinMenuRow(row) SkinMenuRow(row)
SkinMenuRowIcon(_G[rowName..".SpecIcon"]) SkinMenuRowIcon(_G[rowName..".SpecIcon"])
SkinRowExpansion(rowName)
end end
end end
@@ -499,7 +605,7 @@ local function SkinBottomBar()
-- stopped at the arrow, and the dead space to its right is taken out -- 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. -- of the hit rect so it can't swallow clicks meant for those icons.
if dropdown and arrow then if dropdown and arrow then
S:HandleButton(dropdown, nil, nil, true) Skin:Button(dropdown, nil, nil, true)
if dropdown.backdrop then if dropdown.backdrop then
dropdown.backdrop:ClearAllPoints() dropdown.backdrop:ClearAllPoints()
@@ -617,7 +723,7 @@ local function SkinSpecChoices()
local cardName = SPEC_CHOICE:format(i) local cardName = SPEC_CHOICE:format(i)
if not _G[cardName] then break end if not _G[cardName] then break end
S:HandleButton(_G[cardName.."SelectButton"], true) Skin:Button(_G[cardName.."SelectButton"], true)
end end
end end
+2 -2
View File
@@ -17,8 +17,8 @@ local FRAME_NAME = "StoreCollectionFrame"
-- Highlight textures), confirmed by probe -- S:HandleButton's own texture -- Highlight textures), confirmed by probe -- S:HandleButton's own texture
-- clearing handles these directly, no manual stripping needed. -- clearing handles these directly, no manual stripping needed.
local function SkinActionButtons() local function SkinActionButtons()
S:HandleButton(_G[FRAME_NAME.."ActivateStoreButton"]) Skin:Button(_G[FRAME_NAME.."ActivateStoreButton"])
S:HandleButton(_G[FRAME_NAME.."BuyStoreButton"]) Skin:Button(_G[FRAME_NAME.."BuyStoreButton"])
end end
local function SkinSearchBox() local function SkinSearchBox()
+89 -3
View File
@@ -8,9 +8,94 @@ local FRAME_NAME = "AppearanceWardrobeFrame"
-- Standard UIPanelButtonTemplate art, same as Vanity's action buttons -- -- Standard UIPanelButtonTemplate art, same as Vanity's action buttons --
-- S:HandleButton's own texture clearing handles these directly. -- S:HandleButton's own texture clearing handles these directly.
local function SkinActionButtons() local function SkinActionButtons()
S:HandleButton(_G[FRAME_NAME.."PlayerModelSaveOutfitButton"]) Skin:Button(_G[FRAME_NAME.."PlayerModelSaveOutfitButton"])
S:HandleButton(_G[FRAME_NAME.."DisableTransmogButton"]) Skin:Button(_G[FRAME_NAME.."DisableTransmogButton"])
S:HandleButton(_G[FRAME_NAME.."DisableSpellVisualsButton"]) Skin:Button(_G[FRAME_NAME.."DisableSpellVisualsButton"])
end
-- Apply, Cancel and Clear invalid slots only exist while a transmog change is
-- pending, which is why they were missed for so long -- but they exist from the
-- start rather than being created on the first change (probed: they are
-- Buttons, hidden, with nothing pending), so the normal skin pass reaches them.
--
-- Same widget as the talent frame's Activate button: the pill is drawn across
-- plain regions, with no Normal/Pushed texture for HandleButton to clear
-- (probed: they come back nil). Apply and Clear invalid slots take the green
-- variant of the gold atlas, Cancel is drawn from the red file instead --
-- Skin.RedButtonArt matches both files, and Cancel's HIGHLIGHT region goes with
-- the rest of its art.
--
-- The labels don't carry the atlas's green/red split over: the buttons sit in a
-- row and read apart by their text, and they take the same yellow every other
-- templated button on these frames uses -- the talent frame's live Activate
-- label included -- so a pending change doesn't get its own colour scheme. The
-- grey is the disabled half of that same pair.
local LABEL_COLOR = {1, 0.82, 0}
local DISABLED_LABEL_COLOR = {0.55, 0.55, 0.55}
-- Apply is disabled for as long as an invalid slot is pending, and with the
-- pill gone nothing else says so -- the ElvUI panel is drawn the same either
-- way. The label is greyed instead, exactly as the talent frame does it for the
-- active spec's Activate button.
--
-- Driven from the button's update because there's no event for the state
-- change: clearing the invalid slots enables Apply on the spot. The cached
-- state is what keeps that cheap -- the colours are only written when the state
-- actually flips.
local function UpdatePendingButtonState(button)
local enabled = Skin:IsEnabled(button)
if enabled == button.CoAPendingEnabled then return end
button.CoAPendingEnabled = enabled
-- Clearing the invalid slots enables Apply under the very cursor that just
-- clicked Clear invalid slots, so its hover border would stay dark until
-- the pointer moved off. This update already watches the state, so it
-- carries the border too.
Skin:RefreshButtonBorder(button)
local text = button.GetFontString and button:GetFontString()
if text then text:SetTextColor(unpack(enabled and LABEL_COLOR or DISABLED_LABEL_COLOR)) end
end
-- The strip runs on every pass rather than once behind the skinned guard: the
-- art is only there to be cleared once a change is pending, and nothing fires
-- the frame's OnShow at that point -- the window is already open. The buttons'
-- own OnShow is what catches it, since that is exactly when they appear.
local function SkinPendingButton(name)
local button = _G[name]
if not button then return end
Skin:StripArtByFile(button, Skin.RedButtonArt)
if not button.CoASkinned then
button.CoASkinned = true
-- Stripped before templating: HandleButton adds its backdrop as regions
-- of this same button, so a strip afterwards takes the backdrop with the
-- pill.
Skin:Button(button)
button:HookScript("OnShow", function(self)
Skin:StripArtByFile(self, Skin.RedButtonArt)
end)
button:HookScript("OnUpdate", UpdatePendingButtonState)
end
UpdatePendingButtonState(button)
end
local PENDING_BUTTONS = {
"PlayerModelApplyButton",
"PlayerModelCancelButton",
"PlayerModelClearInvalidButton",
}
local function SkinPendingButtons()
for _, suffix in ipairs(PENDING_BUTTONS) do
SkinPendingButton(FRAME_NAME..suffix)
end
end end
local function SkinSearchBox() local function SkinSearchBox()
@@ -59,6 +144,7 @@ local function SkinContents()
Skin:Title(_G[FRAME_NAME.."TitleText"]) Skin:Title(_G[FRAME_NAME.."TitleText"])
Skin:CloseButton(_G[FRAME_NAME.."CloseButton"]) Skin:CloseButton(_G[FRAME_NAME.."CloseButton"])
SkinActionButtons() SkinActionButtons()
SkinPendingButtons()
SkinSearchBox() SkinSearchBox()
SkinCollectionDropdown("Filter") SkinCollectionDropdown("Filter")
SkinCollectionDropdown("Sorting") SkinCollectionDropdown("Sorting")