Compare commits

...

36 Commits

Author SHA1 Message Date
Narcasung e66a7a6354 Always skin the options the CoA client adds
That pass only fills the gaps ElvUI's own Blizzard options skin leaves,
so a switch of its own was never a meaningful choice: it follows
E.private.skins.blizzard.enable, the same gate ElvUI checks.
2026-08-22 16:59:59 +02:00
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
Narcasung 013fe665ab Skin the Challenges frame's Gamemodes tab
The Trials shape a third time, and again suffix for suffix: the search
box and list container are named off the tab exactly as Trials names
its own, the list's only region is the same store divider, and the
scroll arrows hang off the scroll frame rather than off the bar. So the
divider constant is shared rather than copied and the three calls go in
unchanged.

The one difference is the filter, which this tab does not have. Its two
children are the search box and the list and neither is a menu, so
there is nothing for a Dropdown call to be handed. That is written into
the comment rather than left implicit: with the same function sitting
three times over in the file, a missing fourth line otherwise reads as
an oversight.

The tab owns no regions of its own at all, so nothing here needs the
keepTextures care the Rewards tab's PageText took. Both children exist
on a cold reload, before the tab has ever been opened, and the counts
are unchanged after a click, so it rides SkinContents with the rest
rather than needing a hook on its own OnShow.

Everything reuses an existing helper, so the shared layer is unchanged.
2026-08-22 16:50:05 +02:00
Narcasung d9476af220 Skin the Challenges frame's Challenges tab
The tab is the Trials tab again in every detail that matters: the same
three children under the same suffixes, the same store divider as its
list container's only region, the same silver-button filter, and its
scroll arrows named off the scroll frame rather than off the bar. So it
takes the same four calls, sharing the divider constant rather than
copying it.

Everything exists before the tab has ever been opened, so it goes
through SkinContents with the rest and needs no hook of its own.
2026-08-22 16:47:04 +02:00
Narcasung cafc3ce28f Skin the Challenges frame's Rewards tab
The tab's twenty-nine children all exist on a cold reload, before it has
ever been opened, so this goes through SkinContents with the rest of the
window rather than needing a hook on the tab's own OnShow -- which a tab
switch would have wanted, since switching tabs fires nothing.

None of the Trials tab's naming carries over. Its search box is
TrialsTabSearch and its filter a FilterDropDown, where these are
StoreTabSearchBox and Filter, so every suffix was confirmed rather than
copied and the divergence is written down for the four tabs still to do.

The Filter is the one widget that had to be read twice. Structurally it
is a plain Button -- no Middle texture, no Button child, no Text
fontstring -- so the test the Interface Options sweep settled on says it
is not a dropdown. By art it plainly is: the same nine Silver-Button
slices and ChatFrameExpandArrow caret the Trials filter wears. Dropdown
matches on the art rather than on the naming, so it takes it unchanged;
the structural test is only ever a shortcut to that question and here it
answers it wrongly.

The grid is a fixed pool of twenty-one cards built up front, not one
built per page, so a single pass covers every card the tab will ever
show and nothing hangs off a page-change hook. The icon crop is the
exception and is re-applied from a hook on the texture's SetTexture: the
store refills the pool in place on every search, filter and page change,
and SetTexture resets a texture's coords, so a one-shot crop survives
exactly until the first page turn and then the native icon borders come
back on every card at once. The slot plate underneath needs no such
watch -- StripArtByFile noops its SetTexture, so a refill that re-arts it
writes nothing.

Cards take HandleButton's useCreateBackdrop path rather than its default
SetTemplate one, which is what makes their padding possible: a template
is drawn as regions of the button and can only ever match its rect, and
the rect is tight -- 140x32 around a 32px icon, so the icon met the top
and bottom edges and the name ran to the right one. A backdrop is a
child frame that can be set outside it. Same reasoning as the window
panel's, one level down. The hover colouring follows either way, since
it resolves button.backdrop before the button itself.

The two headings above the grid are Buttons by type but captions by
function, so their atlas art comes off and nothing goes back: a backdrop
would draw a box around a caption, and the HIGHLIGHT layer would light
it up under a cursor with nothing behind it. They are walked off the
layout rather than named, the set being one per reward vendor.

Left alone deliberately: the Currency button, which carries the trophy
icon and its counter as its only two regions and no frame art at all,
and PageText, which is a region of the tab itself -- the same trap as
the vanity store's counters. Nothing here blind-strips the tab. No
scroll bar is wired either; the grid pages rather than scrolls and has
no scroll frame to hang one off.

Everything reuses an existing helper, so the shared layer is unchanged.
2026-08-22 16:43:00 +02:00
Narcasung a8541fa1ee Skin the Challenges window, its tab row and the Trials tab
ChallengesFrame is a HIGH-strata window parented straight to UIParent and
mouse-enabled itself, not one of the three that hang off the Collections
container -- so it gets its own module and its own enable switch rather
than joining the collections group, and it deliberately skips
ApplyWindowScale and CollectionTabs: the first scales Collections, which
would resize the other three windows and not this one, and the second
skins the tab row those three share.

The panel is built as a backdrop instead of through SetTemplate. A
template's backdrop is drawn as regions of the frame and can only match
its rect, and the title sits above that rect -- the native art carries a
banner up there -- so a panel sized to the frame left the title floating
over the open world. A backdrop has points of its own, so its top edge is
pushed up over the title, measured live rather than hardcoded, with the
hit rect and the close button following it. The nine-slice is stripped
rather than run through HideArt for a related reason: the title is one of
its regions, and Hide() would take it down with the border.

The tab row is the first stock Blizzard flavour the plugin skins, so the
shared layer grows to cover it. TAB_TEXTURES picks up the Middle pieces
those name their body after, and the OnUpdate art re-check watches the
Disabled set as well -- that pair is how a stock tab draws its selected
state, so watching only the inactive half missed art returning on
whichever tab was open. Native panel tabs also interlock, each anchored
16px back into the one before it so the ornate end caps overlap, which
reads as one merged bar once the art is gone; the overlap is absorbed by
narrowing each tab rather than by spacing the row out, which would have
grown it 80px past a frame with 16px of slack. The row is pulled up flush
against the panel it belongs to, since the native tops that spanned that
gap went with the art.

Inside the Trials tab: the search box, the filter dropdown and the scroll
bar. The dropdown is the same widget as the vanity and wardrobe ones and
takes the existing handler unchanged. The search box needs its border
cleared by file, not stripped -- an EditBox draws its caret and selection
highlight as plain textures of its own, and a blind strip leaves a box
you can type in with no cursor in it.

The scroll bar needed a handler of its own. HandleScrollBar reaches for
the thumb through GetThumbTexture and then calls SetTexture on it, but
these bars are hand-built: the thumb is a Button with art of its own, and
the arrows are named off the scroll frame rather than off the bar, so it
finds neither. Both pieces are also arted lazily -- at skin time their
textures read back nil, which is why clearing them, noop'ing their
SetTexture and alpha'ing them all failed in turn -- so the art is
suppressed from OnUpdate, the same answer the tab art needed. Colours
follow ElvUI's own proportional scroll bars, and the track is left empty
rather than given a backdrop.
2026-08-22 16:32:37 +02:00
Narcasung 1a62b4ed1f Skin the options the CoA client adds to Interface, Video and Audio
ElvUI skins the stock options windows from four hardcoded name lists, so
every widget the client added since upstream last synced stays native --
53 of them here, 39 in four panels upstream never enumerated at all
(Battlenet, RaidEffects, Stereo, Voice). Reported cases were the Auto
Accept Trades checkbox and the Spell Queue Window slider.

Sweep the three panel containers on show and skin by object type instead
of carrying a parallel name list that would go stale the same way.

Panels belonging to other addons are skipped by matching frames against
INTERFACEOPTIONS_ADDONCATEGORIES, rebuilt on every sweep so a
load-on-demand addon that registers late is still caught.

Two guards worth naming. Sliders are gated on frame.template because
HandleSliderFrame has no idempotence check of its own and HookScript
stacks, so a second pass over one ElvUI already skinned would run its
OnDisable/OnEnable handlers twice. Dropdowns are detected structurally,
by a Middle texture plus a Button, because HandleDropDownBox uses that
button unguarded and a false positive is a Lua error rather than a
cosmetic miss.

No Button branch: nothing needs one, and it would flatten other addons'
panel tabs into ElvUI pill buttons.
2026-08-22 15:57:21 +02:00
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
Narcasung d2005c4725 Detect the dispel talents instead of asking for them
The three talents that grant an extra dispel type were gated behind
manual checkboxes because the talent state looked unreadable on this
server. It isn't: C_CharacterAdvancement.GetTalentRankByID reports a
live rank for the player, entry IDs are shared across every class tree
so querying a foreign one just reports rank 0, and
CHARACTER_ADVANCEMENT_UPDATE_ENTRIES_RESULT fires when a build is
confirmed.

Key the talents to their entry IDs and read them instead. The lookup is
cached, since PostUpdate_DebuffHighlight runs once per aura per frame,
and refreshed on build confirmation plus the existing delayed refresh
after login. Every call is guarded, so a missing API reads as "talent
not taken" and behaves as it did before.

The three checkboxes are gone, replaced by a single Detect Talents
switch that turns the whole lookup off.
2026-08-18 15:41:13 +02:00
Narcasung 76413b6828 update readme for the talent tab implementation 2026-08-18 13:29:23 +02:00
Narcasung 01f1f5c7c3 Centre wardrobe tab labels and name the skin for what it covers
The label nudge in the shared tab handler exists to clear the icon on the
talent window's tabs, where the native anchor sits tight against it and
grown text would collide. The wardrobe's category tabs carry no icon and
centre their label, so the same nudge pushed every one of them off to the
right. It's now applied only to tabs that actually have a visible icon.

The settings entry covers three windows, so it's named for all three:
Advancement/Vanity/Wardrobe. The keys behind it move with it -- the db
table is skins.collections, after Collections, the container the three
windows and the tab row all hang off, and talentScale becomes
advancementScale. Nothing here has been released, so there's no stored
setting to migrate.
2026-08-17 22:41:25 +02:00
Narcasung 964f88559d Add a Talents skin entry with an enable switch and per-window scale
The talent window, the Vanity and Wardrobe windows it switches between and
the tab row along its bottom are one feature to the player, so they get one
entry in the Skins tree and one master switch gating all three
Initialize calls together. Skinning one of them without the others reads as
a bug.

Scale is a slider per window, 0.5 to 1.5, applied as a multiplier on the
existing scale rather than an absolute -- the three don't ship at the same
size (the vanity store's frame runs about 9% larger than the others), and
that is the server's choice to keep. A setting of 1.0 leaves everything
exactly as it was.

The scale is set on Collections, not on the window itself. All three
windows and the tab row are its children, and it is the only one of them
with mouse enabled -- it is what the player drags. Scaling a window
directly left the drag target at full size, so a shrunk window had to be
grabbed by clicking outside itself, and the tab row, not being a child of
any window, kept whichever size the first-opened one gave it. Scaling the
container moves its hit area with it and the tabs follow as children.
2026-08-17 22:37:53 +02:00
Narcasung 766e561839 Share one skinning path across the three CoA frames
The talent, vanity and wardrobe frames are built from the same handful of
widget templates, but each module carried its own copy of the handling:
close buttons, panels, dropdown pills, tabs and the ADDON_LOADED loader
were all written out three times. The copies had drifted -- tab backdrop
insets of 3 against 2, tabs grown in one frame and not the other, the
wardrobe clearing only three of the six tab textures -- and that drift is
what reads in-game as the same control looking different depending on which
tab you're on.

Modules/Skinning.lua now owns each of those behaviours once, and the three
modules call into it. Where the copies disagreed the values are unified;
tab growth is gated on the tab actually having a label, since growing an
icon-only tab just pushes its icon off the layout, and the SetChecked hook
is guarded because hooksecurefunc errors outright on a missing method.

Titles are pinned to Arial Narrow at 13: the vanity store titled in Friz
Quadrata while the other two used Arial Narrow. Frame scale is left as the
server set it, so the vanity title still renders slightly larger than the
others.

Two things deliberately not shared: the vanity frame still skips
StripTextures, since its currency counters are regions of the frame itself,
and the talent frame's bottom-bar dropdowns stay on their own path -- they
are a different widget, a native dropdown plus a separate caret button.
2026-08-17 22:19:50 +02:00
Narcasung 083cbcbf9c Group frame skins under their own settings tab
The CoA options were a flat row of tabs, one per feature, which doesn't
survive the number of frame skins now landing. Skins get their own tab,
first in the row, laid out as a vertical tree so each skinned frame is one
entry rather than one more horizontal tab. Extra Action Button and Instance
Swap move under it.

Each skin also gets a master enable toggle. Skinning is one-way -- the
native art is stripped and replaced in place -- so turning one off can only
take effect on the next load, hence the reload prompt and the gate in
CoA:Initialize rather than an attempt to undo the skin live. Existing users
see no change: both default to on.

LayerPicker's UIDropDownMenu_AddButton wrapper is hooked at file scope,
outside that gate, so it checks the toggle itself. That one does respect
the switch immediately.
2026-08-17 22:19:39 +02:00
Narcasung dc263a2419 Skin the CoA Wardrobe tab (AppearanceWardrobeFrame)
Outer panel, close button, and the top-left portrait medallion get the
same first-pass treatment as Vanity/Talent, plus the item collection
panel: native background/border art stripped from Collection and its
InsetOverlay/ShadowOverlay decoration, the 3D model preview's vanilla
border removed, and the action buttons, search box, Filter/Order By
dropdowns, page arrows, and category tabs skinned to match.

Left native for a later pass: the icon grid contents, item slot
buttons, and character model itself.
2026-08-17 21:54:30 +02:00
Narcasung 7862871e3f Kill first-render flicker on Talent/Vanity Blizzard-frame skins
The 0.5s ScheduleRepeatingTimer poll that caught these frames couldn't
catch them before their first-ever :Show() this session, since they're
created on-demand the instant the player opens them -- native art got
one frame of paint before the poll's next tick could skin it.

Switched to a generic ADDON_LOADED listener instead: it fires
synchronously (before control returns to whatever opens the frame)
the moment the owning addon finishes loading, so skinning now lands
before the frame is ever shown. Confirmed in-game via an ADDON_LOADED
probe on the talent frame's addon before implementing.

Also fixes a regression this introduced: TalentFrame's CropBackground
was caching the background texture's "original" tex coords on its
first-ever call for later crop math. That first call now happens
pre-Show, before Blizzard's own code sets the texture's real art-tile
region, so it was permanently baking in the XML-template default
(full 0..1) as the baseline. Moved the crop to run only from OnShow,
where the real coords are already set.
2026-08-17 21:24:42 +02:00
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
Narcasung b14753491c Skin the CoA Vanity tab (StoreCollectionFrame)
Skins the outer window, close button, search box, Filter dropdown
(and its arrow and popup panel), Purchase/Deliver buttons, and the
grid pager arrows. Removes nothing else -- the portrait originally
suspected at the top-left corner turned out not to exist as a
separate element, and the card grid/preview panel are left native.
2026-08-17 20:17:25 +02:00
Narcasung 2736cc0065 Give each character its own settings profile
AceDB:New was passed true as defaultProfile, which the library resolves to the
literal profile name "Default", so profile selection fell back to a single
shared profile for every character instead of to the character key. Changing a
setting on one character changed it everywhere.

Dropping the argument restores AceDB's per-character fallback. Characters
already sitting on the shared profile are moved to their own on next login,
with the shared settings copied across so nothing is lost.
2026-08-17 17:20:26 +02:00
Narcasung 0b9b0c5afa Skin the CoA talent frame's Character Advancement tab
Flattens the custom talent window into the ElvUI look: frame chrome, close
buttons, tabs, bottom bar, dropdowns and their popups, the list scrollbars,
and the spec choice cards' action buttons. The talent tree itself is left
alone -- its node borders encode rank and availability.

Most of the work is in keeping the skin applied. The frames restore their
native art on refresh in three different ways, and none of them fire an
event to hook:

- The scroll arrows re-show a region StripTextures had hidden, so the arrow
  buttons re-hide it from their own update, matching by file so ElvUI's
  panel pieces (which are regions of the same button on this client) survive.
- The pooled menu rows put their hover and selection art back when a scroll
  refills them, without firing OnShow, so the strip runs from the row update
  behind a cheap texture compare.
- The talent frame's close button keeps its absolute frame level while
  TreeView rides along when the frame is raised, so the level bump re-applies
  on every show.

Row fills and the selection edge are textures on the row rather than an ElvUI
backdrop: the backdrop is a child frame and covered the row's icon and text at
every frame level available to it.
2026-08-17 17:15:29 +02:00
Narcasung 948ac29aaf Fix README installation path formatting 2026-07-19 01:41:41 +02:00
Narcasung 015891449e Group CoA anchors under a dedicated CoA category in Toggle Anchors
Registers a COA entry into ElvUI's config-mode dropdown (General/ActionBars/etc.) and tags the Class Resources, Extra Action Bar, and Instance Swap movers with it, so they no longer surface mixed into ElvUI's General/ActionBars anchor filters.
2026-07-19 01:41:04 +02:00
Narcasung 284f26b8b4 Update README with implemented features and install instructions 2026-07-18 16:17:50 +02:00
Narcasung fd2ee1903c Defer AceDB initialization until ADDON_LOADED to fix settings not persisting
CoA.db was created at file-load time, racing ahead of this client's
SavedVariables restoration and silently orphaning the profile table
from the real global before it ever got populated from disk.
2026-07-16 16:26:18 +02:00
Narcasung bf9972e624 Fix tooltip drag hint, force-show crash, and Class Resources tab layout
Replace the "Drag to move" tooltip hint on class resource frames instead
of removing it wholesale, so text sharing the same line (e.g. "Right-click
for options") survives; match by substring rather than exact text and scope
by IsMouseOver since GameTooltip's owner can be a child region.

Guard against force-showing a class resource frame the game has never
naturally displayed: its underlying data (powerType, maxValue, ...) is
never populated for a class that doesn't use it, and forcing Show() on it
renders as a garbage white bar texture. Same fix as HideCoAUI's
seenNaturalShow guard.

Rename the Class Resources tab's checkboxes to the frames' actual global
names, stack them vertically, and add Hide All / Show All buttons below them.
2026-07-16 15:46:55 +02:00
Narcasung 50824cc0e1 Strip Lock/Unlock Frame entry from class resource frames' right-click menu
Dragging is now permanently disabled on these frames, so the native
Lock/Unlock Frame option is meaningless. Reuses the UIDropDownMenu_AddButton
override pattern already used for LayerPickerFrameDropDown, generalized to
match any of the tracked frames' dropdowns by name convention or parent.
2026-07-16 15:34:06 +02:00
Narcasung 97c4defa51 Add Class Resources and Debuff Highlighting modules; migrate settings to AceDB profiles
Move CoA settings from E.global/E.private into a proper AceDB-3.0 database
(CoA.db, SavedVariables ElvUI_CoADB) with a Profiles options tab, since
ElvUI doesn't ship AceDBOptions-3.0 (vendored under Libraries/).

Add ClassResources.lua: hooks the custom-class resource frames (segment
bar, orb, bar, multi-cast bar) into ElvUI's Toggle Anchors, with a
per-frame hide checkbox in a new "Class Resources" options tab.

Add DispelHighlight.lua: highlights dispellable debuffs on unitframes,
with talent-aware filtering for custom classes.

Fix mover/anchor reliability for Class Resources and the Instance
(LayerPicker) button:
- Movers were only retried until the target frame existed, not until the
  mover was actually created, so frames that start at zero size (e.g. a
  resource bar before that resource is ever active) permanently lost
  their mover.
- Class resource frames re-anchor themselves on refresh, stomping the
  mover's anchor; hook SetPoint to snap back to the mover holder whenever
  something else repositions the frame.
- Native click-drag on these frames ends with the engine calling SetPoint
  directly, severing the mover anchor entirely. Permanently block drag
  (script and RegisterForDrag) instead of clearing it once.
2026-07-16 15:24:12 +02:00
Narcasung 3a5344e31f Scale Extra Action Button rim border and hotkey inset with button size
The vanilla border baked into the icon texture grew visible again at
large button sizes since the covering rim used a fixed edge thickness.
Both the rim edge and the hotkey's corner inset now scale with the
button size to keep the border hidden and the hotkey clear of the rim
at any size.
2026-07-13 20:16:12 +02:00
Narcasung d769cebbbe Add Instance button font options and increase padding; store CoA settings account-wide via E.global 2026-07-13 18:27:51 +02:00
Narcasung d5b67920a6 Add Extra Action Button keybind with hotkey text, resizable size slider, and recentered anchor 2026-07-13 18:11:07 +02:00
15 changed files with 4112 additions and 54 deletions
+3
View File
@@ -0,0 +1,3 @@
<Bindings>
<Binding name="CLICK ExtraActionBarPoolFrameExtraActionButtonTemplate1:LeftButton" header="BINDING_HEADER_COA"/>
</Bindings>
+11
View File
@@ -5,7 +5,18 @@
## Notes: Hides and skins custom CoA-server frames for ElvUI. ## Notes: Hides and skins custom CoA-server frames for ElvUI.
## RequiredDeps: ElvUI ## RequiredDeps: ElvUI
## X-IconTexture: Interface\AddOns\ElvUI\Media\ElvUILogo ## X-IconTexture: Interface\AddOns\ElvUI\Media\ElvUILogo
## SavedVariables: ElvUI_CoADB
Bindings.xml
Libraries\AceDBOptions-3.0.lua
core.lua core.lua
Modules\ExtraActionBar.lua Modules\ExtraActionBar.lua
Modules\LayerPicker.lua Modules\LayerPicker.lua
Modules\DispelHighlight.lua
Modules\ClassResources.lua
Modules\Skinning.lua
Modules\TalentFrame.lua
Modules\VanityFrame.lua
Modules\WardrobeFrame.lua
Modules\ChallengesFrame.lua
Modules\InterfaceOptions.lua
+460
View File
@@ -0,0 +1,460 @@
--- AceDBOptions-3.0 provides a universal AceConfig options screen for managing AceDB-3.0 profiles.
-- @class file
-- @name AceDBOptions-3.0
-- @release $Id$
local ACEDBO_MAJOR, ACEDBO_MINOR = "AceDBOptions-3.0", 15
local AceDBOptions = LibStub:NewLibrary(ACEDBO_MAJOR, ACEDBO_MINOR)
if not AceDBOptions then return end -- No upgrade needed
-- Lua APIs
local pairs, next = pairs, next
-- WoW APIs
local UnitClass = UnitClass
-- Global vars/functions that we don't upvalue since they might get hooked, or upgraded
-- List them here for Mikk's FindGlobals script
-- GLOBALS: NORMAL_FONT_COLOR_CODE, FONT_COLOR_CODE_CLOSE
AceDBOptions.optionTables = AceDBOptions.optionTables or {}
AceDBOptions.handlers = AceDBOptions.handlers or {}
--[[
Localization of AceDBOptions-3.0
]]
local L = {
choose = "Existing Profiles",
choose_desc = "You can either create a new profile by entering a name in the editbox, or choose one of the already existing profiles.",
choose_sub = "Select one of your currently available profiles.",
copy = "Copy From",
copy_desc = "Copy the settings from one existing profile into the currently active profile.",
current = "Current Profile:",
default = "Default",
delete = "Delete a Profile",
delete_confirm = "Are you sure you want to delete the selected profile?",
delete_desc = "Delete existing and unused profiles from the database to save space, and cleanup the SavedVariables file.",
delete_sub = "Deletes a profile from the database.",
intro = "You can change the active database profile, so you can have different settings for every character.",
new = "New",
new_sub = "Create a new empty profile.",
profiles = "Profiles",
profiles_sub = "Manage Profiles",
reset = "Reset Profile",
reset_desc = "Reset the current profile back to its default values, in case your configuration is broken, or you simply want to start over.",
reset_sub = "Reset the current profile to the default",
}
local LOCALE = GetLocale()
if LOCALE == "deDE" then
L["choose"] = "Vorhandene Profile"
L["choose_desc"] = "Du kannst ein neues Profil erstellen, indem du einen neuen Namen in der Eingabebox 'Neu' eingibst, oder wähle eines der vorhandenen Profile aus."
L["choose_sub"] = "Wählt ein bereits vorhandenes Profil aus."
L["copy"] = "Kopieren von..."
L["copy_desc"] = "Kopiere die Einstellungen von einem vorhandenen Profil in das aktive Profil."
L["current"] = "Aktuelles Profil:"
L["default"] = "Standard"
L["delete"] = "Profil löschen"
L["delete_confirm"] = "Willst du das ausgewählte Profil wirklich löschen?"
L["delete_desc"] = "Lösche vorhandene oder unbenutzte Profile aus der Datenbank, um Platz zu sparen und die SavedVariables-Datei 'sauber' zu halten."
L["delete_sub"] = "Löscht ein Profil aus der Datenbank."
L["intro"] = "Hier kannst du das aktive Datenbankprofil ändern, damit du verschiedene Einstellungen für jeden Charakter erstellen kannst, wodurch eine sehr flexible Konfiguration möglich wird."
L["new"] = "Neu"
L["new_sub"] = "Ein neues Profil erstellen."
L["profiles"] = "Profile"
L["profiles_sub"] = "Profile verwalten"
L["reset"] = "Profil zurücksetzen"
L["reset_desc"] = "Setzt das momentane Profil auf Standardwerte zurück, für den Fall, dass mit der Konfiguration etwas schief lief oder weil du einfach neu starten willst."
L["reset_sub"] = "Das aktuelle Profil auf Standard zurücksetzen."
elseif LOCALE == "frFR" then
L["choose"] = "Profils existants"
L["choose_desc"] = "Vous pouvez créer un nouveau profil en entrant un nouveau nom dans la boîte de saisie, ou en choississant un des profils déjà existants."
L["choose_sub"] = "Permet de choisir un des profils déjà disponibles."
L["copy"] = "Copier à partir de"
L["copy_desc"] = "Copie les paramètres d'un profil déjà existant dans le profil actuellement actif."
L["current"] = "Profil actuel :"
L["default"] = "Défaut"
L["delete"] = "Supprimer un profil"
L["delete_confirm"] = "Etes-vous sûr de vouloir supprimer le profil sélectionné ?"
L["delete_desc"] = "Supprime les profils existants inutilisés de la base de données afin de gagner de la place et de nettoyer le fichier SavedVariables."
L["delete_sub"] = "Supprime un profil de la base de données."
L["intro"] = "Vous pouvez changer le profil actuel afin d'avoir des paramètres différents pour chaque personnage, permettant ainsi d'avoir une configuration très flexible."
L["new"] = "Nouveau"
L["new_sub"] = "Créée un nouveau profil vierge."
L["profiles"] = "Profils"
L["profiles_sub"] = "Gestion des profils"
L["reset"] = "Réinitialiser le profil"
L["reset_desc"] = "Réinitialise le profil actuel au cas où votre configuration est corrompue ou si vous voulez tout simplement faire table rase."
L["reset_sub"] = "Réinitialise le profil actuel avec les paramètres par défaut."
elseif LOCALE == "koKR" then
L["choose"] = "저장 중인 프로필"
L["choose_desc"] = "입력창에 새로운 이름을 입력하거나 저장 중인 프로필 중 하나를 선택하여 새로운 프로필을 만들 수 있습니다."
L["choose_sub"] = "현재 이용할 수 있는 프로필 중 하나를 선택합니다."
L["copy"] = "복사해오기"
L["copy_desc"] = "현재 사용 중인 프로필에 선택한 프로필의 설정을 복사합니다."
L["current"] = "현재 프로필:"
L["default"] = "기본값"
L["delete"] = "프로필 삭제"
L["delete_confirm"] = "정말로 선택한 프로필을 삭제할까요?"
L["delete_desc"] = "저장 공간 절약과 SavedVariables 파일의 정리를 위해 데이터베이스에서 사용하지 않는 프로필을 삭제하세요."
L["delete_sub"] = "데이터베이스의 프로필을 삭제합니다."
L["intro"] = "활성 데이터베이스 프로필을 변경할 수 있고, 각 캐릭터 별로 다른 설정을 할 수 있습니다."
L["new"] = "새로운 프로필"
L["new_sub"] = "새로운 프로필을 만듭니다."
L["profiles"] = "프로필"
L["profiles_sub"] = "프로필 관리"
L["reset"] = "프로필 초기화"
L["reset_desc"] = "설정이 깨졌거나 처음부터 다시 설정을 원하는 경우, 현재 프로필을 기본값으로 초기화하세요."
L["reset_sub"] = "현재 프로필을 기본값으로 초기화합니다"
elseif LOCALE == "esES" or LOCALE == "esMX" then
L["choose"] = "Perfiles existentes"
L["choose_desc"] = "Puedes crear un nuevo perfil introduciendo un nombre en el recuadro o puedes seleccionar un perfil de los ya existentes."
L["choose_sub"] = "Selecciona uno de los perfiles disponibles."
L["copy"] = "Copiar de"
L["copy_desc"] = "Copia los ajustes de un perfil existente al perfil actual."
L["current"] = "Perfil actual:"
L["default"] = "Por defecto"
L["delete"] = "Borrar un Perfil"
L["delete_confirm"] = "¿Estas seguro que quieres borrar el perfil seleccionado?"
L["delete_desc"] = "Borra los perfiles existentes y sin uso de la base de datos para ganar espacio y limpiar el archivo SavedVariables."
L["delete_sub"] = "Borra un perfil de la base de datos."
L["intro"] = "Puedes cambiar el perfil activo de tal manera que cada personaje tenga diferentes configuraciones."
L["new"] = "Nuevo"
L["new_sub"] = "Crear un nuevo perfil vacio."
L["profiles"] = "Perfiles"
L["profiles_sub"] = "Manejar Perfiles"
L["reset"] = "Reiniciar Perfil"
L["reset_desc"] = "Reinicia el perfil actual a los valores por defectos, en caso de que se haya estropeado la configuración o quieras volver a empezar de nuevo."
L["reset_sub"] = "Reinicar el perfil actual al de por defecto"
elseif LOCALE == "zhTW" then
L["choose"] = "現有的設定檔"
L["choose_desc"] = "您可以在文字方塊內輸入名字以建立新的設定檔,或是選擇一個現有的設定檔使用。"
L["choose_sub"] = "從當前可用的設定檔裡面選擇一個。"
L["copy"] = "複製自"
L["copy_desc"] = "從一個現有的設定檔,將設定複製到現在使用中的設定檔。"
L["current"] = "目前設定檔:"
L["default"] = "預設"
L["delete"] = "刪除一個設定檔"
L["delete_confirm"] = "確定要刪除所選擇的設定檔嗎?"
L["delete_desc"] = "從資料庫裡刪除不再使用的設定檔,以節省空間,並且清理 SavedVariables 檔案。"
L["delete_sub"] = "從資料庫裡刪除一個設定檔。"
L["intro"] = "您可以從資料庫中選擇一個設定檔來使用,如此就可以讓每個角色使用不同的設定。"
L["new"] = "新建"
L["new_sub"] = "新建一個空的設定檔。"
L["profiles"] = "設定檔"
L["profiles_sub"] = "管理設定檔"
L["reset"] = "重置設定檔"
L["reset_desc"] = "將現用的設定檔重置為預設值;用於設定檔損壞,或者單純想要重來的情況。"
L["reset_sub"] = "將目前的設定檔重置為預設值"
elseif LOCALE == "zhCN" then
L["choose"] = "现有的配置文件"
L["choose_desc"] = "你可以通过在文本框内输入一个名字创立一个新的配置文件,也可以选择一个已经存在的配置文件。"
L["choose_sub"] = "从当前可用的配置文件里面选择一个。"
L["copy"] = "复制自"
L["copy_desc"] = "从当前某个已保存的配置文件复制到当前正使用的配置文件。"
L["current"] = "当前配置文件:"
L["default"] = "默认"
L["delete"] = "删除一个配置文件"
L["delete_confirm"] = "你确定要删除所选择的配置文件么?"
L["delete_desc"] = "从数据库里删除不再使用的配置文件,以节省空间,并且清理SavedVariables文件。"
L["delete_sub"] = "从数据库里删除一个配置文件。"
L["intro"] = "你可以选择一个活动的数据配置文件,这样你的每个角色就可以拥有不同的设置值,可以给你的插件配置带来极大的灵活性。"
L["new"] = "新建"
L["new_sub"] = "新建一个空的配置文件。"
L["profiles"] = "配置文件"
L["profiles_sub"] = "管理配置文件"
L["reset"] = "重置配置文件"
L["reset_desc"] = "将当前的配置文件恢复到它的默认值,用于你的配置文件损坏,或者你只是想重来的情况。"
L["reset_sub"] = "将当前的配置文件恢复为默认值"
elseif LOCALE == "ruRU" then
L["choose"] = "Существующие профили"
L["choose_desc"] = "Вы можете создать новый профиль, введя название в поле ввода, или выбрать один из уже существующих профилей."
L["choose_sub"] = "Выбор одиного из уже доступных профилей"
L["copy"] = "Скопировать из"
L["copy_desc"] = "Скопировать настройки из выбранного профиля в активный."
L["current"] = "Текущий профиль:"
L["default"] = "По умолчанию"
L["delete"] = "Удалить профиль"
L["delete_confirm"] = "Вы уверены, что вы хотите удалить выбранный профиль?"
L["delete_desc"] = "Удалить существующий и неиспользуемый профиль из БД для сохранения места, и очистить SavedVariables файл."
L["delete_sub"] = "Удаление профиля из БД"
L["intro"] = "Изменяя активный профиль, вы можете задать различные настройки модификаций для каждого персонажа."
L["new"] = "Новый"
L["new_sub"] = "Создать новый чистый профиль"
L["profiles"] = "Профили"
L["profiles_sub"] = "Управление профилями"
L["reset"] = "Сброс профиля"
L["reset_desc"] = "Сбросить текущий профиль к стандартным настройкам, если ваша конфигурация испорчена или вы хотите настроить всё заново."
L["reset_sub"] = "Сброс текущего профиля на стандартный"
elseif LOCALE == "itIT" then
L["choose"] = "Profili Esistenti"
L["choose_desc"] = "Puoi creare un nuovo profilo digitando il nome della casella di testo, oppure scegliendone uno tra i profili già esistenti."
L["choose_sub"] = "Seleziona uno dei profili attualmente disponibili."
L["copy"] = "Copia Da"
L["copy_desc"] = "Copia le impostazioni da un profilo esistente, nel profilo attivo in questo momento."
L["current"] = "Profilo Attivo:"
L["default"] = "Standard"
L["delete"] = "Cancella un Profilo"
L["delete_confirm"] = "Sei sicuro di voler cancellare il profilo selezionato?"
L["delete_desc"] = "Cancella i profili non utilizzati dal database per risparmiare spazio e mantenere puliti i file di configurazione SavedVariables."
L["delete_sub"] = "Cancella un profilo dal Database."
L["intro"] = "Puoi cambiare il profilo attivo, in modo da usare impostazioni diverse per ogni personaggio."
L["new"] = "Nuovo"
L["new_sub"] = "Crea un nuovo profilo vuoto."
L["profiles"] = "Profili"
L["profiles_sub"] = "Gestisci Profili"
L["reset"] = "Reimposta Profilo"
L["reset_desc"] = "Riporta il tuo profilo attivo alle sue impostazioni predefinite, nel caso in cui la tua configurazione si sia corrotta, o semplicemente tu voglia re-inizializzarla."
L["reset_sub"] = "Reimposta il profilo ai suoi valori predefiniti."
elseif LOCALE == "ptBR" then
L["choose"] = "Perfis Existentes"
L["choose_desc"] = "Você pode tanto criar um perfil novo tanto digitando um nome na caixa de texto, quanto escolher um dos perfis já existentes."
L["choose_sub"] = "Selecione um de seus perfis atualmente disponíveis."
L["copy"] = "Copiar De"
L["copy_desc"] = "Copia as definições de um perfil existente no perfil atualmente ativo."
L["current"] = "Perfil Autal:"
L["default"] = "Padrão"
L["delete"] = "Remover um Perfil"
L["delete_confirm"] = "Tem certeza que deseja remover o perfil selecionado?"
L["delete_desc"] = "Remove perfis existentes e inutilizados do banco de dados para economizar espaço, e limpar o arquivo SavedVariables."
L["delete_sub"] = "Remove um perfil do banco de dados."
L["intro"] = "Você pode alterar o perfil do banco de dados ativo, para que possa ter definições diferentes para cada personagem."
L["new"] = "Novo"
L["new_sub"] = "Cria um novo perfil vazio."
L["profiles"] = "Perfis"
L["profiles_sub"] = "Gerenciar Perfis"
L["reset"] = "Resetar Perfil"
L["reset_desc"] = "Reseta o perfil atual para os valores padrões, no caso de sua configuração estar quebrada, ou simplesmente se deseja começar novamente."
L["reset_sub"] = "Resetar o perfil atual ao padrão"
end
local defaultProfiles
local tmpprofiles = {}
-- Get a list of available profiles for the specified database.
-- You can specify which profiles to include/exclude in the list using the two boolean parameters listed below.
-- @param db The db object to retrieve the profiles from
-- @param common If true, getProfileList will add the default profiles to the return list, even if they have not been created yet
-- @param nocurrent If true, then getProfileList will not display the current profile in the list
-- @return Hashtable of all profiles with the internal name as keys and the display name as value.
local function getProfileList(db, common, nocurrent)
local profiles = {}
-- copy existing profiles into the table
local currentProfile = db:GetCurrentProfile()
for i,v in pairs(db:GetProfiles(tmpprofiles)) do
if not (nocurrent and v == currentProfile) then
profiles[v] = v
end
end
-- add our default profiles to choose from ( or rename existing profiles)
for k,v in pairs(defaultProfiles) do
if (common or profiles[k]) and not (nocurrent and k == currentProfile) then
profiles[k] = v
end
end
return profiles
end
--[[
OptionsHandlerPrototype
prototype class for handling the options in a sane way
]]
local OptionsHandlerPrototype = {}
--[[ Reset the profile ]]
function OptionsHandlerPrototype:Reset()
self.db:ResetProfile()
end
--[[ Set the profile to value ]]
function OptionsHandlerPrototype:SetProfile(info, value)
self.db:SetProfile(value)
end
--[[ returns the currently active profile ]]
function OptionsHandlerPrototype:GetCurrentProfile()
return self.db:GetCurrentProfile()
end
--[[
List all active profiles
you can control the output with the .arg variable
currently four modes are supported
(empty) - return all available profiles
"nocurrent" - returns all available profiles except the currently active profile
"common" - returns all avaialble profiles + some commonly used profiles ("char - realm", "realm", "class", "Default")
"both" - common except the active profile
]]
function OptionsHandlerPrototype:ListProfiles(info)
local arg = info.arg
local profiles
if arg == "common" and not self.noDefaultProfiles then
profiles = getProfileList(self.db, true, nil)
elseif arg == "nocurrent" then
profiles = getProfileList(self.db, nil, true)
elseif arg == "both" then -- currently not used
profiles = getProfileList(self.db, (not self.noDefaultProfiles) and true, true)
else
profiles = getProfileList(self.db)
end
return profiles
end
function OptionsHandlerPrototype:HasNoProfiles(info)
local profiles = self:ListProfiles(info)
return ((not next(profiles)) and true or false)
end
--[[ Copy a profile ]]
function OptionsHandlerPrototype:CopyProfile(info, value)
self.db:CopyProfile(value)
end
--[[ Delete a profile from the db ]]
function OptionsHandlerPrototype:DeleteProfile(info, value)
self.db:DeleteProfile(value)
end
--[[ fill defaultProfiles with some generic values ]]
local function generateDefaultProfiles(db)
defaultProfiles = {
["Default"] = L["default"],
[db.keys.char] = db.keys.char,
[db.keys.realm] = db.keys.realm,
[db.keys.class] = UnitClass("player")
}
end
--[[ create and return a handler object for the db, or upgrade it if it already existed ]]
local function getOptionsHandler(db, noDefaultProfiles)
if not defaultProfiles then
generateDefaultProfiles(db)
end
local handler = AceDBOptions.handlers[db] or { db = db, noDefaultProfiles = noDefaultProfiles }
for k,v in pairs(OptionsHandlerPrototype) do
handler[k] = v
end
AceDBOptions.handlers[db] = handler
return handler
end
--[[
the real options table
]]
local optionsTable = {
desc = {
order = 1,
type = "description",
name = L["intro"] .. "\n",
},
descreset = {
order = 9,
type = "description",
name = L["reset_desc"],
},
reset = {
order = 10,
type = "execute",
name = L["reset"],
desc = L["reset_sub"],
func = "Reset",
},
current = {
order = 11,
type = "description",
name = function(info) return L["current"] .. " " .. NORMAL_FONT_COLOR_CODE .. info.handler:GetCurrentProfile() .. FONT_COLOR_CODE_CLOSE end,
width = "default",
},
choosedesc = {
order = 20,
type = "description",
name = "\n" .. L["choose_desc"],
},
new = {
name = L["new"],
desc = L["new_sub"],
type = "input",
order = 30,
get = false,
set = "SetProfile",
},
choose = {
name = L["choose"],
desc = L["choose_sub"],
type = "select",
order = 40,
get = "GetCurrentProfile",
set = "SetProfile",
values = "ListProfiles",
arg = "common",
},
copydesc = {
order = 50,
type = "description",
name = "\n" .. L["copy_desc"],
},
copyfrom = {
order = 60,
type = "select",
name = L["copy"],
desc = L["copy_desc"],
get = false,
set = "CopyProfile",
values = "ListProfiles",
disabled = "HasNoProfiles",
arg = "nocurrent",
},
deldesc = {
order = 70,
type = "description",
name = "\n" .. L["delete_desc"],
},
delete = {
order = 80,
type = "select",
name = L["delete"],
desc = L["delete_sub"],
get = false,
set = "DeleteProfile",
values = "ListProfiles",
disabled = "HasNoProfiles",
arg = "nocurrent",
confirm = true,
confirmText = L["delete_confirm"],
},
}
--- Get/Create a option table that you can use in your addon to control the profiles of AceDB-3.0.
-- @param db The database object to create the options table for.
-- @return The options table to be used in AceConfig-3.0
-- @usage
-- -- Assuming `options` is your top-level options table and `self.db` is your database:
-- options.args.profiles = LibStub("AceDBOptions-3.0"):GetOptionsTable(self.db)
function AceDBOptions:GetOptionsTable(db, noDefaultProfiles)
local tbl = AceDBOptions.optionTables[db] or {
type = "group",
name = L["profiles"],
desc = L["profiles_sub"],
}
tbl.handler = getOptionsHandler(db, noDefaultProfiles)
tbl.args = optionsTable
AceDBOptions.optionTables[db] = tbl
return tbl
end
-- upgrade existing tables
for db,tbl in pairs(AceDBOptions.optionTables) do
tbl.handler = getOptionsHandler(db)
tbl.args = optionsTable
end
+437
View File
@@ -0,0 +1,437 @@
local E, L, V, P, G = unpack(ElvUI)
local CoA = E:GetModule("CoA")
local Skin = CoA.Skin
local FRAME_NAME = "ChallengesFrame"
-- Unlike the talent/vanity/wardrobe trio, this window is not one of the
-- Collections container's children: it's a HIGH-strata frame parented straight
-- to UIParent, and it's mouse-enabled itself rather than being dragged by a
-- container (probed). So it deliberately doesn't go through
-- Skin:ApplyWindowScale or Skin:CollectionTabs -- the first scales Collections,
-- which would resize the other three windows and not this one, and the second
-- skins the tab row those three share, which this window has no part in. A
-- scale slider for it later would set the scale on the frame itself, and the
-- drag target moves with it because the frame is its own drag target.
-- Stock Blizzard panel tabs, unlike every other tab row the plugin skins: plain
-- Buttons rather than CheckButtons, and their art is named Left/Middle/Right
-- (plus the Disabled set Blizzard swaps in for the selected tab) rather than
-- the server's Left/Center/Right -- both flavours are covered by the shared
-- strip in Skinning.lua. They're real children of the frame, so no level parent
-- is needed: normal parent/child z-order already puts them above its panel.
--
-- Six exist today, two of them gated behind content an account may not have, so
-- the row is walked until the first missing index rather than against a
-- hardcoded count -- same shape as Skin:CollectionTabs.
--
-- This is the one row in the plugin that interlocks: every tab past the first is
-- anchored 16px back into the one before it (probed) so the native end caps
-- overlap, which reads as a single merged bar once the art is gone. Hence the
-- unoverlap flag; the server-authored rows sit apart already and don't take it.
local MAX_TABS = 10
local function SkinTabs()
for i = 1, MAX_TABS do
local tab = _G[FRAME_NAME.."Tab"..i]
if not tab then break end
Skin:Tab(tab, nil, true)
end
end
-- The row hangs 6px clear of the frame's bottom edge (probed), which the native
-- art spanned with the tabs' own ornate tops -- gone with the art, so the row
-- reads as detached from the window it belongs to. Only the first tab carries a
-- vertical offset at all, the rest being anchored off its edge, so moving it
-- moves the row.
--
-- Set to the backdrop inset rather than to zero: the tab's flat backdrop starts
-- that far inside the tab, so a tab flush with the frame still leaves its
-- visible edge floating. Written as an absolute rather than as an adjustment to
-- what's there, which keeps it idempotent across the repeated skin passes.
local function AnchorTabRow()
local tab = _G[FRAME_NAME.."Tab1"]
if not tab then return end
local point, relativeTo, relativePoint, x = tab:GetPoint(1)
if not point then return end
tab:SetPoint(point, relativeTo, relativePoint, x, Skin.TabBackdropInset)
end
-- The nine-slice draws the ornate purple border and the teal inner panel over
-- the top of the frame, doubling up with the Transparent template's own border
-- -- but unlike the talent and wardrobe frames' nine-slices it isn't purely
-- decorative: the window's title is one of its regions (probed: nine BORDER
-- textures, one ARTWORK texture, and the title FontString). So it's stripped
-- rather than run through Skin:HideArt, whose Hide() would take the title down
-- with the border. StripTextures only touches Textures, so the art goes and the
-- FontString stays.
local function StripBorderArt()
local nineSlice = _G[FRAME_NAME.."NineSlice"]
if not nineSlice then return end
nineSlice:StripTextures()
end
-- That title has no name of its own -- there is no ChallengesFrameTitleText,
-- and none of the other usual suffixes resolve either (probed) -- so it's
-- picked out by type, the same way the vanity frame's pager arrows are picked
-- out by position. The nine-slice owns exactly one FontString.
local function GetTitle()
local nineSlice = _G[FRAME_NAME.."NineSlice"]
if not nineSlice then return end
for i = 1, nineSlice:GetNumRegions() do
local region = select(i, nineSlice:GetRegions())
if region:GetObjectType() == "FontString" then return region end
end
end
-- Vertical slack left above the title inside the widened panel.
local TITLE_PADDING = 6
-- The title is drawn above the frame's own top edge -- the native art carries a
-- banner up there that the frame's rect doesn't include -- so a panel sized to
-- the frame leaves the title floating over the open world. The panel is built
-- as a backdrop rather than through SetTemplate for exactly this reason: a
-- template's backdrop is drawn as regions of the frame itself and can only ever
-- match its rect, while a backdrop is a child frame with points of its own, so
-- its top edge can be pushed up to take the title in.
--
-- The distance is measured live rather than hardcoded: it's whatever gap the
-- server's own layout left between the title and the frame, so this stays right
-- if that layout changes. Re-measured on every show for the same reason the tab
-- heights are (see Skinning.lua) -- on a cold /reload the layout isn't settled
-- at skin time, and a one-shot measurement catches a stale value.
--
-- The hit rect is grown to match. The frame is its own drag target (probed), and
-- without this the widened panel would have a band along its top that looks part
-- of the window but can't be grabbed.
local function UpdatePanelTop(frame)
local backdrop = frame.backdrop
local title = GetTitle()
if not (backdrop and title) then return end
local frameTop, titleTop = frame:GetTop(), title:GetTop()
if not (frameTop and titleTop) then return end
local offset = titleTop - frameTop + TITLE_PADDING
if offset < 0 then offset = 0 end
backdrop:Point("TOPLEFT", frame, "TOPLEFT", 0, offset)
backdrop:Point("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0)
-- Negative insets grow the hit rect outwards rather than shrinking it.
frame:SetHitRectInsets(0, 0, -offset, 0)
end
-- Per-tab contents. Each of the six tabs owns its own copy of these widgets
-- rather than sharing one set, so they're skinned per tab; Trials, Rewards,
-- Challenges and Gamemodes are done, and the two left follow the same shape.
--
-- Nothing about one tab's naming carries to the next: the Trials search box is
-- "...TrialsTabSearch" and its filter a "...FilterDropDown", where the Rewards
-- pair are "...StoreTabSearchBox" and "...Filter" (probed). Every suffix on a
-- new tab is worth confirming rather than copying.
local TRIALS_TAB = FRAME_NAME.."TrialsTab"
local TRIALS_LIST = TRIALS_TAB.."Challenges"
local TRIALS_SCROLL = TRIALS_LIST.."ScrollFrame"
-- The list container's one and only region: a 2px rule drawn across the bottom
-- of the panel from the store art (probed). It's the native panel's own divider,
-- and with the panel around it flat there's nothing left for it to divide.
local LIST_DIVIDER_ART = "perksactivities"
local function SkinTrialsTab()
Skin:SearchBox(_G[TRIALS_TAB.."Search"])
-- Cleared by file rather than stripped: the container is the scroll frame's
-- own parent, so a blind strip is a blunter instrument than one known region
-- needs, and StripArtByFile's noop holds if the list ever re-arts it.
Skin:StripArtByFile(_G[TRIALS_LIST], LIST_DIVIDER_ART)
-- The same nine "UI-Silver-Button" slices and ChatFrameExpandArrow caret as
-- the vanity and wardrobe dropdowns (probed), so it takes the same handler.
-- Nothing is passed for its popout: the Trials tab has exactly three children
-- and none of them is a menu (probed), so wherever the list is built it isn't
-- there. Skin:Dropdown skips that half when it's handed nil.
Skin:Dropdown(_G[TRIALS_TAB.."FilterDropDown"])
-- The arrows are named off the scroll frame rather than off the bar, which is
-- the other reason S:HandleScrollBar can't find them on its own.
Skin:ScrollBar(
_G[TRIALS_SCROLL.."ScrollBar"],
_G[TRIALS_SCROLL.."ScrollBarThumb"],
_G[TRIALS_SCROLL.."ScrollUpButton"],
_G[TRIALS_SCROLL.."ScrollDownButton"]
)
end
local STORE_TAB = FRAME_NAME.."StoreTab"
local STORE_LAYOUT = STORE_TAB.."StoreButtonLayout"
-- Walked to the first missing index rather than against the twenty-one that
-- exist today, the same shape as the tab row: this count is a page size, and a
-- page size is exactly the sort of thing a content patch moves.
local MAX_SLOTS = 40
-- The bevelled empty-slot plate every bag and auction square on this client
-- draws, here behind each reward card (probed). The dashes are escaped because
-- StripArtByFile matches with find, where an unescaped dash is a pattern range.
local SLOT_ART = "UI%-AuctionFrame%-ItemSlot"
-- The card's rect is drawn tight around its contents: 140x32 with a 32px icon,
-- so the icon meets the top and bottom edges exactly and the name runs to the
-- right one. The native plate hid that by bleeding a bevel outwards; a flat
-- backdrop on the same rect just looks cramped. The grid leaves far more room
-- than this between cards -- tens of pixels each way -- so the box is grown
-- rather than the contents moved in, which would fight the store's own layout.
local CARD_PADDING = 4
-- The headings above the grid are drawn out of the quest log's atlas by tex
-- coord (probed: three slices plus the button's own NormalTexture, all one
-- file). Cleared by file rather than stripped so the label survives.
local HEADING_ART = "questmaplogatlas"
-- A row rather than an item square: 140x32 with a 32px icon flush left, then
-- the name, then a SimpleHTML cost (probed). The icon is a region of the button
-- itself rather than sitting in a frame of its own, unlike the talent menu's
-- rows, so it takes the crop but not a backdrop of its own.
--
-- The card takes HandleButton's useCreateBackdrop path rather than its default
-- SetTemplate one, which is what makes the padding possible at all: a template
-- is drawn as regions of the button and can only ever match its rect, while a
-- backdrop is a child frame that can be set outside it. Same reasoning as the
-- window panel's, one level down. Skin:Button's hover colouring follows either
-- way -- it resolves button.backdrop before the button itself.
--
-- The native hover square needs nothing by name: it's a plain region on the
-- HIGHLIGHT layer (probed), which is the whole layer Skin:Button's
-- StripHighlightArt takes.
--
-- The crop is re-applied from a hook rather than set once. The grid is a fixed
-- pool the store refills in place on every search, filter and page change, and
-- SetTexture resets a texture's coords -- so a one-shot crop would survive
-- exactly until the first page turn, and the native icon borders would come
-- back on every card at once. The plate underneath needs no such watch:
-- StripArtByFile noops its SetTexture, so a refill that re-arts it writes
-- nothing.
local function SkinStoreCard(slot)
if not slot then return end
Skin:StripArtByFile(slot, SLOT_ART)
Skin:Button(slot, nil, nil, true)
-- Re-set on every pass rather than once: SetOutside writes fixed points, so
-- a UI scale change that moves what a pixel is leaves them stale.
if slot.backdrop then
slot.backdrop:SetOutside(slot, CARD_PADDING, CARD_PADDING)
end
local name = slot:GetName()
local icon = name and _G[name.."Icon"]
if not icon then return end
icon:SetTexCoord(unpack(E.TexCoords))
-- Guarded separately from Skin:Button's own flag, and for the same reason it
-- guards its own: this runs again on every show, and hooksecurefunc stacks
-- handlers rather than replacing them.
if not slot.CoAIconHooked then
slot.CoAIconHooked = true
hooksecurefunc(icon, "SetTexture", function(texture)
texture:SetTexCoord(unpack(E.TexCoords))
end)
end
end
-- "Trial Master's Rewards" and its sibling for the build vendor are Buttons by
-- type, but they aren't buttons: they're the grid's section headings, drawn as
-- a stylised plaque and doing nothing when clicked. So they get their art taken
-- off and nothing put back -- no backdrop, which would draw a box around a
-- caption and read as clickable, and no hover border for the same reason. The
-- HIGHLIGHT layer goes too, or the caption lights up under the cursor with
-- nothing behind it.
--
-- Walked off the layout rather than named, since the set is one per reward
-- vendor and grows with content. Same reasoning as the tab row.
local function SkinStoreHeadings()
local layout = _G[STORE_LAYOUT]
if not layout then return end
for i = 1, layout:GetNumChildren() do
local heading = select(i, layout:GetChildren())
Skin:StripArtByFile(heading, HEADING_ART)
Skin:StripHighlightArt(heading)
end
end
-- Everything on this tab exists before it has ever been opened (probed: the
-- same twenty-nine children on a cold reload as after a click), so it goes
-- through SkinContents with the rest rather than needing a hook on the tab's
-- own OnShow.
--
-- Two things here are deliberately left alone. The Currency button carries the
-- trophy icon and the "Trial Master's Trophy: 0" counter as its only two
-- regions and no frame art at all, so there's nothing to strip and a backdrop
-- would only make a label look clickable. PageText is a FontString region of
-- the tab itself -- the same trap as the vanity store's counters, and the
-- reason Skin:Panel grew keepTextures -- so nothing here blind-strips the tab.
--
-- No scroll bar is wired: the grid pages rather than scrolls, and there's no
-- scroll frame anywhere under the tab to hang one off (probed).
local function SkinStoreTab()
Skin:SearchBox(_G[STORE_TAB.."SearchBox"])
-- Not a dropdown by structure -- no Middle texture, no Button child, no Text
-- fontstring, just a plain Button (probed) -- but it is one by art: the same
-- nine "UI-Silver-Button" slices and ChatFrameExpandArrow caret the Trials
-- filter wears. Skin:Dropdown matches on the art rather than on the naming,
-- so it takes this one too; the Middle-plus-Button structural test is only
-- ever a shortcut to that question, and here it answers it wrongly.
Skin:Dropdown(_G[STORE_TAB.."Filter"])
-- Stock "UI-SquareButton" arrows (probed), unlike the hand-built bar on the
-- Trials list, so these go through the ElvUI handler unaided.
Skin:NextPrevButton(_G[STORE_TAB.."PreviousPageButton"], "left")
Skin:NextPrevButton(_G[STORE_TAB.."NextPageButton"], "right")
SkinStoreHeadings()
for i = 1, MAX_SLOTS do
local slot = _G[STORE_TAB.."Slot"..i]
if not slot then break end
SkinStoreCard(slot)
end
end
local CHALLENGES_TAB = FRAME_NAME.."ChallengesTab"
local CHALLENGES_LIST = CHALLENGES_TAB.."Challenges"
local CHALLENGES_SCROLL = CHALLENGES_LIST.."ScrollFrame"
-- The one tab whose naming does carry over from another: the same three
-- children as Trials, under the same suffixes, with the same store divider as
-- its list container's only region, the same nine "UI-Silver-Button" slices and
-- caret on its filter, and its arrows named off the scroll frame rather than
-- off the bar (probed). So it takes the Trials treatment call for call -- see
-- SkinTrialsTab above for why each of the four is the call it is.
--
-- The divider constant is shared rather than copied: it is the same 2px rule out
-- of the same file, not a second one that happens to match today.
local function SkinChallengesTab()
Skin:SearchBox(_G[CHALLENGES_TAB.."Search"])
Skin:StripArtByFile(_G[CHALLENGES_LIST], LIST_DIVIDER_ART)
Skin:Dropdown(_G[CHALLENGES_TAB.."FilterDropDown"])
Skin:ScrollBar(
_G[CHALLENGES_SCROLL.."ScrollBar"],
_G[CHALLENGES_SCROLL.."ScrollBarThumb"],
_G[CHALLENGES_SCROLL.."ScrollUpButton"],
_G[CHALLENGES_SCROLL.."ScrollDownButton"]
)
end
local GAMEMODES_TAB = FRAME_NAME.."GamemodesTab"
local GAMEMODES_LIST = GAMEMODES_TAB.."Challenges"
local GAMEMODES_SCROLL = GAMEMODES_LIST.."ScrollFrame"
-- The Trials/Challenges shape a third time, and again suffix for suffix: the
-- same search box and list names, the same store divider as its list
-- container's only region, the same arrows named off the scroll frame rather
-- than off the bar (probed). See SkinTrialsTab above for why each call is the
-- call it is.
--
-- What differs is the filter: there isn't one, and that's the tab's own shape
-- rather than something missed here. It has exactly two children, the search
-- box and the list, and neither of them is a menu (probed), so there is nothing
-- for a Skin:Dropdown line to be handed.
--
-- It owns no regions of its own either (probed), so nothing here needs the
-- keepTextures care the Rewards tab's PageText took. And everything under it
-- exists before the tab has ever been opened -- two children and no regions on
-- a cold reload, unchanged after a click -- so it rides SkinContents like the
-- other three rather than needing a hook on the tab's own OnShow.
local function SkinGamemodesTab()
Skin:SearchBox(_G[GAMEMODES_TAB.."Search"])
Skin:StripArtByFile(_G[GAMEMODES_LIST], LIST_DIVIDER_ART)
Skin:ScrollBar(
_G[GAMEMODES_SCROLL.."ScrollBar"],
_G[GAMEMODES_SCROLL.."ScrollBarThumb"],
_G[GAMEMODES_SCROLL.."ScrollUpButton"],
_G[GAMEMODES_SCROLL.."ScrollDownButton"]
)
end
-- The close button is anchored inside the frame's own top-right corner, which
-- stopped being the corner the player sees once the panel grew up over the title
-- band -- it ends up floating a title's height below the top edge. Re-anchored
-- to the panel instead, and re-run on every pass so it follows the panel's top
-- whenever that gets re-measured.
--
-- No offset of its own: Skin:CloseButton normalises the button to a 32px box
-- with a 12px X centred in it, so the margin off the corner is already built in.
local function AnchorCloseButton(frame)
local close = _G[FRAME_NAME.."CloseButton"]
if not (close and frame.backdrop) then return end
close:ClearAllPoints()
close:SetPoint("TOPRIGHT", frame.backdrop, "TOPRIGHT", 0, 0)
end
-- Nothing here re-runs on a tab switch, and nothing needs to: switching tabs
-- doesn't fire the frame's OnShow, and the one thing a switch does disturb is
-- the tab art, which Skin:Tab watches from its own OnUpdate. The title
-- fontstring is reused across tabs rather than swapped for a per-tab one -- its
-- text changes, the object doesn't -- so it only needs the one pass too.
local function SkinContents()
StripBorderArt()
Skin:Title(GetTitle())
UpdatePanelTop(_G[FRAME_NAME])
Skin:CloseButton(_G[FRAME_NAME.."CloseButton"])
AnchorCloseButton(_G[FRAME_NAME])
SkinTabs()
AnchorTabRow()
SkinTrialsTab()
SkinStoreTab()
SkinGamemodesTab()
SkinChallengesTab()
end
local function SkinFrame(frame)
if not frame.CoASkinned then
frame.CoASkinned = true
-- A single BACKGROUND texture is all the frame owns (probed), and it's
-- decorative -- nothing functional is drawn as a region of this frame,
-- unlike the vanity store, whose currency counters are. So it's stripped
-- outright. Stripped before the backdrop is made, not after: a backdrop
-- built first would be a child frame and survive, but the order is kept
-- the same as Skin:Panel's for one less rule to remember.
frame:StripTextures()
frame:CreateBackdrop("Transparent")
frame:HookScript("OnShow", SkinContents)
end
SkinContents()
end
local function TryHook()
local frame = _G[FRAME_NAME]
if not frame then return false end
SkinFrame(frame)
return true
end
function CoA:InitializeChallengesFrame()
if not E.private.skins.blizzard.enable then return end
Skin:OnFrameAvailable(TryHook)
end
+305
View File
@@ -0,0 +1,305 @@
local E, L, V, P, G = unpack(ElvUI)
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 = {
{name = "CoAResourceSegmentBar", moverText = "Resource Segment Bar", hideKey = "hideResourceSegmentBar", defaultY = 40},
{name = "CoAResourceOrb", moverText = "Resource Orb", hideKey = "hideResourceOrb", defaultY = 100},
{name = "CoAResourceBar", moverText = "Resource Bar", hideKey = "hideResourceBar", defaultY = 160},
{name = "CoAMultiCastActionBarFrame", moverText = "Multi Cast Action Bar", hideKey = "hideMultiCastActionBar", defaultY = 220},
}
local DEFAULT_WIDTH, DEFAULT_HEIGHT = 150, 30
local FRAME_NAMES = {}
for _, def in ipairs(FRAMES) do
FRAME_NAMES[def.name] = true
end
-- These frames' native right-click dropdown offers a "Lock/Unlock Frame"
-- entry that's meaningless now that dragging is permanently disabled (see
-- DisableDrag below), so strip it. Matched loosely (case-insensitive,
-- requiring both words) since the exact wording isn't confirmed, and scoped
-- to only our tracked frames' dropdowns so it can't affect unrelated menus.
do
local function IsTrackedDropdown(menu)
if not menu then return false end
local name = menu.GetName and menu:GetName()
local ownerName = name and name:match("^(.-)DropDown$")
if ownerName and FRAME_NAMES[ownerName] then return true end
local parent = menu.GetParent and menu:GetParent()
local parentName = parent and parent.GetName and parent:GetName()
return parentName and FRAME_NAMES[parentName] or false
end
local orig_AddButton = UIDropDownMenu_AddButton
UIDropDownMenu_AddButton = function(info, level)
if info and info.text and IsTrackedDropdown(UIDROPDOWNMENU_INIT_MENU) then
local text = info.text:lower()
if text:find("lock") and text:find("frame") then
return
end
end
return orig_AddButton(info, level)
end
end
-- Replace the native "Drag to move" tooltip hint with one that reflects how
-- these frames are actually repositioned now (native drag is disabled).
-- Scoped by IsMouseOver rather than GameTooltip:GetOwner(), since the owner
-- passed to SetOwner may be a child region rather than the tracked frame
-- itself. Matched loosely (case-insensitive "drag" + "move") since the exact
-- wording isn't confirmed. Hooked on both OnShow and OnUpdate since some
-- tooltips keep refreshing their lines while visible.
local tooltipFrames = {}
local tooltipHooked = false
local function ReplaceTooltipLines()
local hovering = false
for frame in pairs(tooltipFrames) do
if frame:IsMouseOver() then
hovering = true
break
end
end
if not hovering then return end
for i = 1, GameTooltip:NumLines() do
local line = _G["GameTooltipTextLeft"..i]
local text = line and line:GetText()
if text then
-- Some lines combine the drag hint with other text (e.g. a
-- trailing "Right-click for options"), so replace just the
-- matched span instead of the whole line.
local replaced, count = text:gsub("[Dd]rag.-[Mm]ove", "Toggle ElvUI's anchors to move")
if count > 0 then
line:SetText(replaced)
end
end
end
end
local function FixTooltip(frame)
if tooltipFrames[frame] then return end
tooltipFrames[frame] = true
if not tooltipHooked then
tooltipHooked = true
GameTooltip:HookScript("OnShow", ReplaceTooltipLines)
GameTooltip:HookScript("OnUpdate", ReplaceTooltipLines)
end
end
-- A single nil-out isn't enough: the frame's own update logic re-attaches
-- OnDragStart/OnDragStop (and re-registers drag buttons) on refresh, and
-- native dragging ends with the engine calling SetPoint directly on the
-- frame, severing the live anchor to our mover holder. So we don't just
-- clear drag once, we permanently intercept any future attempt to turn it
-- back on. The CoAClearing* guards stop our own corrective calls from
-- re-triggering these same hooks.
local function DisableDrag(frame)
if frame.CoADragDisabled then return end
frame.CoADragDisabled = true
frame:SetScript("OnDragStart", nil)
frame:SetScript("OnDragStop", nil)
frame:RegisterForDrag()
hooksecurefunc(frame, "SetScript", function(self, script, handler)
if handler and (script == "OnDragStart" or script == "OnDragStop") and not self.CoAClearingDragScript then
self.CoAClearingDragScript = true
self:SetScript(script, nil)
self.CoAClearingDragScript = false
end
end)
hooksecurefunc(frame, "RegisterForDrag", function(self, ...)
if select("#", ...) > 0 and not self.CoAClearingDrag then
self.CoAClearingDrag = true
self:RegisterForDrag()
self.CoAClearingDrag = false
end
end)
end
-- Anchoring a frame while in combat lockdown can taint it, so anchors
-- queued during combat are batched and applied together on the next
-- PLAYER_REGEN_ENABLED instead of each frame registering its own handler
-- (which would stomp on each other, since AceEvent keeps only the most
-- recently registered callback per event for a given object).
local pendingAnchors = {}
local combatHandlerRegistered = false
local function QueueAnchor(fn)
if not InCombatLockdown() then
fn()
return
end
table.insert(pendingAnchors, fn)
if not combatHandlerRegistered then
combatHandlerRegistered = true
CoA:RegisterEvent("PLAYER_REGEN_ENABLED", function()
for i = 1, #pendingAnchors do
pendingAnchors[i]()
end
wipe(pendingAnchors)
CoA:UnregisterEvent("PLAYER_REGEN_ENABLED")
combatHandlerRegistered = false
end)
end
end
local function AnchorToHolder(frame, holder)
frame:ClearAllPoints()
frame:SetPoint("CENTER", holder, "CENTER")
end
-- Class resource frames re-anchor themselves (e.g. relative to the player/
-- target frame) whenever they refresh, which stomps our mover anchor. Since
-- there's no event that fires only for that self-repositioning, we hook
-- SetPoint itself and snap back to the holder any time something else moves
-- the frame. CoARepositioning guards against the corrective SetPoint call
-- re-triggering this same hook.
local function LockPosition(frame, holder)
if frame.CoAPositionLocked then return end
frame.CoAPositionLocked = true
hooksecurefunc(frame, "SetPoint", function(self)
if self.CoARepositioning then return end
self.CoARepositioning = true
QueueAnchor(function()
AnchorToHolder(self, holder)
self.CoARepositioning = false
end)
end)
end
-- Created eagerly (independent of the native frame ever showing up -- several
-- 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()
if width > 0 and height > 0 then
holder:Size(width, height)
end
end
local function AnchorFrame(frame, name)
local holder = _G["CoA_"..name.."Holder"]
if not holder then return end
QueueAnchor(function()
AnchorToHolder(frame, holder)
LockPosition(frame, holder)
end)
end
-- The server never Show()s a frame the current class doesn't use, so its
-- data (powerType, maxValue, ...) is never populated. Forcing Show() on it
-- exposes that uninitialized state and renders as a garbage white bar
-- texture (same issue and same fix as HideCoAUI). Only force-show a frame
-- we've already seen the game display naturally at least once.
local seenNaturalShow = {}
-- CoAForceHidden distinguishes frames we hid ourselves (via the options
-- checkbox) from frames the game itself decided to hide, so unchecking the
-- box only restores frames we were suppressing.
local function ApplyVisibility(frame, hideKey)
if CoA.db.profile[hideKey] then
frame.CoAForceHidden = true
frame:Hide()
elseif frame.CoAForceHidden and seenNaturalShow[frame] then
frame.CoAForceHidden = false
frame:Show()
end
end
local function SetupVisibility(frame, hideKey)
if frame.CoAVisibilityHooked then return end
frame.CoAVisibilityHooked = true
frame:HookScript("OnShow", function(self)
seenNaturalShow[self] = true
ApplyVisibility(self, hideKey)
end)
ApplyVisibility(frame, hideKey)
end
function CoA:UpdateClassResourceVisibility()
for _, def in ipairs(FRAMES) do
local frame = _G[def.name]
if frame then
ApplyVisibility(frame, def.hideKey)
end
end
end
local hooked = {}
local function TryHookAll()
local allHooked = true
for _, def in ipairs(FRAMES) do
if not hooked[def.name] then
local frame = _G[def.name]
if frame then
DisableDrag(frame)
SetupVisibility(frame, def.hideKey)
FixTooltip(frame)
SyncHolderSize(frame, def.name)
AnchorFrame(frame, def.name)
hooked[def.name] = true
else
allHooked = false
end
end
end
return allHooked
end
function CoA:InitializeClassResources()
for _, def in ipairs(FRAMES) do
CreateMoverHolder(def)
end
if TryHookAll() then return end
self.classResourcesTimer = self:ScheduleRepeatingTimer(function()
if TryHookAll() then
self:CancelTimer(self.classResourcesTimer)
self.classResourcesTimer = nil
end
end, 0.5)
end
+165
View File
@@ -0,0 +1,165 @@
local E, L, V, P, G = unpack(ElvUI)
local UF = E:GetModule("UnitFrames")
local CoA = E:GetModule("CoA")
-- Base dispel types each custom class can remove, independent of talents.
-- CHRONOMANCER is a special case: its dispel removes the last debuff applied
-- to the target regardless of type, so it's never filtered out here -- we
-- can only approximate this as "treat every typed debuff as dispellable",
-- since the underlying oUF scan only ever surfaces typed debuffs anyway.
local CLASS_DISPEL_TYPES = {
CHRONOMANCER = true,
SUNCLERIC = {Magic = true, Poison = true, Disease = true},
STARCALLER = {Poison = true, Disease = true},
PROPHET = {Poison = true},
WITCHDOCTOR = {Curse = true},
CULTIST = {Magic = true},
PYROMANCER = {},
}
-- Extra dispel types unlocked by a talent choice, keyed to the Character
-- Advancement entry that grants them. The IDs are stable and shared across
-- every class tree, so querying one from a class that can't take it simply
-- reports rank 0 rather than erroring.
local TALENT_DISPEL_TYPES = {
PROPHET = {talentID = 6324, talentName = "Blight Antidote", types = {Curse = true}},
CULTIST = {talentID = 12982, talentName = "Devour Curse", types = {Curse = true}},
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.
-- PostUpdate_DebuffHighlight runs once per aura per frame, so the query is
-- hoisted out of that path and refreshed only when the build can change.
local hasTalentDispel = false
-- 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",
-- which degrades to the same behaviour as before auto-detection existed.
local function HasTalent(talent)
local api = C_CharacterAdvancement
if not api then return false end
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
-- 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
-- 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 _, class = UnitClass("player")
local talent = TALENT_DISPEL_TYPES[class]
local hasTalent = (talent and HasTalent(talent)) or false
if hasTalent == hasTalentDispel then return false end
hasTalentDispel = hasTalent
return true
end
function CoA:CanDispel(debuffType)
local _, class = UnitClass("player")
local baseTypes = CLASS_DISPEL_TYPES[class]
if baseTypes == true then return true end
if baseTypes and baseTypes[debuffType] then return true end
local talent = TALENT_DISPEL_TYPES[class]
if talent and hasTalentDispel and CoA.db.profile.dispelHighlightDetectTalents and talent.types[debuffType] then
return true
end
return false
end
local function SuppressHighlight(object)
if object.DebuffHighlightBackdrop and object.DBHGlow then
object.DBHGlow:Hide()
elseif object.DebuffHighlightUseTexture then
object.DebuffHighlight:SetTexture(nil)
else
object.DebuffHighlight:SetVertexColor(0, 0, 0, 0)
end
end
local origPostUpdate = UF.PostUpdate_DebuffHighlight
local function DispelAwarePostUpdate(dbh, object, debuffType, texture, wasFiltered, style, color)
origPostUpdate(dbh, object, debuffType, texture, wasFiltered, style, color)
if CoA.db.profile.dispelHighlightOnlyMine and debuffType and not wasFiltered and not CoA:CanDispel(debuffType) then
SuppressHighlight(object)
end
end
UF.PostUpdate_DebuffHighlight = DispelAwarePostUpdate
hooksecurefunc(UF, "Configure_DebuffHighlight", function(_, frame)
local dbh = frame.DebuffHighlight
if dbh then
dbh.PostUpdate = UF.PostUpdate_DebuffHighlight
end
end)
function CoA:UpdateDispelHighlight()
RefreshTalentState()
UF:Update_AllFrames()
end
function CoA:RefreshDispelTalents()
if RefreshTalentState() then
UF:Update_AllFrames()
end
end
-- On Ascension, UnitClass("player")'s second return only reliably reports the
-- real custom class (CULTIST, PYROMANCER, ...) a short while after login --
-- immediately at ADDON_LOADED/PLAYER_LOGIN it can still read back the generic
-- "HERO" base class, and the advancement data lags in the same way. If a
-- debuff highlight gets evaluated before that data syncs, CoA:CanDispel
-- wrongly returns false and the highlight stays wrongly suppressed until the
-- next aura change. Force one extra refresh shortly after entering the world
-- so the very first debuff isn't judged too early.
--
-- CHARACTER_ADVANCEMENT_UPDATE_ENTRIES_RESULT fires when a build is confirmed
-- in the talent UI, but switching to another saved specialization doesn't
-- confirm anything -- that path only fires PENDING_BUILD_UPDATED and
-- SUGGESTIONS_UPDATED, so both are needed or the ranks read stale for the rest
-- 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()
CoA:RegisterEvent("PLAYER_ENTERING_WORLD", function()
CoA:ScheduleTimer("UpdateDispelHighlight", 2)
end)
for _, event in ipairs({
"CHARACTER_ADVANCEMENT_UPDATE_ENTRIES_RESULT",
"CHARACTER_ADVANCEMENT_PENDING_BUILD_UPDATED",
"CHARACTER_ADVANCEMENT_SUGGESTIONS_UPDATED",
}) do
CoA:RegisterEvent(event, function()
CoA:RefreshDispelTalents()
end)
end
end
+110 -23
View File
@@ -1,8 +1,12 @@
local E, L, V, P, G = unpack(ElvUI) local E, L, V, P, G = unpack(ElvUI)
local LSM = E.Libs.LSM
local CoA = E:GetModule("CoA") local CoA = E:GetModule("CoA")
local CONTAINER_NAME = "ExtraActionBar" local CONTAINER_NAME = "ExtraActionBar"
local BUTTON_NAME = "ExtraActionBarPoolFrameExtraActionButtonTemplate1" local BUTTON_NAME = "ExtraActionBarPoolFrameExtraActionButtonTemplate1"
local BINDING_ACTION = "CLICK "..BUTTON_NAME..":LeftButton"
_G["BINDING_NAME_"..BINDING_ACTION] = "Extra Action Button"
local function SkinGlow(button, name) local function SkinGlow(button, name)
local glow = _G[name.."SpellActivationAlert"] local glow = _G[name.."SpellActivationAlert"]
@@ -16,10 +20,77 @@ local function SkinGlow(button, name)
end end
end end
local function SkinButton(button) local RIM_EDGE_RATIO = 6 / 52 -- 6px confirmed empirically at the native 52px size to fully hide the icon's baked-in vanilla border
local function UpdateRimEdge(button, size)
local rim = button and button.CoARim
if not rim then return end
rim:SetBackdrop({edgeFile = E.media.blankTex, edgeSize = size * RIM_EDGE_RATIO})
rim:SetBackdropBorderColor(unpack(E.media.bordercolor))
end
local HOTKEY_MARGIN = 3
local function UpdateHotkeyPosition(button, size)
local hotkey = button and _G[button:GetName().."HotKey"]
if not hotkey then return end
local inset = -(size * RIM_EDGE_RATIO + HOTKEY_MARGIN)
hotkey:ClearAllPoints()
hotkey:Point("TOPRIGHT", inset, inset)
end
local function UpdateSize(button)
button = button or _G[BUTTON_NAME]
if button then
local size = CoA.db.profile.extraActionButtonSize or 52
button:SetSize(size, size)
UpdateRimEdge(button, size)
UpdateHotkeyPosition(button, size)
end
end
function CoA:UpdateExtraActionButtonSize()
UpdateSize()
local holder = _G["CoA_ExtraActionBarHolder"]
if holder then
holder:Size(CoA.db.profile.extraActionButtonSize or 52)
end
end
local function UpdateHotkeyText(button)
button = button or _G[BUTTON_NAME]
local hotkey = button and _G[button:GetName().."HotKey"]
if not hotkey then return end
local key = GetBindingKey(BINDING_ACTION)
if key then
hotkey:SetText(GetBindingText(key, "KEY_"))
hotkey:Show()
else
hotkey:SetText("")
hotkey:Hide()
end
end
local function RecenterAnchor(button, container)
local x, y = button:GetCenter()
local cx, cy = container:GetCenter()
if not (x and y and cx and cy) then return end
button:ClearAllPoints()
button:SetPoint("CENTER", container, "CENTER", x - cx, y - cy)
end
local function SkinButton(button, container)
if button.CoASkinned then return end if button.CoASkinned then return end
button.CoASkinned = true button.CoASkinned = true
RecenterAnchor(button, container)
UpdateSize(button)
local name = button:GetName() local name = button:GetName()
local icon = _G[name.."Icon"] local icon = _G[name.."Icon"]
local hotkey = _G[name.."HotKey"] local hotkey = _G[name.."HotKey"]
@@ -57,14 +128,18 @@ local function SkinButton(button)
local rim = CreateFrame("Frame", nil, button.backdrop) local rim = CreateFrame("Frame", nil, button.backdrop)
rim:SetAllPoints(icon) rim:SetAllPoints(icon)
rim:SetFrameLevel(button.backdrop:GetFrameLevel() + 10) rim:SetFrameLevel(button.backdrop:GetFrameLevel() + 10)
rim:SetBackdrop({edgeFile = E.media.blankTex, edgeSize = 6}) button.CoARim = rim
rim:SetBackdropBorderColor(unpack(E.media.bordercolor)) UpdateRimEdge(button, CoA.db.profile.extraActionButtonSize or 52)
button:SetFrameLevel(rim:GetFrameLevel() + 1)
end end
if hotkey then if hotkey then
hotkey:ClearAllPoints() hotkey:FontTemplate(LSM:Fetch("font", E.db.actionbar.font), E.db.actionbar.fontSize, E.db.actionbar.fontOutline)
hotkey:Point("TOPRIGHT", 0, 0) hotkey:SetTextColor(1, 1, 1)
hotkey:FontTemplate()
UpdateHotkeyText(button)
CoA:RegisterEvent("UPDATE_BINDINGS", function() UpdateHotkeyText(button) end)
end end
if macroText then if macroText then
@@ -87,26 +162,34 @@ local function SkinButton(button)
SkinGlow(button, name) SkinGlow(button, name)
end end
local function SetupMover(container, button) -- Created eagerly (independent of the native ExtraActionBar ever showing 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 restores
-- the saved position from E.db.movers.
local function CreateMoverHolder()
if CoA.extraActionBarMoverCreated then return end if CoA.extraActionBarMoverCreated then return end
local width, height = button:GetSize()
if width == 0 or height == 0 then return end
local left, bottom = button:GetLeft(), button:GetBottom()
if not left or not bottom then return 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,ACTIONBARS", nil, "CoA,skin,extraActionBar")
holder:SetAllPoints(_G["CoA_ExtraActionBarMover"])
local function Anchor() local function Anchor()
container:ClearAllPoints() container:ClearAllPoints()
@@ -128,14 +211,18 @@ local function TryHook()
local button = _G[BUTTON_NAME] local button = _G[BUTTON_NAME]
if container and button then if container and button then
SetupMover(container, button) SkinButton(button, container)
SkinButton(button) SetupContainer(container)
AnchorContainer(container)
return true
end end
return container ~= nil and button ~= nil 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()
+142
View File
@@ -0,0 +1,142 @@
local E, L, V, P, G = unpack(ElvUI)
local S = E:GetModule("Skins")
local CoA = E:GetModule("CoA")
local _G = _G
local ipairs, pairs, select, type = ipairs, pairs, select, type
-- Generic sweep over the stock options windows.
--
-- ElvUI skins those windows from four hardcoded name lists (checkboxes,
-- sliders, buttons, dropdowns) in Modules/Skins/Blizzard/BlizzardOptions.lua,
-- so anything the client added since upstream last synced stays native --
-- 53 widgets on this server, 39 of them in four panels upstream never
-- enumerated at all (Battlenet, RaidEffects, Stereo, Voice). Rather than
-- carry a parallel name list that goes stale the same way, this walks the
-- panel containers and skins by object type.
local MAX_DEPTH = 3
-- Every panel is parented at load (probed: the Interface container reports the
-- same child count before and after opening the window), so the containers can
-- be walked without waiting for a category to be displayed.
local ROOTS = {
"InterfaceOptionsFramePanelContainer",
"VideoOptionsFramePanelContainer",
"AudioOptionsFramePanelContainer",
}
-- Hooked on all three windows rather than only the Interface one: the Video and
-- Audio windows are separate frames reachable straight from the game menu, and
-- a player who opens one of those first would otherwise see it unskinned. Each
-- show sweeps every root, which costs nothing -- the pass is idempotent.
local TRIGGERS = {
"InterfaceOptionsFrame",
"VideoOptionsFrame",
"AudioOptionsFrame",
}
-- Panels owned by other addons, keyed by frame. Rebuilt on every sweep rather
-- than cached at init: load-on-demand addons register their category late, and
-- a set built once would let their panels through on later shows.
local addonPanels = {}
local function RebuildAddOnPanels()
for panel in pairs(addonPanels) do
addonPanels[panel] = nil
end
local categories = _G.INTERFACEOPTIONS_ADDONCATEGORIES
if type(categories) ~= "table" then return end
-- The entries are the panel frames themselves, not descriptors (probed).
-- Matching on the frame is why this doesn't need a name prefix whitelist:
-- an addon can name its panel anything, and the registry is authoritative.
for _, panel in ipairs(categories) do
if type(panel) == "table" then
addonPanels[panel] = true
end
end
end
-- Structural test, not a name test. S:HandleDropDownBox uses _G[name.."Button"]
-- unguarded when anchoring its backdrop, so a false positive is a Lua error
-- rather than a cosmetic miss; it also force-sets the frame's width, which
-- mangles a group box even when it doesn't error. A DropDown name suffix was
-- considered and rejected -- ElvUI's own dropdown list carries
-- CompactUnitFrameProfilesProfileSelector (no suffix) and ...SortByDropdown
-- (lowercase d), so suffix matching misses real dropdowns and adds nothing.
local function IsDropDown(frame)
local name = frame.GetName and frame:GetName()
if not name then return false end
local middle = _G[name.."Middle"]
if not middle or not middle.IsObjectType or not middle:IsObjectType("Texture") then return false end
return _G[name.."Button"] ~= nil
end
-- depth is the depth of the children being visited, counting the container's
-- own children as 1. Three is enough for the deepest real case: the Controls
-- panel's anonymous "Looting Options" group box and the compact raid frame
-- profile dialogs both hold their widgets a level below the panel.
local function Sweep(frame, depth)
for i = 1, frame:GetNumChildren() do
local child = select(i, frame:GetChildren())
if child and not addonPanels[child] then
local objectType = child:GetObjectType()
if objectType == "CheckButton" then
S:HandleCheckBox(child)
elseif objectType == "Slider" then
-- The gate is load-bearing, not an optimisation:
-- HandleSliderFrame has no idempotence guard of its own and
-- HookScript stacks, so a second pass over a slider ElvUI
-- already skinned would run its OnDisable/OnEnable handlers
-- twice. E:SetTemplate sets frame.template, so that field is
-- the "already skinned" marker for sliders.
if not child.template then
S:HandleSliderFrame(child)
end
elseif objectType == "Frame" and IsDropDown(child) then
S:HandleDropDownBox(child)
elseif depth < MAX_DEPTH then
-- Deliberately no Button branch: nothing in the sweep needs one,
-- and it would flatten other addons' panel tabs -- object type
-- Button, but tabs -- into ElvUI pill buttons.
Sweep(child, depth + 1)
end
end
end
end
local function SweepOptions()
RebuildAddOnPanels()
for _, name in ipairs(ROOTS) do
local root = _G[name]
if root then
Sweep(root, 1)
end
end
end
function CoA:InitializeInterfaceOptions()
-- The same two switches that gate ElvUI's own pass: if the user turned the
-- stock options skin off, the gap this fills isn't a gap any more.
if not E.private.skins.blizzard.enable then return end
if not E.private.skins.blizzard.BlizzardOptions then return end
-- Skinning on show rather than at init puts the sweep after ElvUI's
-- Skin_BlizzardOptions callback with no ordering work, and re-running it on
-- every show catches panels that only appear once their addon loads.
for _, name in ipairs(TRIGGERS) do
local frame = _G[name]
if frame then
frame:HookScript("OnShow", SweepOptions)
end
end
end
+77 -20
View File
@@ -1,12 +1,46 @@
local E, L, V, P, G = unpack(ElvUI) local E, L, V, P, G = unpack(ElvUI)
local LSM = E.Libs.LSM
local CoA = E:GetModule("CoA") local CoA = E:GetModule("CoA")
local BUTTON_NAME = "LayerPickerFrame" local BUTTON_NAME = "LayerPickerFrame"
local MIN_WIDTH, MIN_HEIGHT = 66, 14
local PAD_X, PAD_Y = 10, 10
local function UpdateFont(button)
button = button or _G[BUTTON_NAME]
local text = button and _G[button:GetName().."Text"]
if not text then return end
text:FontTemplate(LSM:Fetch("font", CoA.db.profile.instanceButtonFont), CoA.db.profile.instanceButtonFontSize, CoA.db.profile.instanceButtonFontOutline)
button:SetSize(
math.max(MIN_WIDTH, text:GetStringWidth() + PAD_X),
math.max(MIN_HEIGHT, text:GetStringHeight() + PAD_Y)
)
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()
UpdateFont()
SyncHolderSize()
end
-- Hooked at file scope, so unlike the rest of the skin it stays live even when
-- the skin is off -- check the toggle here instead. Reset Position only makes
-- sense while the frame is where the server put it; once we've handed it to a
-- mover the entry does nothing useful.
do do
local orig_AddButton = UIDropDownMenu_AddButton local orig_AddButton = UIDropDownMenu_AddButton
UIDropDownMenu_AddButton = function(info, level) UIDropDownMenu_AddButton = function(info, level)
if info and info.text == "Reset Position" and UIDROPDOWNMENU_INIT_MENU == LayerPickerFrameDropDown then if CoA.db and CoA.db.profile.skins.instanceSwap
and info and info.text == "Reset Position" and UIDROPDOWNMENU_INIT_MENU == LayerPickerFrameDropDown then
return return
end end
@@ -14,32 +48,56 @@ do
end end
end end
-- A single nil-out isn't enough: native dragging ends with the engine
-- calling SetPoint directly on the frame, severing the live anchor to our
-- mover holder. So we don't just clear drag once, we permanently intercept
-- any future attempt to turn it back on. The CoAClearing* guards stop our
-- own corrective calls from re-triggering these same hooks.
local function DisableDrag(button) local function DisableDrag(button)
if button.CoADragDisabled then return end if button.CoADragDisabled then return end
button.CoADragDisabled = true button.CoADragDisabled = true
button:SetScript("OnDragStart", nil) button:SetScript("OnDragStart", nil)
button:SetScript("OnDragStop", nil) button:SetScript("OnDragStop", nil)
button:RegisterForDrag()
hooksecurefunc(button, "SetScript", function(self, script, handler)
if handler and (script == "OnDragStart" or script == "OnDragStop") and not self.CoAClearingDragScript then
self.CoAClearingDragScript = true
self:SetScript(script, nil)
self.CoAClearingDragScript = false
end
end)
hooksecurefunc(button, "RegisterForDrag", function(self, ...)
if select("#", ...) > 0 and not self.CoAClearingDrag then
self.CoAClearingDrag = true
self:RegisterForDrag()
self.CoAClearingDrag = false
end
end)
end end
local function SetupMover(button) -- Created eagerly (independent of the native LayerPickerFrame ever showing
-- 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
-- restores the saved position from E.db.movers.
local function CreateMoverHolder()
if CoA.layerPickerMoverCreated then return end if CoA.layerPickerMoverCreated then return end
local width, height = button:GetSize()
if width == 0 or height == 0 then return end
local left, bottom = button:GetLeft(), button:GetBottom()
if not left or not bottom then return 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, nil, 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()
@@ -60,16 +118,11 @@ local function SkinButton(button)
if button.CoASkinned then return end if button.CoASkinned then return end
button.CoASkinned = true button.CoASkinned = true
local name = button:GetName()
local text = _G[name.."Text"]
button:StripTextures(true) button:StripTextures(true)
button:CreateBackdrop("Default") button:CreateBackdrop("Default")
button:StyleButton() button:StyleButton()
if text then UpdateFont(button)
text:FontTemplate()
end
end end
local function TryHook() local function TryHook()
@@ -77,14 +130,18 @@ local function TryHook()
if button then if button then
DisableDrag(button) DisableDrag(button)
SetupMover(button)
SkinButton(button) SkinButton(button)
SyncHolderSize(button)
AnchorButton(button)
return true
end end
return button ~= nil 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()
+834
View File
@@ -0,0 +1,834 @@
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
+735
View File
@@ -0,0 +1,735 @@
local E, L, V, P, G = unpack(ElvUI)
local S = E:GetModule("Skins")
local CoA = E:GetModule("CoA")
local Skin = CoA.Skin
local FRAME_NAME = "CoATalentFrame"
-- 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
Skin:Button(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
Skin:CloseButton(close)
-- 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
-- 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
-- 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"])
SkinRowExpansion(rowName)
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 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)
Skin:ScrollArrow(up, "up")
end
local down = _G[listName.."ScrollFrameScrollDownButton"]
if down then
down:Point("TOP", scrollBar, "BOTTOM", 0, -1)
Skin:ScrollArrow(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
Skin:Button(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
Skin:Panel(menu)
Skin:CloseButton(_G[menuName.."Close"])
-- 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
-- The tab row is shared with the vanity and wardrobe windows and is handled in
-- Skinning.lua. The talent frame is passed as its owner while this window is
-- the open one: the tabs aren't its children, so they don't draw above its
-- panel on their own.
local function SkinTabs()
Skin:CollectionTabs(_G[FRAME_NAME])
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
Skin:Button(_G[cardName.."SelectButton"], true)
end
end
local function SkinFrame(frame)
if not frame.CoASkinned then
frame.CoASkinned = true
Skin:HideArt(_G[FRAME_NAME.."NineSlice"])
-- The round portrait medallion overhangs the top-left corner and has
-- no flat equivalent, so it goes rather than getting reskinned.
Skin:HideArt(_G[FRAME_NAME.."PortraitFrame"])
Skin:Panel(frame)
-- 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)
Skin:ApplyWindowScale("advancementScale")
Skin:Title(_G[FRAME_NAME.."TitleText"])
SkinCloseButton(self)
SkinBottomBar()
CropBackground()
SkinChildren(self, 1)
SkinTabs()
SkinSpecChoices()
end)
end
Skin:ApplyWindowScale("advancementScale")
Skin:Title(_G[FRAME_NAME.."TitleText"])
SkinCloseButton(frame)
SkinBottomBar()
-- Not CropBackground() here: this runs pre-Show now (see InitializeTalentFrame),
-- before Blizzard's own code sets the background texture's real art-tile
-- coords -- it's still XML-template default (full 0..1) at this point. Crop
-- caches "original" coords on first call and never recaptures, so cropping
-- here would permanently bake in the wrong baseline. OnShow (below) is the
-- only place this is safe to run.
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
Skin:OnFrameAvailable(TryHook)
end
+98
View File
@@ -0,0 +1,98 @@
local E, L, V, P, G = unpack(ElvUI)
local S = E:GetModule("Skins")
local CoA = E:GetModule("CoA")
local Skin = CoA.Skin
local FRAME_NAME = "StoreCollectionFrame"
-- Confirmed live by probing size/position: these two OVERLAY "Portrait2"
-- regions sit at the currency counters (aligned with SPCounterHintButton and
-- DPCounterHintButton), not at the top-left corner. An earlier version of
-- this matched them as the portrait and stripped them, which blanked the
-- counter badges instead. Whatever the real top-left portrait is hasn't been
-- identified yet -- it isn't among StoreCollectionFrame's own regions or
-- children -- so nothing removes it for now.
-- Standard UIPanelButtonTemplate art (FontString + Normal/Pushed/Disabled/
-- Highlight textures), confirmed by probe -- S:HandleButton's own texture
-- clearing handles these directly, no manual stripping needed.
local function SkinActionButtons()
Skin:Button(_G[FRAME_NAME.."ActivateStoreButton"])
Skin:Button(_G[FRAME_NAME.."BuyStoreButton"])
end
local function SkinSearchBox()
S:HandleEditBox(_G[FRAME_NAME.."SearchBox"])
end
-- The filter pill and its popout are the shared dropdown widget; see
-- Skinning.lua for why S:HandleButton alone can't clear it.
local function SkinDropdown()
Skin:Dropdown(_G[FRAME_NAME.."Dropdown"], _G[FRAME_NAME.."DropdownMenu"])
end
-- Neither pager button is named (both are anonymous children of
-- CollectionList), so direction is worked out from their own anchor offset
-- rather than a name match, and re-checked on every pass instead of cached --
-- cheap, and self-corrects if the two ever come back in a different order.
local function SkinPagerArrows()
local list = _G[FRAME_NAME.."CollectionList"]
if not list then return end
local a, b = select(3, list:GetChildren()), select(4, list:GetChildren())
if not (a and b and a:GetObjectType() == "Button" and b:GetObjectType() == "Button") then return end
local _, _, _, ax = a:GetPoint()
local _, _, _, bx = b:GetPoint()
local prevButton, nextButton = a, b
if bx and ax and bx < ax then
prevButton, nextButton = b, a
end
S:HandleNextPrevButton(prevButton, "left")
S:HandleNextPrevButton(nextButton, "right")
end
local function SkinContents()
-- Scale is set on the shared container while this window is the open one,
-- which carries the tab row along with it (see Skinning.lua).
Skin:ApplyWindowScale("vanityScale")
Skin:CollectionTabs(_G[FRAME_NAME])
Skin:Title(_G[FRAME_NAME.."TitleText"])
Skin:CloseButton(_G[FRAME_NAME.."CloseButton"])
SkinActionButtons()
SkinSearchBox()
SkinDropdown()
SkinPagerArrows()
end
local function SkinFrame(frame)
if not frame.CoASkinned then
frame.CoASkinned = true
-- Templated without stripping, unlike the talent and wardrobe frames:
-- the currency counters are drawn as regions of the frame itself (see
-- the note at the top of this file), so a strip blanks them.
Skin:Panel(frame, true)
frame:HookScript("OnShow", SkinContents)
end
SkinContents()
end
local function TryHook()
local frame = _G[FRAME_NAME]
if not frame then return false end
SkinFrame(frame)
return true
end
function CoA:InitializeVanityFrame()
if not E.private.skins.blizzard.enable then return end
Skin:OnFrameAvailable(TryHook)
end
+235
View File
@@ -0,0 +1,235 @@
local E, L, V, P, G = unpack(ElvUI)
local S = E:GetModule("Skins")
local CoA = E:GetModule("CoA")
local Skin = CoA.Skin
local FRAME_NAME = "AppearanceWardrobeFrame"
-- Standard UIPanelButtonTemplate art, same as Vanity's action buttons --
-- S:HandleButton's own texture clearing handles these directly.
local function SkinActionButtons()
Skin:Button(_G[FRAME_NAME.."PlayerModelSaveOutfitButton"])
Skin:Button(_G[FRAME_NAME.."DisableTransmogButton"])
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
local function SkinSearchBox()
S:HandleEditBox(_G[FRAME_NAME.."CollectionSearchBox"])
end
-- suffix is "Filter" or "Sorting" -- both are CollectionX buttons with a
-- matching CollectionXMenu popout (confirmed by probe), and both are the same
-- widget as the vanity frame's single dropdown, so both go through the shared
-- handler.
local function SkinCollectionDropdown(suffix)
Skin:Dropdown(_G[FRAME_NAME.."Collection"..suffix], _G[FRAME_NAME.."Collection"..suffix.."Menu"])
end
local function SkinPagerArrows()
local prevButton = _G[FRAME_NAME.."CollectionPageLeftButton"]
local nextButton = _G[FRAME_NAME.."CollectionPageRightButton"]
if prevButton then S:HandleNextPrevButton(prevButton, "left") end
if nextButton then S:HandleNextPrevButton(nextButton, "right") end
end
-- Same art naming as the talent frame's tabs (confirmed by probe), so they go
-- through the same shared handler. No level parent is passed: these are proper
-- descendants of Collection rather than separately-placed siblings, so normal
-- parent/child z-order already puts them above the panel and the frame-level
-- bump the talent tabs need doesn't apply.
--
-- Named PoolFrameAppearanceTypeTabTemplate1 through 8 (confirmed by probe),
-- not pooled/created dynamically like the talent frame's spec choices, so a
-- plain indexed loop is enough -- no OnShow hook needed to catch late pool
-- fills.
local TAB_COUNT = 8
local function SkinCategoryTabs()
for i = 1, TAB_COUNT do
Skin:Tab(_G[FRAME_NAME.."CollectionPoolFrameAppearanceTypeTabTemplate"..i])
end
end
local function SkinContents()
-- Scale is set on the shared container while this window is the open one,
-- which carries the tab row along with it (see Skinning.lua).
Skin:ApplyWindowScale("wardrobeScale")
Skin:CollectionTabs(_G[FRAME_NAME])
Skin:Title(_G[FRAME_NAME.."TitleText"])
Skin:CloseButton(_G[FRAME_NAME.."CloseButton"])
SkinActionButtons()
SkinPendingButtons()
SkinSearchBox()
SkinCollectionDropdown("Filter")
SkinCollectionDropdown("Sorting")
SkinPagerArrows()
SkinCategoryTabs()
end
local function SkinFrame(frame)
if not frame.CoASkinned then
frame.CoASkinned = true
-- The native NineSlice panel draws its own ornate border/background on
-- top of the Transparent template's border, doubling up. Same fix as
-- the talent frame: strip and hide it.
Skin:HideArt(_G[FRAME_NAME.."NineSlice"])
-- The round medallion overhangs the top-left corner (confirmed by
-- probe: 61x61, TOPLEFT -6,8 -- matches the frame's own corner, not a
-- counter badge elsewhere, unlike Vanity's same-named "Portrait2"
-- texture). No flat equivalent, so it goes rather than getting
-- reskinned, same treatment as the talent frame's portrait.
Skin:HideArt(_G[FRAME_NAME.."PortraitFrame"])
-- The frame's own regions (confirmed by probe: 4 total, all BACKGROUND/
-- BORDER/OVERLAY textures, none of them functional -- unlike Vanity's
-- frame, nothing here doubles as a counter badge) are the wood-panel
-- background art, so this one is safe to strip before templating.
Skin:Panel(frame)
-- InsetOverlay's NineSlice and the ShadowOverlay are separate decorative
-- art layered over the item grid area (8-piece atlas borders/shadow
-- edges, confirmed via probe), stripped outright -- no functional
-- content lives on either.
Skin:HideArt(_G[FRAME_NAME.."CollectionInsetOverlayNineSlice"])
-- The actual panel behind the grid: Collection itself (confirmed via
-- /fstack -- InsetOverlay was the wrong target, its own rect doesn't
-- match the visible panel). Collection is the grid's real parent, so
-- no frame-level/strata juggling needed -- children always draw above
-- their own parent.
--
-- 16 of its own regions turned out to hold real art -- a background
-- tile plus ~14 atlas border/corner pieces (the ornate corners
-- /fstack couldn't ever pick out, since loose regions aren't frames
-- and don't show up there) -- none of it functional (the "Collected
-- 50/2744" counter is a separate FontString region, untouched by
-- StripTextures). Painting an opaque/red template over it earlier
-- just masked it; it was still there underneath, which is why
-- Transparent let it bleed back through. Strip first, then template,
-- same order as the outer frame -- SetTemplate's own WHITE8X8 backdrop
-- pieces land as regions on this same frame too, so stripping after
-- would wipe them right back off.
Skin:Panel(_G[FRAME_NAME.."Collection"])
-- The 3D model preview's vanilla border is an anonymous child (first
-- of PlayerModel's own, confirmed by probe: 8-piece "UIFrame" atlas
-- border, sized to match the model panel). No name to key off of, same
-- as Vanity's pager arrows -- picked out positionally instead. The
-- race-specific scenic backdrop texture is PlayerModel's own region,
-- not this child's, so it's untouched.
local playerModel = _G[FRAME_NAME.."PlayerModel"]
local modelBorder = playerModel and select(1, playerModel:GetChildren())
if modelBorder and modelBorder:GetObjectType() == "Frame" then
Skin:HideArt(modelBorder)
end
Skin:HideArt(_G[FRAME_NAME.."CollectionShadowOverlay"])
frame:HookScript("OnShow", SkinContents)
end
SkinContents()
end
local function TryHook()
local frame = _G[FRAME_NAME]
if not frame then return false end
SkinFrame(frame)
return true
end
function CoA:InitializeWardrobeFrame()
if not E.private.skins.blizzard.enable then return end
Skin:OnFrameAvailable(TryHook)
end
+16 -2
View File
@@ -1,6 +1,20 @@
# ElvUI_CoA # ElvUI_CoA (WIP)
ElvUI plugin that hides and skins the custom Conquest of Azeroth (Ascension WoW, client 3.3.5) server frames not covered by stock ElvUI. ElvUI plugin that hides and skins the custom CoA (3.3.5) frames not covered by stock ElvUI.
## Implemented
- Character advancement, vanity, wardrobe frames: skin, scale
- Challenges frame: skin
- Class resource frames: hiding
- Extra action button frame: skin, anchor, size
- Instance swap frame: skin, anchor, font settings
- Interface/Video/Audio options: skin the widgets ElvUI's own pass misses
- Only highlight unitframes if dispellable by your CoA class.
## Installation
Download the repo as .zip, extract in `Interface\AddOns`, remove `-master` from the folder name.
## Requirements ## Requirements
+484 -9
View File
@@ -1,48 +1,523 @@
local E, L, V, P, G = unpack(ElvUI) local E, L, V, P, G = unpack(ElvUI)
local EP = E.Libs.EP local EP = E.Libs.EP
local ACH = LibStub("LibAceConfigHelper")
local AceDB = LibStub("AceDB-3.0")
local AceDBOptions = LibStub("AceDBOptions-3.0")
local AddOnName = ... local AddOnName = ...
BINDING_HEADER_COA = "Conquest of Azeroth"
-- TODO: hook up server restart frame (RestartTimerFrame)
local CoA = E:NewModule("CoA", "AceEvent-3.0", "AceTimer-3.0") local CoA = E:NewModule("CoA", "AceEvent-3.0", "AceTimer-3.0")
E.CoA = CoA E.CoA = CoA
V.CoA = {} local defaults = {
profile = {
skins = {
extraActionButton = true,
instanceSwap = true,
collections = {
enable = true,
-- Multipliers on each frame's own scale, so 1 is "as the server
-- built it" rather than a fixed size.
advancementScale = 1,
vanityScale = 1,
wardrobeScale = 1,
},
-- Deliberately not inside the collections group: that switch covers
-- the three windows that share the Collections container and its tab
-- row, and this one is a separate window on UIParent with a tab row
-- of its own.
challenges = true,
},
extraActionButtonSize = 52,
instanceButtonFont = "PT Sans Narrow",
instanceButtonFontSize = 12,
instanceButtonFontOutline = "OUTLINE",
dispelHighlightOnlyMine = false,
dispelHighlightDetectTalents = true,
hideResourceSegmentBar = false,
hideResourceOrb = false,
hideResourceBar = false,
hideMultiCastActionBar = false,
},
}
function CoA:RefreshConfig()
if self.UpdateExtraActionButtonSize then self:UpdateExtraActionButtonSize() end
if self.UpdateInstanceButtonFont then self:UpdateInstanceButtonFont() end
if self.UpdateDispelHighlight then self:UpdateDispelHighlight() end
if self.UpdateClassResourceVisibility then self:UpdateClassResourceVisibility() end
if self.UpdateFrameScales then self:UpdateFrameScales() end
end
CoA:RegisterEvent("ADDON_LOADED", function(_, addon)
if addon ~= AddOnName then return end
CoA:UnregisterEvent("ADDON_LOADED")
CoA.db = AceDB:New("ElvUI_CoADB", defaults)
-- One-time migration: earlier versions pinned every character to a single
-- shared "Default" profile. Move each character to its own name-based
-- profile (copying over the settings it already had) the first time it logs in.
if not CoA.db.char.migratedSharedProfile then
local charProfile = CoA.db.keys.char
if CoA.db:GetCurrentProfile() == "Default" and charProfile ~= "Default" then
CoA.db:SetProfile(charProfile)
CoA.db:CopyProfile("Default", true)
end
CoA.db.char.migratedSharedProfile = true
end
CoA.db.RegisterCallback(CoA, "OnProfileChanged", "RefreshConfig")
CoA.db.RegisterCallback(CoA, "OnProfileCopied", "RefreshConfig")
CoA.db.RegisterCallback(CoA, "OnProfileReset", "RefreshConfig")
end)
-- One slider per window rather than a single shared one: the three don't ship
-- at the same scale, so a shared value would only line them up by flattening
-- the difference the server built in.
local function scaleOption(order, name, key)
return {
order = order,
type = "range",
name = name,
min = 0.5,
max = 1.5,
step = 0.01,
get = function() return CoA.db.profile.skins.collections[key] end,
set = function(_, value)
CoA.db.profile.skins.collections[key] = value
if CoA.UpdateFrameScales then
CoA:UpdateFrameScales()
end
end,
}
end
local function getOptions() local function getOptions()
local profiles = AceDBOptions:GetOptionsTable(CoA.db)
profiles.order = 5
local options = { local options = {
order = 55, order = 55,
type = "group", type = "group",
childGroups = "tab", childGroups = "tab",
name = string.format("|cff1784d1%s|r", "Conquest of Azeroth"), name = string.format("|cff1784d1%s|r", "Conquest of Azeroth"),
args = { args = {
extraActionButton = { -- Frame skins live under their own tab, laid out as a vertical tree
-- rather than more horizontal tabs: one entry per skinned frame, and
-- there are a lot more of those coming.
skins = {
order = 1, order = 1,
type = "group", type = "group",
name = "Extra Action Button", childGroups = "tree",
args = {}, name = "Skins",
args = {
extraActionButton = {
order = 1,
type = "group",
name = "Extra Action Button",
args = {
header = {
order = 1,
type = "header",
name = "Extra Action Button",
},
-- Skinning is one-way: the native art is stripped and
-- replaced in place, so turning a skin off can only take
-- effect on the next load. Hence the reload prompt.
enable = {
order = 2,
type = "toggle",
name = "Enable",
desc = "Skin the Extra Action Button. Requires a UI reload.",
get = function() return CoA.db.profile.skins.extraActionButton end,
set = function(_, value)
CoA.db.profile.skins.extraActionButton = value
E:StaticPopup_Show("CONFIG_RL")
end,
},
desc = {
order = 3,
type = "description",
name = "You can move this element with Toggle Anchors.\n",
},
size = {
order = 4,
disabled = function() return not CoA.db.profile.skins.extraActionButton end,
type = "range",
name = "Size",
desc = "Adjust the width/height of the Extra Action Button, in pixels.",
min = 30,
max = 100,
step = 1,
get = function() return CoA.db.profile.extraActionButtonSize end,
set = function(_, value)
CoA.db.profile.extraActionButtonSize = value
if CoA.UpdateExtraActionButtonSize then
CoA:UpdateExtraActionButtonSize()
end
end,
},
},
},
instanceSwap = {
order = 2,
type = "group",
name = "Instance Swap",
args = {
header = {
order = 1,
type = "header",
name = "Instance Swap",
},
enable = {
order = 2,
type = "toggle",
name = "Enable",
desc = "Skin the Instance Swap button. Requires a UI reload.",
get = function() return CoA.db.profile.skins.instanceSwap end,
set = function(_, value)
CoA.db.profile.skins.instanceSwap = value
E:StaticPopup_Show("CONFIG_RL")
end,
},
desc = {
order = 3,
type = "description",
name = "You can move this element with Toggle Anchors.\n",
},
instanceFont = {
order = 4,
disabled = function() return not CoA.db.profile.skins.instanceSwap end,
type = "group",
inline = true,
name = "Instance Font",
args = {
font = ACH:SharedMediaFont("Font", nil, 1, nil,
function() return CoA.db.profile.instanceButtonFont end,
function(_, value)
CoA.db.profile.instanceButtonFont = value
if CoA.UpdateInstanceButtonFont then
CoA:UpdateInstanceButtonFont()
end
end),
fontSize = {
order = 2,
type = "range",
name = "Font Size",
min = 8,
max = 32,
step = 1,
get = function() return CoA.db.profile.instanceButtonFontSize end,
set = function(_, value)
CoA.db.profile.instanceButtonFontSize = value
if CoA.UpdateInstanceButtonFont then
CoA:UpdateInstanceButtonFont()
end
end,
},
fontOutline = ACH:FontFlags("Font Outline", nil, 3, nil,
function() return CoA.db.profile.instanceButtonFontOutline end,
function(_, value)
CoA.db.profile.instanceButtonFontOutline = value
if CoA.UpdateInstanceButtonFont then
CoA:UpdateInstanceButtonFont()
end
end),
},
},
},
},
-- One entry for the whole talent window: its own frame, the
-- tab row along its bottom, and the Vanity and Wardrobe
-- windows those tabs open. They're separate frames but one
-- feature to the player, and they're skinned as a set --
-- named for Collections, the container they all hang off.
collections = {
order = 3,
type = "group",
name = "Advancement/Vanity/Wardrobe",
args = {
header = {
order = 1,
type = "header",
name = "Advancement/Vanity/Wardrobe",
},
enable = {
order = 2,
type = "toggle",
name = "Enable",
desc = "Skin the talent window, its tabs, and the Vanity and Wardrobe windows. Requires a UI reload.",
get = function() return CoA.db.profile.skins.collections.enable end,
set = function(_, value)
CoA.db.profile.skins.collections.enable = value
E:StaticPopup_Show("CONFIG_RL")
end,
},
scale = {
order = 3,
type = "group",
inline = true,
name = "Scale",
disabled = function() return not CoA.db.profile.skins.collections.enable end,
args = {
advancementScale = scaleOption(1, "Advancement", "advancementScale"),
vanityScale = scaleOption(2, "Vanity", "vanityScale"),
wardrobeScale = scaleOption(3, "Wardrobe", "wardrobeScale"),
},
},
},
},
challenges = {
order = 4,
type = "group",
name = "Challenges",
args = {
header = {
order = 1,
type = "header",
name = "Challenges",
},
enable = {
order = 2,
type = "toggle",
name = "Enable",
desc = "Skin the Challenges window and its tabs. Requires a UI reload.",
get = function() return CoA.db.profile.skins.challenges end,
set = function(_, value)
CoA.db.profile.skins.challenges = value
E:StaticPopup_Show("CONFIG_RL")
end,
},
},
},
},
}, },
instanceSwap = { classResources = {
order = 2, order = 2,
type = "group", type = "group",
name = "Instance Swap", name = "Class Resources",
args = {}, args = {
header = {
order = 1,
type = "header",
name = "Class Resources",
},
desc = {
order = 2,
type = "description",
name = "You can move these elements with Toggle Anchors.\n",
},
hideResourceSegmentBar = {
order = 3,
type = "toggle",
name = "Hide CoAResourceSegmentBar",
width = "full",
get = function() return CoA.db.profile.hideResourceSegmentBar end,
set = function(_, value)
CoA.db.profile.hideResourceSegmentBar = value
if CoA.UpdateClassResourceVisibility then
CoA:UpdateClassResourceVisibility()
end
end,
},
hideResourceOrb = {
order = 4,
type = "toggle",
name = "Hide CoAResourceOrb",
width = "full",
get = function() return CoA.db.profile.hideResourceOrb end,
set = function(_, value)
CoA.db.profile.hideResourceOrb = value
if CoA.UpdateClassResourceVisibility then
CoA:UpdateClassResourceVisibility()
end
end,
},
hideResourceBar = {
order = 5,
type = "toggle",
name = "Hide CoAResourceBar",
width = "full",
get = function() return CoA.db.profile.hideResourceBar end,
set = function(_, value)
CoA.db.profile.hideResourceBar = value
if CoA.UpdateClassResourceVisibility then
CoA:UpdateClassResourceVisibility()
end
end,
},
hideMultiCastActionBar = {
order = 6,
type = "toggle",
name = "Hide CoAMultiCastActionBarFrame",
width = "full",
get = function() return CoA.db.profile.hideMultiCastActionBar end,
set = function(_, value)
CoA.db.profile.hideMultiCastActionBar = value
if CoA.UpdateClassResourceVisibility then
CoA:UpdateClassResourceVisibility()
end
end,
},
hideAll = {
order = 7,
type = "execute",
name = "Hide All",
func = function()
CoA.db.profile.hideResourceSegmentBar = true
CoA.db.profile.hideResourceOrb = true
CoA.db.profile.hideResourceBar = true
CoA.db.profile.hideMultiCastActionBar = true
if CoA.UpdateClassResourceVisibility then
CoA:UpdateClassResourceVisibility()
end
end,
},
showAll = {
order = 8,
type = "execute",
name = "Show All",
func = function()
CoA.db.profile.hideResourceSegmentBar = false
CoA.db.profile.hideResourceOrb = false
CoA.db.profile.hideResourceBar = false
CoA.db.profile.hideMultiCastActionBar = false
if CoA.UpdateClassResourceVisibility then
CoA:UpdateClassResourceVisibility()
end
end,
},
},
}, },
dispelHighlight = {
order = 3,
type = "group",
name = "Debuff Highlighting",
args = {
header = {
order = 1,
type = "header",
name = "Debuff Highlighting",
},
onlyMine = {
order = 2,
type = "toggle",
name = "Only Highlight If Dispellable By Me",
desc = "Suppress the debuff highlight on unitframes for debuff types your class cannot dispel.",
get = function() return CoA.db.profile.dispelHighlightOnlyMine end,
set = function(_, value)
CoA.db.profile.dispelHighlightOnlyMine = value
if CoA.UpdateDispelHighlight then
CoA:UpdateDispelHighlight()
end
end,
},
detectTalents = {
order = 3,
type = "toggle",
name = "Detect Talents",
desc = "Read the talents that grant extra dispel types from your current build. Turn this off to fall back to what your class can dispel without any talent.",
disabled = function() return not CoA.db.profile.dispelHighlightOnlyMine end,
get = function() return CoA.db.profile.dispelHighlightDetectTalents end,
set = function(_, value)
CoA.db.profile.dispelHighlightDetectTalents = value
if CoA.UpdateDispelHighlight then
CoA:UpdateDispelHighlight()
end
end,
},
},
},
profiles = profiles,
}, },
} }
E.Options.args.CoA = options E.Options.args.CoA = options
end end
local function RegisterMoverCategory()
for _, layout in ipairs(E.ConfigModeLayouts) do
if layout == "COA" then return end
end
tinsert(E.ConfigModeLayouts, "COA")
E.ConfigModeLocalizedStrings.COA = "CoA"
end
function CoA:Initialize() function CoA:Initialize()
RegisterMoverCategory()
EP:RegisterPlugin(AddOnName, getOptions) EP:RegisterPlugin(AddOnName, getOptions)
if self.InitializeExtraActionBar then -- ADDON_LOADED normally beats module init, but fall back to the defaults
-- rather than error out if a skin gets initialized before the DB exists.
local skins = self.db and self.db.profile.skins or defaults.profile.skins
if self.InitializeExtraActionBar and skins.extraActionButton then
self:InitializeExtraActionBar() self:InitializeExtraActionBar()
end end
if self.InitializeLayerPicker then if self.InitializeLayerPicker and skins.instanceSwap then
self:InitializeLayerPicker() self:InitializeLayerPicker()
end end
if self.InitializeDispelHighlight then
self:InitializeDispelHighlight()
end
if self.InitializeClassResources then
self:InitializeClassResources()
end
-- One switch for all three: the vanity and wardrobe windows are the talent
-- frame's own tabs, so skinning one without the others reads as a bug.
if skins.collections.enable then
if self.InitializeTalentFrame then
self:InitializeTalentFrame()
end
if self.InitializeVanityFrame then
self:InitializeVanityFrame()
end
if self.InitializeWardrobeFrame then
self:InitializeWardrobeFrame()
end
end
-- Its own switch rather than a place in the group above: the Challenges
-- window isn't reachable from the talent frame's tabs, so turning it off
-- doesn't leave a half-skinned set behind.
if self.InitializeChallengesFrame and skins.challenges then
self:InitializeChallengesFrame()
end
-- No switch of its own: this one only fills the gaps ElvUI's own Blizzard
-- options pass leaves, so it's on wherever that pass is, and its own gate on
-- E.private.skins.blizzard.enable is the same one ElvUI checks.
if self.InitializeInterfaceOptions then
self:InitializeInterfaceOptions()
end
end end
local function InitializeCallback() local function InitializeCallback()