diff --git a/EUI_UnlockMode.lua b/EUI_UnlockMode.lua index dabec030..5542592d 100644 --- a/EUI_UnlockMode.lua +++ b/EUI_UnlockMode.lua @@ -6091,7 +6091,7 @@ local function CreateMover(barKey) StanceBar = true, PetBar = true, ERB_TotemBar = true, -- totem bar: align active icons left/right/center } - local canGrow = _GROW_KEYS[barKey] or barKey:sub(1, 4) == "CDM_" + local canGrow = _GROW_KEYS[barKey] or barKey:sub(1, 4) == "CDM_" or barKey:sub(1, 4) == "PAB_" -- Match-source capability: the width/height MATCH buttons may appear even when -- drag/manual resize is disabled (noResize), if the element opts in via @@ -6794,14 +6794,26 @@ local function CreateMover(barKey) local _, v3 = EllesmereUI.GetTotemGrowDir() isVert = v3 end + elseif barKey:sub(1, 4) == "PAB_" then + -- Player Aura Bars support vertical growth too (Up/Down, + -- 2026-08-04 addition) -- read the bar's own current + -- growDirection (same bridge the currentVal lookup below uses) + -- to decide which pair of grow options this popup offers. + local euf3 = EllesmereUI.Lite.GetAddon("EllesmereUIUnitFrames", true) + local pabDir = (euf3 and euf3.GetGrowDirectionForBar and euf3:GetGrowDirectionForBar(barKey)) or "LEFT" + isVert = (pabDir == "UP" or pabDir == "DOWN") else local eab3 = EllesmereUI.Lite.GetAddon("EllesmereUIActionBars", true) local s3 = eab3 and eab3.db and eab3.db.profile and eab3.db.profile.bars and eab3.db.profile.bars[barKey] if s3 then isVert = (s3.orientation == "vertical") end end - local growDirs = { - { label = "Grow Centered", val = "CENTER" }, - } + -- Player Aura Bars: no "Grow Centered" -- AK's SetFlowLayoutGrowthDirection + -- (AnchorUtil.FlowDirection) is a strict Left/Right/Up/Down axis, it has + -- no centered concept, unlike CDM/ActionBars' own bar renderer. + local growDirs = {} + if barKey:sub(1, 4) ~= "PAB_" then + growDirs[#growDirs + 1] = { label = "Grow Centered", val = "CENTER" } + end if isVert then growDirs[#growDirs + 1] = { label = "Grow Up", val = "UP" } growDirs[#growDirs + 1] = { label = "Grow Down", val = "DOWN" } @@ -6825,6 +6837,9 @@ local function CreateMover(barKey) -- layout does or it would highlight an option that is not offered. currentVal = EllesmereUI.GetTotemGrowDir and EllesmereUI.GetTotemGrowDir() or (isVert and "DOWN" or "RIGHT") + elseif barKey:sub(1, 4) == "PAB_" then + local euf4 = EllesmereUI.Lite.GetAddon("EllesmereUIUnitFrames", true) + currentVal = (euf4 and euf4.GetGrowDirectionForBar and euf4:GetGrowDirectionForBar(barKey)) or "LEFT" else local eab4 = EllesmereUI.Lite.GetAddon("EllesmereUIActionBars", true) local s4 = eab4 and eab4.db and eab4.db.profile and eab4.db.profile.bars @@ -6911,6 +6926,11 @@ local function CreateMover(barKey) if tb then tb.growDirection = sideVal end if EllesmereUI.LayoutTotemBar then EllesmereUI.LayoutTotemBar() end EllesmereUI.RecenterBarAnchor(barKey) + elseif barKey:sub(1, 4) == "PAB_" then + local euf = EllesmereUI.Lite.GetAddon("EllesmereUIUnitFrames", true) + if euf and euf.SetGrowDirectionForBar then + euf:SetGrowDirectionForBar(barKey, sideVal) + end else local eab = EllesmereUI.Lite.GetAddon("EllesmereUIActionBars", true) if eab and eab.SetGrowDirectionForBar then diff --git a/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua b/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua index 56a0e67c..c17eb061 100644 --- a/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua +++ b/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua @@ -219,13 +219,15 @@ local function ApplyDmFx(button, d, style) -- the fx border override's container at +2), below the dispel -- ring and text -- the engine dispel recolor ALWAYS wins over -- borders and glows. Final order: border < fx border < glow < - -- dispel ring < text. The carrier write matches AuraKit's - -- ladder (ring +3, text +4) so this pass never drags the text - -- back down onto the ring. (Creation-window calls; our frames.) + -- dispel ring < text. Own level (+3), not shared with the fx + -- border override's container (+2). The carrier write matches + -- AuraKit's ladder (ring +4, text +5) so this pass never drags + -- the text back down onto the ring. (Creation-window calls; our + -- frames.) local base = (d.borderHost and d.borderHost:GetFrameLevel()) or (button:GetFrameLevel() + 1) - gov:SetFrameLevel(base + 2) - if d.stackCarrier then d.stackCarrier:SetFrameLevel(base + 4) end + gov:SetFrameLevel(base + 3) + if d.stackCarrier then d.stackCarrier:SetFrameLevel(base + 5) end gov:EnableMouse(false) d.dmFxgHost = gov end diff --git a/EllesmereUIUnitFrames/EUI_PlayerAuraBars_ManagerPages.lua b/EllesmereUIUnitFrames/EUI_PlayerAuraBars_ManagerPages.lua new file mode 100644 index 00000000..5b7f082f --- /dev/null +++ b/EllesmereUIUnitFrames/EUI_PlayerAuraBars_ManagerPages.lua @@ -0,0 +1,2505 @@ +------------------------------------------------------------------------------- +-- EUI_PlayerAuraBars_ManagerPages.lua +-- Single "Player Aura Bars" tab: sidebar lists the two fixed default bars +-- (Buffs, Debuffs -- always present, not deletable) plus any custom bars, +-- with "Add Buff Bar" / "Add Debuff Bar" actions. Selecting any entry shows +-- its settings in the detail pane, split into ASSIGNED (content selection, +-- model-specific) / CORE / DISPLAY sections (RaidFrames layout pattern) -- +-- default and custom bars share the same field-building helpers since both +-- are just cfg tables with the same shape (see +-- EllesmereUIUnitFrames_PlayerAuraBars.lua's DefaultBuffsCfg/ +-- DefaultDebuffsCfg and the custom-bar CRUD section). +-- +-- 2026-08-01 redesign: default Buffs bar unified onto the same BM2/filters +-- model custom buff bars already used (Filters dropdown + Extra Spells +-- dropdown, ns.PAB_Filters registry) -- classFilters no longer applies to +-- ANY buff-side bar. Debuff bars (default + custom) keep the class-token +-- model, now with a "Show All Debuffs" toggle that bypasses classFilters +-- without discarding it (mirrors RaidFrames' DebuffManager). The Filter +-- Editor modal (ns.PABMP_ShowFilterEditor) is implemented below -- see its +-- doc comment for what was deliberately NOT ported from RaidFrames' BM2 +-- (curated preset spell database, own-only tracking). +------------------------------------------------------------------------------- + +local _, ns = ... + +if not (EllesmereUI and EllesmereUI.IS_121) then return end + +local floor, max = math.floor, math.max + +local function L(s) return EllesmereUI.L and EllesmereUI.L(s) or s end + +local TILE_H = 54 + +-- Forward-declared: defined below (verbatim port of RaidFrames' editor +-- scroll helper), used by WrapCompensatedBody -- which is itself defined +-- ABOVE that point in the file -- to give the detail pane's body a real +-- scrollbar (2026-08-02 fix: the body previously had no scroll mechanism +-- at all, just silent SetClipsChildren cropping; content taller than the +-- visible area was simply unreachable, a pre-existing gap that only became +-- visible once the new preview box pushed settings fields below the fold). +local AttachEditorScroll + +-- Selection state: {kind="buff"|"debuff", id=barId|"default"} or nil. +local pabSel = { kind = "buff", id = "default" } +-- Filter Editor's own selected-filter state (independent of pabSel, since +-- the editor is a modal that can be opened from any buff-side detail pane). +local pabFilterSel +-- Preserves the spell-list scroll position across Rebuild() calls (add/ +-- remove/rename all rebuild the whole editor) -- same reasoning as BM2's +-- fdScrollPos. +local pabFilterScrollPos = 0 + +-- Standard WoW anchor points -- verified against AK's own usage: +-- style.durationPoint/stackPoint feed SetPoint(point, button, point, ...) +-- directly (EllesmereUI_AuraKit.lua's ApplyStyleToRegions), NOT the old +-- module's lowercase "top"/"bottom" convention. +local AURA_POINT_VALUES = { + TOP = "Top", BOTTOM = "Bottom", LEFT = "Left", RIGHT = "Right", + TOPLEFT = "Top Left", TOPRIGHT = "Top Right", + BOTTOMLEFT = "Bottom Left", BOTTOMRIGHT = "Bottom Right", CENTER = "Center", +} +local AURA_POINT_ORDER = { + "TOP", "BOTTOM", "LEFT", "RIGHT", + "TOPLEFT", "TOPRIGHT", "BOTTOMLEFT", "BOTTOMRIGHT", "CENTER", +} +local GROW_DIR_VALUES = { LEFT = "Left", RIGHT = "Right", UP = "Up", DOWN = "Down" } +local GROW_DIR_ORDER = { "LEFT", "RIGHT", "UP", "DOWN" } +local ICON_WRAP_VALUES = { LEFT = "Left", RIGHT = "Right" } +local ICON_WRAP_ORDER = { "LEFT", "RIGHT" } + +-- Native AuraContainerSortMethod/AuraContainerSortDirection enum names +-- (in-game dump, 2026-08-03: AuraContainerSortMethod = {Default=0, +-- BigDefensive=1, UnitFrameDebuff=2, ImportantOnly=3, Expiration=4, +-- ExpirationOnly=5, Name=6, NameOnly=7, AuraInstanceIDOnly=8}, +-- AuraContainerSortDirection = {Normal=0, Reverse=1}). Curated down +-- (2026-08-03, Joel) to the 4 values whose names are unambiguous for an +-- aura bar -- BigDefensive/UnitFrameDebuff/ExpirationOnly/NameOnly/ +-- AuraInstanceIDOnly read as narrower, other-UI-specific variants and are +-- deliberately left out of this dropdown (their exact behavior isn't +-- documented anywhere in this repo either way). +-- +-- "Important" (native key ImportantOnly) sorts by `C_Spell.IsSpellImportant` +-- (verified 2026-08-03 against Blizzard's PTR source, AuraUtil.lua's +-- ImportantOnlyAuraCompare) -- a native per-spell flag, not dispel-type- +-- based and not debuff-specific, so it's equally meaningful for buffs. +-- (Originally hidden from the buff-side dropdown under a wrong assumption +-- that it meant "dispellable debuffs first"; corrected, now shared.) +local SORT_METHOD_VALUES = { + Default = "Default", ImportantOnly = "Important", + Expiration = "Expiration", Name = "Name", +} +local SORT_METHOD_ORDER = { "Default", "Expiration", "Name", "ImportantOnly" } +local SORT_DIR_VALUES = { Normal = "Normal", Reverse = "Reverse" } +local SORT_DIR_ORDER = { "Normal", "Reverse" } + +local DISPEL_COLOR_ROWS = { + { key = "dispelColorMagic", label = "Magic", fallback = { 0.349, 0.475, 1.0 } }, + { key = "dispelColorCurse", label = "Curse", fallback = { 0.636, 0.0, 0.64 } }, + { key = "dispelColorDisease", label = "Disease", fallback = { 0.671, 0.384, 0.098 } }, + { key = "dispelColorPoison", label = "Poison", fallback = { 0.0, 0.706, 0.286 } }, + { key = "dispelColorBleed", label = "Bleed", fallback = { 0.75, 0.15, 0.15 } }, +} + +-- ns.PAB_AllPresetSpells() intentionally contains resolved `alts` as their +-- own entries (rank/talent-variant spellIDs of the same buff family, see +-- that function's own doc comment) -- correct for PAB_ResolveSpells (any +-- of them tracks the buff), but every UI list drawing from that universe +-- (Extra Spells' Presets group, Filter Editor's Search Spells) showed them +-- as same-named duplicate rows, which read as a bug rather than a feature. +-- Deduped here, display-only, by resolved spell name -- first (lowest) +-- spellID per name wins, matching table.sort's ascending id order. A +-- spell whose name can't be resolved yet (not cached client-side) falls +-- back to its own id as the dedup key so it never collapses into an +-- unrelated entry. +local function DedupedPresetSpellUniverse() + local universe = (ns.PAB_AllPresetSpells and ns.PAB_AllPresetSpells()) or {} + local seenNames, out = {}, {} + for i = 1, #universe do + local id = universe[i] + local name = C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(id) + local dedupKey = name or ("id:" .. id) + if not seenNames[dedupKey] then + seenNames[dedupKey] = true + out[#out + 1] = id + end + end + return out +end + +-- Same display-only dedup pass, applied to the "Filters" assignment +-- dropdown (ASSIGNED BUFFS' Filters -- distinct from the Filter Editor's +-- own sidebar list, which manages the real filter objects individually +-- and must NOT be deduped or renaming/deleting the "hidden" duplicate +-- becomes impossible). ns.PAB_Filters() can end up with same-named +-- entries (e.g. two user-created filters both left at the "New Filter" +-- default, or duplicate presets from an earlier import-migration bug) -- +-- first (lowest id, i.e. oldest) entry per name wins. +-- Alphabetical, case-insensitive by name (2026-08-03, Joel: editable +-- filters should always list alphabetically) -- applied to both the +-- Filters assignment dropdown (via DedupedFilterItems below) and the +-- Filter Editor's own sidebar (ns.PABMP_ShowFilterEditor). Always returns +-- a FRESH copy -- ns.PAB_Filters() hands back the live persisted list +-- (EllesmereUIUnitFrames_PlayerAuraBars.lua's ns.PAB_Filters, `store.list` +-- directly), so sorting in place would silently reorder SavedVariables +-- every time the editor is opened. +local function SortFiltersByName(list) + local out = {} + for i = 1, #list do out[i] = list[i] end + table.sort(out, function(a, b) return (a.name or ""):lower() < (b.name or ""):lower() end) + return out +end + +local function DedupedFilterItems() + local filters = (ns.PAB_Filters and ns.PAB_Filters()) or {} + local seenNames, out = {}, {} + for i = 1, #filters do + local f = filters[i] + if not seenNames[f.name] then + seenNames[f.name] = true + out[#out + 1] = f + end + end + return SortFiltersByName(out) +end + +-- Custom buff bar sidebar tile subtitle -- was a flat "N spells" (the +-- RESOLVED spell count, filters expanded), which didn't tell the user +-- WHICH selection mode a bar was actually in. Now reflects the bar's +-- actual ASSIGNED BUFFS config instead of the resolved count: +-- Show All Buffs on: "Show All Buffs" (+ " + N spells" if Extra Spells +-- also has entries -- Extra Spells stays active/visible regardless of +-- Show All Buffs, see BuildAssignedBuffsFields' exRow, so it's still +-- meaningful to surface here). +-- Show All Buffs off: first 3 selected filters' names, comma-joined, in +-- ns.PAB_Filters() list order (map iteration via bar.filters alone is +-- unordered -- would make the tile flicker between refreshes). If 3+ +-- filters are selected the 3rd shown name is truncated to its first 3 +-- characters + "..." as an overflow hint. Falls back to the old +-- resolved-count phrasing when no filters are selected at all (e.g. a +-- bar with only Extra Spells and Show All Buffs off). +local function TruncateFilterName(name) + return (name or ""):sub(1, 3) .. "..." +end + +local function BuildBuffBarSubtitle(bar) + local extraCount = bar.spells and #bar.spells or 0 + + if bar.showAllBuffs ~= false then + if extraCount > 0 then + return L("All Buffs") .. " + " .. extraCount .. " " .. L("spells") + end + return L("All Buffs") + end + + local names, totalSelected = {}, 0 + if bar.filters then + local allFilters = ns.PAB_Filters and ns.PAB_Filters() + if allFilters then + for i = 1, #allFilters do + local f = allFilters[i] + if bar.filters[f.id] then + totalSelected = totalSelected + 1 + if #names < 3 then names[#names + 1] = f.name end + end + end + end + end + + if #names == 0 then + local resolved = ns.PAB_ResolveSpells and ns.PAB_ResolveSpells(bar) or (bar.spells or {}) + return tostring(#resolved) .. " " .. L("spells") + end + + if totalSelected >= 3 then + names[#names] = TruncateFilterName(names[#names]) + end + + local label = table.concat(names, ", ") + if extraCount > 0 then + label = label .. " + " .. extraCount .. " " .. L("spells") + end + return label +end + +-- Custom debuff bar sidebar tile subtitle -- same "reflect the actual +-- ASSIGNED DEBUFFS config" fix as BuildBuffBarSubtitle above, just for the +-- debuff shape (bar.showAllDebuffs + bar.classFilters, no filters/extra +-- spells concept on the debuff side -- custom debuff bars are pure +-- class-token selection, see ns.PAB_AddCustomDebuffBar). +local function BuildDebuffBarSubtitle(bar) + if bar.showAllDebuffs ~= false then + return L("Show All Debuffs") + end + local nc = 0 + if bar.classFilters then for _ in pairs(bar.classFilters) do nc = nc + 1 end end + return tostring(nc) .. " " .. (nc == 1 and L("class") or L("classes")) +end + +------------------------------------------------------------------------------- +-- Shared field builders -- used by BOTH the default bars and custom bars. +-- cfg is whatever table the caller wants read/written: DefaultBuffsCfg(s), +-- DefaultDebuffsCfg(s), or a custom bar object itself (custom bar objects +-- carry the same field names directly on themselves, see the CRUD comment +-- in EllesmereUIUnitFrames_PlayerAuraBars.lua). apply() is called after +-- every field edit; callers decide what that means for them (Restyle()+ +-- ApplyLiveConfig() for default bars, PAB_ReloadCustomBuffBar/DebuffBar +-- for custom bars). +------------------------------------------------------------------------------- + +-- "Assigned Buffs": Filters checkbox dropdown (references the shared PAB +-- Filters registry, EllesmereUIUnitFrames_PlayerAuraBars.lua) + Extra +-- Spells checkbox dropdown (direct SpellIDs, cfg.spells). Mirrors +-- ns.BMP_BuildAssignedFilters (EUI_RaidFrames_ManagerPages.lua) 1:1 in +-- widget structure. Deliberately NOT a full port: PAB has no curated +-- preset-spell universe (see PAB_Filters' doc comment), so the Extra +-- Spells dropdown only ever lists the bar's own already-added spells +-- under "Selected" plus the "Custom Spell ID" action -- no "Presets" +-- group, there is nothing to browse. Used by BOTH the default Buffs bar +-- and every custom buff bar (unified onto one model 2026-08-01). +local function BuildAssignedBuffsFields(frame, fontPath, sy, cfg, apply) + local W = EllesmereUI.Widgets + local PP = EllesmereUI.PanelPP + local _, hh = 0, 0 + + _, hh = W:SectionHeader(frame, "ASSIGNED BUFFS", sy); sy = sy - hh + + -- "Filters": single unified checkbox dropdown (2026-08-03 redesign, + -- Joel). Was a separate "Show All Buffs" toggle blocking a whole + -- second "Filters" dropdown via an overlay frame -- now folded into + -- ONE dropdown as pinned, non-editable pseudo-filter rows above a + -- divider, followed by the real (user-editable) PAB_Filters entries: + -- + -- Edit Filters (pinned top action, unchanged) + -- [ ] All Buffs (key PAB_ALL_BUFFS_KEY -> cfg.showAllBuffs, never locked) + -- [ ] Has Duration (key PAB_HAS_DURATION_KEY -> cfg.hasDuration, NEVER locked either -- + -- see below, it narrows All Buffs too, unlike real filters) + -- ------------------- (isHeader, blank label -- plain divider line) + -- [ ] (locked while All Buffs is on) + -- + -- "All Buffs" replaces the old standalone toggle 1:1 (same cfg field, + -- same "nil == on" default). "Has Duration" is new: native + -- `candidateFilters.maxDuration` (see BuffCandidateExtras in + -- EllesmereUIUnitFrames_PlayerAuraBars.lua) -- excludes permanent + -- (duration=0) buffs from whatever this bar is already showing, + -- INCLUDING the All Buffs catch-all itself (BuffCandidateExtras is + -- merged onto every active buff group, not just the "spells" one) -- + -- so unlike real Filters/Extra Spells, it must stay usable while All + -- Buffs is on (2026-08-03 fix: it was wrongly locked alongside real + -- filters, making it impossible to ever select). Neither pseudo-filter + -- is a real ns.PAB_Filters() entry, so neither appears in the Filter + -- Editor sidebar. + -- + -- Locking (real filters only) uses BuildVisOptsCBDropdown's existing + -- item.lockedFn/item.lockedTooltip (greys the row, blocks its click, + -- tooltip on hover) -- an already-generic, pre-existing mechanism (used + -- elsewhere for rows whose availability depends on another selection), + -- not a new addition to the shared widget. Kept for the same reason the + -- old toggle blocked the dropdown: while All Buffs is on, a real + -- filter's spell selection is redundant (All Buffs already shows + -- everything) -- Has Duration's exclusion is NOT redundant, hence the + -- exemption above. + -- + -- Extra Spells (direct SpellIDs) shares this same row (2026-08-03, + -- Joel: "Filters [XYZ] | Extra Spells [XYZ]") -- see the RIGHT-region + -- block below. + local ffRow + ffRow, hh = W:DualRow(frame, sy, + { + type = "dropdown", text = "Filters", + values = { __placeholder = "..." }, order = { "__placeholder" }, + getValue = function() return "__placeholder" end, setValue = function() end + }, + { + type = "dropdown", text = "Extra Spells", + values = { __placeholder = "..." }, order = { "__placeholder" }, + getValue = function() return "__placeholder" end, setValue = function() end + } + ); sy = sy - hh + + local PAB_ALL_BUFFS_KEY, PAB_HAS_DURATION_KEY = "__allBuffs", "__hasDuration" + + -- LEFT: Filters checkbox dropdown, "Edit Filters" pinned top action. + do + local rgn = ffRow._leftRegion + if rgn._control then rgn._control:Hide() end + local function AllBuffsOn() return cfg.showAllBuffs ~= false end + local function LockedWhileAllBuffs() return AllBuffsOn() end + local function FilterItems() + local filters = DedupedFilterItems() + local items = { + { isTopAction = true, label = "Edit Filters", onClick = function() + ns.PABMP_ShowFilterEditor() + end }, + { key = PAB_ALL_BUFFS_KEY, label = "All Buffs", + tooltip = "Show every buff. Filters/Extra Spells are ignored while this is on." }, + { key = PAB_HAS_DURATION_KEY, label = "Has Duration", + tooltip = "Only show buffs with a duration (hides permanent buffs)." }, + { isHeader = true, label = "" }, + } + for i = 1, #filters do + items[#items + 1] = { key = filters[i].id, label = filters[i].name, + lockedFn = LockedWhileAllBuffs, + lockedTooltip = function() return EllesmereUI.DisabledTooltip("All Buffs", "disabled") end } + end + return items + end + local cbDD, cbRefresh = EllesmereUI.BuildVisOptsCBDropdown( + rgn, 190, rgn:GetFrameLevel() + 2, + FilterItems, + function(k) + if k == PAB_ALL_BUFFS_KEY then return AllBuffsOn() end + if k == PAB_HAS_DURATION_KEY then return cfg.hasDuration == true end + cfg.filters = cfg.filters or {} + return cfg.filters[k] == true + end, + function(k, v) + if k == PAB_ALL_BUFFS_KEY then + cfg.showAllBuffs = v + if v then + -- Turning All Buffs on deselects every real editable + -- filter (2026-08-03, Joel) -- they'd be locked/ + -- redundant anyway while it's on, this just keeps + -- the stored selection from lying dormant. Extra + -- Spells and Has Duration are untouched: neither is + -- an "editable filter" and both stay meaningful + -- alongside All Buffs. + cfg.filters = nil + end + apply() + EllesmereUI:RefreshPage(true) + return + end + if k == PAB_HAS_DURATION_KEY then + cfg.hasDuration = v or nil + apply() + EllesmereUI:RefreshPage() + return + end + cfg.filters = cfg.filters or {} + cfg.filters[k] = v or nil + apply() + -- Non-force: runs only the registered lightweight refresh + -- callbacks (e.g. the sidebar tile's subtitleFn) in-place, + -- unlike RefreshPage(true) which would tear down and + -- rebuild the whole page -- and close this open dropdown + -- mid multi-select. + EllesmereUI:RefreshPage() + end, + nil, 12) + PP.Point(cbDD, "RIGHT", rgn, "RIGHT", -20, 0) + rgn._control = cbDD; rgn._lastInline = nil + EllesmereUI.RegisterWidgetRefresh(cbRefresh) + end + + -- RIGHT: Extra Spells checkbox dropdown (direct cfg.spells only, see + -- doc comment above for why there is no "Presets" group here). Shares + -- ffRow with Filters (2026-08-03 redesign) instead of its own row. + do + local rgn = ffRow._rightRegion + if rgn._control then rgn._control:Hide() end + local function HasDirect(id) + local sp = cfg.spells + if not sp then return false end + for i = 1, #sp do if sp[i] == id then return true end end + return false + end + local function ShowCustomIdPopup() + EllesmereUI:ShowInputPopup({ + title = L("Add Spell ID"), + message = L("Enter the spell ID to track."), + confirmText = L("Add"), cancelText = L("Cancel"), + onConfirm = function(text) + local id = tonumber(text or "") + if id and id > 0 and not HasDirect(id) then + cfg.spells = cfg.spells or {} + cfg.spells[#cfg.spells + 1] = id + apply() + EllesmereUI:RefreshPage(true) + end + end, + }) + end + local function SpellEntry(id) + local name = C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(id) + return { key = id, label = (name or ("Spell " .. tostring(id))), + icon = C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(id) } + end + local function ByLabel(a, b) return a.label < b.label end + local function ExtraItems() + -- Spells already provided by ASSIGNED FILTERS' enabled spells + -- are excluded from Presets (adding them as extras would be + -- redundant) -- same exclusion BM2 applies. + local covered = {} + if cfg.filters then + for fid in pairs(cfg.filters) do + local f = ns.PAB_GetFilter and ns.PAB_GetFilter(fid) + if f then + for id, on in pairs(f.spells) do + if on then covered[id] = true end + end + end + end + end + local universe = DedupedPresetSpellUniverse() + local selected, rest = {}, {} + local seen = {} + local sp = cfg.spells or {} + for i = 1, #sp do + seen[sp[i]] = true + selected[#selected + 1] = SpellEntry(sp[i]) + end + for i = 1, #universe do + local id = universe[i] + if not seen[id] and not covered[id] then rest[#rest + 1] = SpellEntry(id) end + end + table.sort(selected, ByLabel) + table.sort(rest, ByLabel) + local items = { + { isTopAction = true, label = "Custom Spell ID", onClick = ShowCustomIdPopup }, + } + if #selected > 0 then + items[#items + 1] = { isHeader = true, label = "Selected" } + for i = 1, #selected do items[#items + 1] = selected[i] end + end + items[#items + 1] = { isHeader = true, label = "Presets" } + for i = 1, #rest do items[#items + 1] = rest[i] end + return items + end + local cbDD, cbRefresh = EllesmereUI.BuildVisOptsCBDropdown( + rgn, 190, rgn:GetFrameLevel() + 2, + ExtraItems, + HasDirect, + function(k, v) + cfg.spells = cfg.spells or {} + if v then + if not HasDirect(k) then cfg.spells[#cfg.spells + 1] = k end + else + for i = #cfg.spells, 1, -1 do + if cfg.spells[i] == k then table.remove(cfg.spells, i) end + end + end + apply() + -- Same reasoning as the Filters dropdown above: lightweight + -- refresh only, so this checkbox dropdown stays open. + EllesmereUI:RefreshPage() + end, + nil, 10, true) + PP.Point(cbDD, "RIGHT", rgn, "RIGHT", -20, 0) + rgn._control = cbDD; rgn._lastInline = nil + EllesmereUI.RegisterWidgetRefresh(cbRefresh) + end + + return sy +end + +-- "Assigned Debuffs": Show All Debuffs toggle + Base Filters (class-token) +-- checkbox dropdown, blocked while Show All is on. Mirrors RaidFrames' +-- DebuffManager BuildBaseDetailDM "ASSIGNED DEBUFFS" row (same blocking- +-- overlay pattern). Used by BOTH the default Debuffs bar and every custom +-- debuff bar. +local function BuildAssignedDebuffsFields(frame, fontPath, sy, cfg, apply) + local W = EllesmereUI.Widgets + local PP = EllesmereUI.PanelPP + local _, hh = 0, 0 + + _, hh = W:SectionHeader(frame, "ASSIGNED DEBUFFS", sy); sy = sy - hh + + local safRow + safRow, hh = W:DualRow(frame, sy, + { + type = "toggle", text = "Show All Debuffs", + tooltip = "Show every debuff. The Base Filters dropdown is ignored while this is on.", + -- ~= false (not == true): defaults to ON, mirrors Show All + -- Buffs' own "nil == on" convention (2026-08-02 symmetry fix). + -- Stores the raw boolean directly, same as showAllBuffs' + -- setValue -- NOT normalized to nil/true, since nil must mean + -- "on" now, the opposite of what it meant before this fix. + getValue = function() return cfg.showAllDebuffs ~= false end, + setValue = function(v) + cfg.showAllDebuffs = v + apply() + EllesmereUI:RefreshPage(true) + end + }, + { + type = "dropdown", text = "Base Filters", + values = { __placeholder = "..." }, order = { "__placeholder" }, + getValue = function() return "__placeholder" end, setValue = function() end + } + ); sy = sy - hh + do + local rgn = safRow._rightRegion + if rgn._control then rgn._control:Hide() end + local items = ns.PAB_ClassItems and ns.PAB_ClassItems(false) or {} + local cbDD, cbRefresh = EllesmereUI.BuildVisOptsCBDropdown( + rgn, 190, rgn:GetFrameLevel() + 2, items, + function(k) + cfg.classFilters = cfg.classFilters or {} + return cfg.classFilters[k] == true + end, + function(k, v) + cfg.classFilters = cfg.classFilters or {} + cfg.classFilters[k] = v or nil + apply() + -- Non-force: same reasoning as the buff-side Filters/Extra + -- Spells dropdowns -- runs only the registered lightweight + -- refresh callbacks (e.g. the sidebar tile's subtitleFn) in + -- place, without closing this open dropdown. + EllesmereUI:RefreshPage() + end) + PP.Point(cbDD, "RIGHT", rgn, "RIGHT", -20, 0) + rgn._control = cbDD; rgn._lastInline = nil + EllesmereUI.RegisterWidgetRefresh(cbRefresh) + + -- Blocked while Show All Debuffs is on (canonical blocking-overlay + -- pattern for a conditionally-interactive inline control, mirrors + -- RaidFrames' BuildBaseDetailDM). + local block = CreateFrame("Frame", nil, cbDD) + block:SetAllPoints() + block:SetFrameLevel(cbDD:GetFrameLevel() + 10) + block:EnableMouse(true) + block:SetScript("OnEnter", function() + EllesmereUI.ShowWidgetTooltip(cbDD, EllesmereUI.DisabledTooltip("Show All Debuffs", "disabled")) + end) + block:SetScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) + local function UpdateState() + local allOn = cfg.showAllDebuffs ~= false + cbDD:SetAlpha(allOn and 0.4 or 1) + block:SetShown(allOn) + end + EllesmereUI.RegisterWidgetRefresh(UpdateState) + UpdateState() + end + + return sy +end + +-- "Core": Icon Size (+Icon Zoom cog) | Growth Direction; +-- Sort Method | Sort Direction; +-- Duration [+expand][swatch][toggle] | Stacks [+expand][swatch][toggle]. +-- Shared by every bar (default + custom, buff + debuff) -- growDirection +-- is generic (BuildContainerSpec doesn't care about slots vs. groups). +-- isBuff is currently unused here (Sort Method's option set is now shared +-- between buffs/debuffs, see SORT_METHOD_VALUES' doc comment) but kept for +-- callers/future per-polarity fields. +local function BuildCoreFields(frame, fontPath, sy, cfg, apply, isBuff) + local W = EllesmereUI.Widgets + local PP = EllesmereUI.PanelPP + local _, hh = 0, 0 + + _, hh = W:SectionHeader(frame, "CORE", sy); sy = sy - hh + + local sizeRow + sizeRow, hh = W:DualRow(frame, sy, + { + type = "slider", text = "Icon Size", min = 16, max = 60, step = 1, trackWidth = 120, + getValue = function() return cfg.iconSize or 32 end, + setValue = function(v) cfg.iconSize = v; apply() end + }, + { + type = "dropdown", text = "Growth Direction", + values = GROW_DIR_VALUES, order = GROW_DIR_ORDER, + getValue = function() return cfg.growDirection or "LEFT" end, + setValue = function(v) cfg.growDirection = v; apply() end + } + ); sy = sy - hh + + _, hh = W:DualRow(frame, sy, + { + type = "dropdown", text = "Sort Method", + values = SORT_METHOD_VALUES, order = SORT_METHOD_ORDER, + getValue = function() return cfg.sortMethod or "Default" end, + setValue = function(v) cfg.sortMethod = v; apply() end + }, + { + type = "dropdown", text = "Sort Direction", + values = SORT_DIR_VALUES, order = SORT_DIR_ORDER, + getValue = function() return cfg.sortDirection or "Normal" end, + setValue = function(v) cfg.sortDirection = v; apply() end + } + ); sy = sy - hh + do + local rgn = sizeRow._leftRegion + local _, cogShow = EllesmereUI.BuildCogPopup({ + title = "Icon Size", + rows = { + { type = "slider", label = "Icon Zoom", min = 0, max = 0.20, step = 0.01, + get = function() return cfg.iconZoom or 0.055 end, + set = function(v) cfg.iconZoom = v; apply() end }, + }, + }) + ns._PAMakeCogBtn(rgn, cogShow) + end + do + -- Icon Wrap (2026-08-04, Joel): only meaningful for vertical growth + -- (Up/Down) -- decides which side additional columns stack toward + -- when Icons Per Row/Column > 1. Cog-only, no separate dropdown row, + -- and only shown while Growth Direction is Up/Down. + local rgn = sizeRow._rightRegion + local _, cogShow = EllesmereUI.BuildCogPopup({ + title = "Growth", + rows = { + { type = "dropdown", label = "Icon Wrap", + values = ICON_WRAP_VALUES, order = ICON_WRAP_ORDER, + get = function() return cfg.iconWrapDirection or "LEFT" end, + set = function(v) cfg.iconWrapDirection = v; apply() end }, + }, + }) + local cogBtn = ns._PAMakeCogBtn(rgn, cogShow) + local function UpdateWrapCogVisibility() + local dir = cfg.growDirection or "LEFT" + cogBtn:SetShown(dir == "UP" or dir == "DOWN") + end + EllesmereUI.RegisterWidgetRefresh(UpdateWrapCogVisibility) + UpdateWrapCogVisibility() + end + + local function AttachCog(rgn, title, rows) + local _, cogShow = EllesmereUI.BuildCogPopup({ title = title, rows = rows }) + local cogBtn = CreateFrame("Button", nil, rgn) + cogBtn:SetSize(26, 26) + cogBtn:SetPoint("RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) + rgn._lastInline = cogBtn + cogBtn:SetFrameLevel(rgn:GetFrameLevel() + 5) + cogBtn:SetAlpha(0.4) + local cogTex = cogBtn:CreateTexture(nil, "OVERLAY") + cogTex:SetAllPoints(); cogTex:SetTexture(EllesmereUI.RESIZE_ICON) + cogBtn:SetScript("OnEnter", function(self) self:SetAlpha(0.7) end) + cogBtn:SetScript("OnLeave", function(self) self:SetAlpha(0.4) end) + cogBtn:SetScript("OnClick", function(self) cogShow(self) end) + end + + local dsRow + dsRow, hh = W:DualRow(frame, sy, + { + type = "toggle", text = "Duration", + getValue = function() return cfg.durationShow ~= false end, + setValue = function(v) cfg.durationShow = v; apply() end + }, + { + type = "toggle", text = "Stacks", + getValue = function() return cfg.stackShow ~= false end, + setValue = function(v) cfg.stackShow = v; apply() end + } + ); sy = sy - hh + do + local rgn = dsRow._leftRegion + local swatch, updateSwatch = EllesmereUI.BuildColorSwatch( + rgn, dsRow:GetFrameLevel() + 3, + function() return (cfg.durationColorR or 1), (cfg.durationColorG or 1), (cfg.durationColorB or 1), 1 end, + function(r, g, b) cfg.durationColorR, cfg.durationColorG, cfg.durationColorB = r, g, b; apply() end, + false, 20) + swatch:SetPoint("RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) + rgn._lastInline = swatch + EllesmereUI.RegisterWidgetRefresh(updateSwatch) + AttachCog(rgn, "Duration Text", { + { type = "slider", label = "Text Size", min = 6, max = 24, step = 1, + get = function() return cfg.durationTextSize or 11 end, + set = function(v) cfg.durationTextSize = v; apply() end }, + { type = "slider", label = "Offset X", min = -50, max = 50, step = 1, + get = function() return cfg.durationOffsetX or 0 end, + set = function(v) cfg.durationOffsetX = v; apply() end }, + { type = "slider", label = "Offset Y", min = -50, max = 50, step = 1, + get = function() return cfg.durationOffsetY or 0 end, + set = function(v) cfg.durationOffsetY = v; apply() end }, + { type = "dropdown", label = "Position", + values = AURA_POINT_VALUES, order = AURA_POINT_ORDER, + get = function() return cfg.durationPosition or "BOTTOM" end, + set = function(v) cfg.durationPosition = v; apply() end }, + }) + end + do + local rgn = dsRow._rightRegion + local swatch, updateSwatch = EllesmereUI.BuildColorSwatch( + rgn, dsRow:GetFrameLevel() + 3, + function() return (cfg.stackColorR or 1), (cfg.stackColorG or 1), (cfg.stackColorB or 1), 1 end, + function(r, g, b) cfg.stackColorR, cfg.stackColorG, cfg.stackColorB = r, g, b; apply() end, + false, 20) + swatch:SetPoint("RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) + rgn._lastInline = swatch + EllesmereUI.RegisterWidgetRefresh(updateSwatch) + AttachCog(rgn, "Stacks Text", { + { type = "slider", label = "Text Size", min = 6, max = 24, step = 1, + get = function() return cfg.stackTextSize or 11 end, + set = function(v) cfg.stackTextSize = v; apply() end }, + { type = "slider", label = "Offset X", min = -50, max = 50, step = 1, + get = function() return cfg.stackOffsetX or 0 end, + set = function(v) cfg.stackOffsetX = v; apply() end }, + { type = "slider", label = "Offset Y", min = -50, max = 50, step = 1, + get = function() return cfg.stackOffsetY or 0 end, + set = function(v) cfg.stackOffsetY = v; apply() end }, + { type = "dropdown", label = "Position", + values = AURA_POINT_VALUES, order = AURA_POINT_ORDER, + get = function() return cfg.stackPosition or "TOP" end, + set = function(v) cfg.stackPosition = v; apply() end }, + }) + end + + return sy +end + +-- "Display": Border Size [swatch] | Spacing; Icons per Row (+Max Rows/Max +-- Total/Row Spacing cog) | spacer. +local function BuildDisplayFields(frame, fontPath, sy, cfg, apply, isBuff) + local W = EllesmereUI.Widgets + local PP = EllesmereUI.PanelPP + local _, hh = 0, 0 + + _, hh = W:SectionHeader(frame, "DISPLAY", sy); sy = sy - hh + + local borderRow + borderRow, hh = W:DualRow(frame, sy, + { + type = "slider", text = "Border Size", min = 0, max = 4, step = 1, trackWidth = 120, + getValue = function() return cfg.borderSize or 1 end, + setValue = function(v) cfg.borderSize = v; apply() end + }, + { + type = "slider", text = "Spacing", min = 0, max = 20, step = 1, trackWidth = 120, + getValue = function() return cfg.padding or 5 end, + setValue = function(v) cfg.padding = v; apply() end + } + ); sy = sy - hh + do + local rgn = borderRow._leftRegion + local swatch, updateSwatch = EllesmereUI.BuildColorSwatch( + rgn, borderRow:GetFrameLevel() + 3, + function() + return (cfg.borderR or 0), (cfg.borderG or 0), (cfg.borderB or 0), (cfg.borderA or 1) + end, + function(r, g, b, a) + cfg.borderR, cfg.borderG, cfg.borderB, cfg.borderA = r, g, b, a + apply() + end, + true, 20) + swatch:SetPoint("RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) + rgn._lastInline = swatch + EllesmereUI.RegisterWidgetRefresh(updateSwatch) + end + do + -- Row Spacing moved here from Icons Per Row's cog (2026-08-03, + -- Joel) -- it's spacing between rows, same family as Spacing + -- (icon-to-icon gap), not a grid-size concern like Icons Per Row/ + -- Max Rows/Max Total. + local rgn = borderRow._rightRegion + local _, cogShow = EllesmereUI.BuildCogPopup({ + title = "Spacing", + rows = { + -- nil = 12px default (2026-08-04, Joel) -- deliberately + -- decoupled from Spacing/padding, no longer mirrors it. + { type = "slider", label = "Row Spacing", min = 0, max = 20, step = 1, + get = function() return cfg.rowSpacing or 12 end, + set = function(v) cfg.rowSpacing = v; apply() end }, + }, + }) + ns._PAMakeCogBtn(rgn, cogShow) + end + + local rowRow + rowRow, hh = W:DualRow(frame, sy, + { + type = "slider", text = "Icons Per Row", min = 1, max = 20, step = 1, trackWidth = 120, + getValue = function() return cfg.iconsPerRow or (isBuff and 11 or 8) end, + setValue = function(v) cfg.iconsPerRow = v; apply() end + }, + { type = "label", text = "" } + ); sy = sy - hh + do + -- Verified against EUI_RaidFrames_BuffManager.lua's "legacy layout" + -- branch: perRowCfg paired with a blank spacer, cog on + -- gridRow._leftRegion -- i.e. directly on Icons Per Row's own + -- region. Same trackWidth=120 slider + cog combo used there. + local rgn = rowRow._leftRegion + local _, cogShow = EllesmereUI.BuildCogPopup({ + title = "Icons Per Row", + rows = { + { type = "slider", label = "Max Rows", min = 1, max = 10, step = 1, + get = function() return cfg.maxRows or (isBuff and 3 or 2) end, + set = function(v) cfg.maxRows = v; apply() end }, + { type = "slider", label = "Max Total", min = 1, max = 40, step = 1, + get = function() return cfg.maxTotal or (isBuff and 32 or 16) end, + set = function(v) cfg.maxTotal = v; apply() end }, + }, + }) + ns._PAMakeCogBtn(rgn, cogShow) + end + + return sy +end + +-- Debuff-category bars only (default debuffs + custom debuff bars). +local function BuildDispelColorFields(frame, fontPath, sy, cfg, apply) + local W = EllesmereUI.Widgets + local PP = EllesmereUI.PanelPP + local _, hh = 0, 0 + + _, hh = W:SectionHeader(frame, "DISPEL COLORS", sy); sy = sy - hh + + local function AddDispelSwatch(rgn, entry) + local swatch, updateSwatch = EllesmereUI.BuildColorSwatch( + rgn, rgn:GetFrameLevel() + 3, + function() + local c = cfg[entry.key] + if c then return c.r or 1, c.g or 1, c.b or 1, 1 end + return entry.fallback[1], entry.fallback[2], entry.fallback[3], 1 + end, + function(r, g, b) + cfg[entry.key] = { r = r, g = g, b = b } + apply() + end, + false, 18) + PP.Point(swatch, "RIGHT", rgn, "RIGHT", -20, 0) + EllesmereUI.RegisterWidgetRefresh(updateSwatch) + end + for i = 1, #DISPEL_COLOR_ROWS, 2 do + local left, right = DISPEL_COLOR_ROWS[i], DISPEL_COLOR_ROWS[i + 1] + local row + row, hh = W:DualRow(frame, sy, + { type = "label", text = left.label }, + right and { type = "label", text = right.label } or { type = "label", text = "" } + ); sy = sy - hh + AddDispelSwatch(row._leftRegion, left) + if right then AddDispelSwatch(row._rightRegion, right) end + end + + return sy +end + +------------------------------------------------------------------------------- +-- Icon Effects Per-Filter (debuffs only) -- ported from Raid Frames' +-- BuildFxEffects (EUI_RaidFrames_ManagerPages.lua), NOT shared code. Each +-- cfg.fxList entry: a Filters set (ns.PAB_FxClassItems -- PAB's debuff +-- category vocabulary keyed by the lowercase engine group key, plus a +-- synthetic "all" catch-all) + optional Icon Glow + Border override + Size +-- override. The engine side (EllesmereUIUnitFrames_PlayerAuraBars.lua) +-- matches the FIRST active block whose filters include a button's +-- category -- see PAB_ApplyDmFx/PAB_FxBlockFor there. +------------------------------------------------------------------------------- + +local function BuildFxEffects(frame, sy, cfg, apply) + local W = EllesmereUI.Widgets + local PP = EllesmereUI.PanelPP + if not (W and PP) then return sy end + local hh + local MEDIA_MP = "Interface\\AddOns\\EllesmereUI\\media\\icons\\" + + local list = cfg.fxList or {} + + -- Only offer styles PAB_ApplyDmFx can actually render as selected + -- (2026-08-05): every driver-ticked style (procedural/buttonGlow/ + -- autocast/shapeGlow) gets unconditionally remapped to a FlipBook-safe + -- style on real AuraButtons -- confirmed permanent in Blizzard's own + -- PTR 12.1 source (Blizzard_AuraButton.xml: useForbiddenObjectTable= + -- "true" + ForbiddenAspects incl. ChangeParent, baked into the base + -- template, not combat-conditional) -- so picking one here never + -- actually shows live. Mirrors Glows.RestrictionSafeStyle's own gate + -- (EllesmereUI_Glows.lua) rather than duplicating the style-name list. + -- Re-include if Blizzard ever exposes a supported extension point. + local GLOW_VALUES = { [0] = "None" } + local GLOW_ORDER = { 0 } + local Styles = EllesmereUI.Glows and EllesmereUI.Glows.STYLES + if Styles then + for i, entry in ipairs(Styles) do + if not (entry.procedural or entry.buttonGlow or entry.autocast or entry.shapeGlow) then + GLOW_VALUES[i] = entry.name + GLOW_ORDER[#GLOW_ORDER + 1] = i + end + end + end + + -- One "ICON EFFECTS" section block per list entry. + for bi = 1, #list do + local e = list[bi] + if not e.filters then e.filters = {} end + + local hdrRgn + hdrRgn, hh = W:SectionHeader(frame, "ICON EFFECTS", sy); sy = sy - hh + -- Remove X right after the section title text + if hdrRgn then + local del = CreateFrame("Button", nil, hdrRgn) + del:SetSize(14, 14) + if hdrRgn._label then + del:SetPoint("LEFT", hdrRgn._label, "RIGHT", 8, 0) + else + del:SetPoint("BOTTOMRIGHT", hdrRgn, "BOTTOMRIGHT", 0, 6) + end + del:SetFrameLevel(hdrRgn:GetFrameLevel() + 2) + del:SetAlpha(0.5) + local dx = del:CreateTexture(nil, "OVERLAY") + dx:SetAllPoints() + if dx.SetSnapToPixelGrid then dx:SetSnapToPixelGrid(false); dx:SetTexelSnappingBias(0) end + dx:SetTexture(MEDIA_MP .. "eui-close.png") + del:SetScript("OnEnter", function(self) + self:SetAlpha(0.9) + EllesmereUI.ShowWidgetTooltip(self, EllesmereUI.L("Delete")) + end) + del:SetScript("OnLeave", function(self) + self:SetAlpha(0.5) + EllesmereUI.HideWidgetTooltip() + end) + local blockIdx = bi + del:SetScript("OnClick", function() + table.remove(list, blockIdx) + apply() + EllesmereUI:RefreshPage(true) + end) + end + + -- Row 1: Filters | Icon Glow (+ class/custom swatches) + local row + row, hh = W:DualRow(frame, sy, + { type = "dropdown", text = "Filters", + values = { __placeholder = "..." }, order = { "__placeholder" }, + getValue = function() return "__placeholder" end, + setValue = function() end }, + { type = "dropdown", text = "Icon Glow", + values = GLOW_VALUES, order = GLOW_ORDER, + getValue = function() return e.glowType or 0 end, + setValue = function(v) e.glowType = v; apply(); EllesmereUI:RefreshPage() end }); sy = sy - hh + do + local rgn = row._leftRegion + if rgn._control then rgn._control:Hide() end + local items = ns.PAB_FxClassItems and ns.PAB_FxClassItems() or {} + local cbDD, cbRefresh = EllesmereUI.BuildVisOptsCBDropdown( + rgn, 190, rgn:GetFrameLevel() + 2, items, + function(k) return e.filters[k] == true end, + function(k, v) + e.filters[k] = v or nil + apply() + EllesmereUI:RefreshPage() + end) + PP.Point(cbDD, "RIGHT", rgn, "RIGHT", -20, 0) + rgn._control = cbDD; rgn._lastInline = nil + if cbRefresh then EllesmereUI.RegisterWidgetRefresh(cbRefresh) end + end + do + local rgn = row._rightRegion + local ctrl = rgn._control + + local classSwatch, updateClassSwatch = EllesmereUI.BuildColorSwatch( + rgn, row:GetFrameLevel() + 3, + function() + local _, classFile = UnitClass("player") + local cc = classFile and RAID_CLASS_COLORS and RAID_CLASS_COLORS[classFile] + if cc then return cc.r, cc.g, cc.b end + return 1, 0.82, 0 + end, + function() end, + false, 20) + PP.Point(classSwatch, "RIGHT", ctrl, "LEFT", -8, 0) + classSwatch:SetScript("OnClick", function() + e.glowClassColor = true; apply(); EllesmereUI:RefreshPage() + end) + classSwatch:SetScript("OnEnter", function() + EllesmereUI.ShowWidgetTooltip(classSwatch, "Class Colored") + end) + classSwatch:SetScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) + + local glowSwatch, updateGlowSwatch = EllesmereUI.BuildColorSwatch( + rgn, row:GetFrameLevel() + 3, + function() return e.glowR or 1.0, e.glowG or 0.776, e.glowB or 0.376 end, + function(r, g, b) + e.glowR, e.glowG, e.glowB = r, g, b + apply() + end, + false, 20) + PP.Point(glowSwatch, "RIGHT", classSwatch, "LEFT", -8, 0) + glowSwatch:SetScript("OnEnter", function() + EllesmereUI.ShowWidgetTooltip(glowSwatch, "Custom Colored") + end) + glowSwatch:SetScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) + -- Click the dimmed custom swatch to switch back from class color. + local origGlowClick = glowSwatch:GetScript("OnClick") + glowSwatch:SetScript("OnClick", function(self, ...) + if e.glowClassColor then + e.glowClassColor = false; apply(); EllesmereUI:RefreshPage() + return + end + if (e.glowType or 0) == 0 then return end + if origGlowClick then origGlowClick(self, ...) end + end) + + local function UpdateFxGlowState() + local noGlow = (e.glowType or 0) == 0 + local isClassColored = e.glowClassColor + glowSwatch:SetAlpha((isClassColored or noGlow) and 0.3 or 1) + classSwatch:SetAlpha((isClassColored and not noGlow) and 1 or 0.3) + end + EllesmereUI.RegisterWidgetRefresh(function() updateGlowSwatch(); updateClassSwatch(); UpdateFxGlowState() end) + UpdateFxGlowState() + end + + -- Row 2: Border (+ swatch) | Size (icon size for the matched + -- filters; 0 = the bar's own icon size). + local bRow + bRow, hh = W:DualRow(frame, sy, + { type = "slider", text = "Border", min = 0, max = 4, step = 1, trackWidth = 120, + getValue = function() return e.borderSize or 0 end, + setValue = function(v) e.borderSize = v; apply() end }, + { type = "slider", text = "Size", min = 0, max = 60, step = 1, trackWidth = 120, + getValue = function() return e.size or 0 end, + setValue = function(v) + e.size = (v and v > 0) and v or nil + apply() + end }); sy = sy - hh + do + local rgn = bRow._leftRegion + local swatch = EllesmereUI.BuildColorSwatch(rgn, bRow:GetFrameLevel() + 3, + function() + local c = e.borderColor or { r = 0, g = 0, b = 0 } + return c.r or 0, c.g or 0, c.b or 0, 1 + end, + function(r, g, b) + e.borderColor = { r = r, g = g, b = b } + apply() + end, false, 20) + swatch:SetPoint("RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) + rgn._lastInline = swatch + end + end + + -- "Add Icon Effects Per-Filter" accent text link (centered) + do + local ar, ag, ab = 1, 0.82, 0.30 + if EllesmereUI.GetAccentColor then ar, ag, ab = EllesmereUI.GetAccentColor() end + local addBtn = CreateFrame("Button", nil, frame) + addBtn:SetHeight(22) + addBtn:SetPoint("TOP", frame, "TOP", 0, sy - 17) + addBtn:SetFrameLevel(frame:GetFrameLevel() + 2) + local lbl = addBtn:CreateFontString(nil, "OVERLAY") + local fp = (EllesmereUI.GetFontPath and EllesmereUI.GetFontPath("unitFrames")) or "Fonts\\FRIZQT__.TTF" + lbl:SetFont(fp, 16, "") + lbl:SetPoint("CENTER", addBtn, "CENTER", 0, 0) + lbl:SetText(EllesmereUI.L("Add Icon Effects Per-Filter")) + lbl:SetTextColor(ar, ag, ab) + lbl:SetAlpha(0.9) + addBtn:SetWidth(lbl:GetStringWidth() + 8) + addBtn:SetScript("OnEnter", function() lbl:SetAlpha(1) end) + addBtn:SetScript("OnLeave", function() lbl:SetAlpha(0.9) end) + addBtn:SetScript("OnClick", function() + cfg.fxList = cfg.fxList or {} + cfg.fxList[#cfg.fxList + 1] = { filters = {} } + EllesmereUI:RefreshPage(true) + end) + sy = sy - 17 - 22 - 8 + end + return sy +end + +------------------------------------------------------------------------------- +-- Default bar detail (Buffs / Debuffs -- fixed identity, not deletable) +------------------------------------------------------------------------------- + +-- W:DualRow/W:SectionHeader compute their internal label/control proportions +-- assuming a frame padded like a standard single-column options page +-- (EllesmereUI.CONTENT_PAD margins on both sides, typically 45px). PAB's +-- two-pane layout only reserves 20px for its detail pane (PABMP_BuildPage), +-- so DualRow's internal math runs against a narrower frame than it expects +-- and rows overlap -- confirmed by screenshots where the overlap persists +-- regardless of which region (left/right) carries the extra widgets. +-- Verified fix, ported from RaidFrames' ns.DMP_BuildPage (the exact page +-- the reference screenshot came from): oversize the frame actually handed +-- to DualRow by `padDiff` on both sides, shift it left by `padDiff` so its +-- effective left edge still lines up with the visible 20px inset, and clip +-- the overflow on an outer frame (RaidFrames: "DualRow width compensated +-- so rows align with the 20px PAD"). +-- 2026-08-02 fix: `clip` is now a real ScrollFrame (was a plain Frame with +-- SetClipsChildren -- silently cropped any overflow with no way to reach +-- it, a pre-existing gap that only became visible once the new preview box +-- pushed settings fields below the fold). Callers MUST call +-- FinalizeCompensatedBody(body, finalSy) once after building all their +-- fields, so the scroll child's real height (and therefore the scrollbar's +-- thumb/track visibility) reflects actual content instead of a guess. +-- 2026-08-03 fix: restructured to put the padDiff shift on `scroll` itself +-- (matching RaidFrames' ns.DMP_BuildPage settingsScroll line-for-line) instead +-- of on `body` inside an unshifted scroll. Both are mathematically equivalent +-- for where DualRow/SectionHeader content ends up (verified via /fstack + +-- a full frame-tree dump: both put rows at parentFrame_left + PAD) -- but +-- Joel measured a REAL ~30px visual difference in-game (PAB ~50px, RaidFrames +-- Debuff Manager ~20px) that this math could not explain and multiple manual +-- measurements confirmed. Rather than keep guessing why the two structurally- +-- different-but-equivalent approaches render differently, this mirrors DM's +-- approach exactly since that one is confirmed correct. +local function WrapCompensatedBody(parentFrame, topOffset) + local contentPad = EllesmereUI.CONTENT_PAD or 45 + local PAD = 20 -- matches PABMP_BuildPage's own detail-pane inset + local padDiff = contentPad - PAD + local visibleW = parentFrame:GetWidth() + + local scroll = CreateFrame("ScrollFrame", nil, parentFrame) + scroll:SetPoint("TOPLEFT", parentFrame, "TOPLEFT", -padDiff, topOffset or 0) + scroll:SetPoint("BOTTOMRIGHT", parentFrame, "BOTTOMRIGHT", padDiff, 0) + scroll:SetFrameLevel(parentFrame:GetFrameLevel() + 1) + scroll:SetClipsChildren(true) + + local body = CreateFrame("Frame", nil, scroll) + body:SetSize(visibleW + padDiff * 2, 10) -- finalized by FinalizeCompensatedBody + body._showRowDivider = true + scroll:SetScrollChild(body) + body._pabUpdateThumb = AttachEditorScroll(scroll, body, nil, padDiff + 2) + + -- Every WrapCompensatedBody call in this file immediately follows a + -- PAB_BuildPreviewBox call on the same `parentFrame` (see the four + -- BuildXDetail functions below) -- registering here, instead of at each + -- call site, keeps the preview's live-resize wiring in one place. Reanchors + -- this scroll frame's top edge whenever PAB_MaybeRefreshPreview resizes + -- the preview box above it (Icons Per Row/Max Rows/Max Total/Icon Size/ + -- Row Spacing change), so the settings area below never overlaps a grown + -- box or leaves a stale gap under a shrunk one -- and refreshes the + -- scrollbar thumb/track right after, since MaxScroll()/UpdateThumb read + -- scroll:GetHeight() live and would otherwise stay stale (wrong thumb + -- size, or a track that should now show/hide) until the next scroll + -- interaction. Registered last (after body._pabUpdateThumb exists) so + -- the closure can call it. + if ns.PAB_SetPreviewResizeHandler then + ns.PAB_SetPreviewResizeHandler(function(newTopOffset) + scroll:ClearAllPoints() + scroll:SetPoint("TOPLEFT", parentFrame, "TOPLEFT", -padDiff, newTopOffset) + scroll:SetPoint("BOTTOMRIGHT", parentFrame, "BOTTOMRIGHT", padDiff, 0) + if body._pabUpdateThumb then body._pabUpdateThumb() end + end) + end + + return body +end + +-- Sizes the scroll child to its real content height and refreshes the +-- scrollbar's thumb/track visibility. Call once, after all of a detail +-- pane's fields have been built into `body` and the final sy/by +-- accumulator value is known. +local function FinalizeCompensatedBody(body, sy) + body:SetHeight(max(10, math.abs(sy) + 20)) + if body._pabUpdateThumb then body._pabUpdateThumb() end +end + +local function BuildDefaultBarDetail(frame, fontPath, isBuff) + local W = EllesmereUI.Widgets + if not W then return end + + local s = ns.db and ns.db.profile and ns.db.profile.playerAuraBars + if not s then return end + local cfg = isBuff and ns.PAB_DefaultBuffsCfg(s) or ns.PAB_DefaultDebuffsCfg(s) + + local title = frame:CreateFontString(nil, "OVERLAY") + title:SetFont(fontPath, 15, "") + title:SetPoint("TOPLEFT", frame, "TOPLEFT", 20, -14) + title:SetText(isBuff and L("Buffs") or L("Debuffs")) + title:SetTextColor(1, 1, 1, 0.95) + local desc = frame:CreateFontString(nil, "OVERLAY") + desc:SetFont(fontPath, 11, "") + desc:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -4) + desc:SetText(L("Built-in bar. Cannot be deleted.")) + desc:SetTextColor(1, 1, 1, 0.45) + + local function ApplyBar() + if ns.PAB_Restyle then ns.PAB_Restyle() end + if ns.PAB_ApplyLiveConfig then ns.PAB_ApplyLiveConfig(isBuff) end + end + + local scrollTop = -50 + if ns.PAB_BuildPreviewBox then + scrollTop = ns.PAB_BuildPreviewBox(frame, fontPath, -50, isBuff and "buff" or "debuff", "default", cfg) + end + local body = WrapCompensatedBody(frame, scrollTop) + local sy = 0 + + if isBuff then + sy = BuildAssignedBuffsFields(body, fontPath, sy, cfg, ApplyBar) + else + sy = BuildAssignedDebuffsFields(body, fontPath, sy, cfg, ApplyBar) + end + sy = BuildCoreFields(body, fontPath, sy, cfg, ApplyBar, isBuff) + sy = BuildDisplayFields(body, fontPath, sy, cfg, ApplyBar, isBuff) + if not isBuff then + sy = BuildDispelColorFields(body, fontPath, sy, cfg, ApplyBar) + sy = BuildFxEffects(body, sy, cfg, ApplyBar) + end + FinalizeCompensatedBody(body, sy) +end + +-- Third default bar, migrated from the retired standalone +-- EllesmereUIUnitFrames_ExternalDefensives.lua module. Deliberately does +-- NOT call BuildAssignedBuffsFields: its content is a single fixed engine +-- classification (EXTERNAL_DEFENSIVE), not a user-selected spell/filter +-- set, so there is nothing to assign -- only Core (icon size, grow +-- direction, border, duration/stack styling) and Display apply. See +-- ns.PAB_ApplyExtDefLiveConfig's own doc comment for the engine side. +local function BuildExternalDefensivesBarDetail(frame, fontPath) + local W = EllesmereUI.Widgets + if not W then return end + + local s = ns.db and ns.db.profile and ns.db.profile.playerAuraBars + if not s then return end + local cfg = ns.PAB_DefaultExternalDefensivesCfg and ns.PAB_DefaultExternalDefensivesCfg(s) + if not cfg then return end + + local title = frame:CreateFontString(nil, "OVERLAY") + title:SetFont(fontPath, 15, "") + title:SetPoint("TOPLEFT", frame, "TOPLEFT", 20, -14) + title:SetText(L("External Defensives")) + title:SetTextColor(1, 1, 1, 0.95) + local desc = frame:CreateFontString(nil, "OVERLAY") + desc:SetFont(fontPath, 11, "") + desc:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -4) + desc:SetText(L("Built-in bar. Shows external defensives cast on you (Pain Suppression, Ironbark, etc). Cannot be deleted.")) + desc:SetTextColor(1, 1, 1, 0.45) + + local function ApplyBar() + if ns.PAB_Restyle then ns.PAB_Restyle() end + if ns.PAB_ApplyExtDefLiveConfig then ns.PAB_ApplyExtDefLiveConfig() end + end + + local scrollTop = -50 + if ns.PAB_BuildPreviewBox then + scrollTop = ns.PAB_BuildPreviewBox(frame, fontPath, -50, "buff", "extdef", cfg) + end + local body = WrapCompensatedBody(frame, scrollTop) + local sy = 0 + + sy = BuildCoreFields(body, fontPath, sy, cfg, ApplyBar, true) + sy = BuildDisplayFields(body, fontPath, sy, cfg, ApplyBar, true) + FinalizeCompensatedBody(body, sy) +end + +------------------------------------------------------------------------------- +-- Shared tile widget (verbatim pattern from EUI_RaidFrames_ManagerPages.lua +-- BuildTile, trimmed to the fields this page actually uses) +------------------------------------------------------------------------------- + +local function BuildTile(parentFrame, y, opts) + local fontPath = opts.fontPath + local tile = CreateFrame("Button", nil, parentFrame) + tile:SetSize(opts.width, TILE_H) + tile:SetPoint("TOPLEFT", parentFrame, "TOPLEFT", 0, y) + tile:SetFrameLevel(parentFrame:GetFrameLevel() + 1) + + local bg = tile:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints() + bg:SetColorTexture(1, 1, 1, opts.selected and 0.06 or 0) + + if opts.selected then + local accent = tile:CreateTexture(nil, "ARTWORK", nil, 2) + accent:SetSize(2, TILE_H) + accent:SetPoint("TOPLEFT", tile, "TOPLEFT", 0, 0) + local ac = EllesmereUI.ELLESMERE_GREEN + if ac then accent:SetColorTexture(ac.r, ac.g, ac.b, 1) + else accent:SetColorTexture(0.05, 0.82, 0.62, 1) end + end + + local textRight = opts.showToggle and -52 or -16 + + local titleFS = tile:CreateFontString(nil, "OVERLAY") + titleFS:SetFont(fontPath, 13, "") + titleFS:SetPoint("TOPLEFT", tile, "TOPLEFT", 12, -10) + titleFS:SetPoint("RIGHT", tile, "RIGHT", textRight, 0) + titleFS:SetJustifyH("LEFT") + titleFS:SetWordWrap(false) + titleFS:SetText(opts.title or "") + titleFS:SetTextColor(1, 1, 1) + + if opts.subtitle or opts.subtitleFn then + local sub = tile:CreateFontString(nil, "OVERLAY") + sub:SetFont(fontPath, 11, "") + sub:SetPoint("TOPLEFT", titleFS, "BOTTOMLEFT", 0, -4) + sub:SetPoint("RIGHT", tile, "RIGHT", textRight, 0) + sub:SetJustifyH("LEFT") + sub:SetWordWrap(false) + sub:SetText(opts.subtitleFn and opts.subtitleFn() or opts.subtitle) + sub:SetTextColor(0.4, 0.4, 0.4) + -- subtitleFn (vs. a static subtitle string): re-read on every + -- lightweight RefreshPage() pass, e.g. after a Filters/Extra Spells + -- checkbox toggle in the detail pane -- those call apply() + + -- RefreshPage() (non-force) rather than a full page rebuild, since + -- a full rebuild would close the open checkbox dropdown mid + -- multi-select. Without this, the sidebar tile's subtitle would + -- only update on the next full page rebuild (bar select, add, + -- delete, ...), not live. + if opts.subtitleFn then + EllesmereUI.RegisterWidgetRefresh(function() sub:SetText(opts.subtitleFn()) end) + end + end + + tile:SetScript("OnEnter", function() + if not opts.selected then bg:SetColorTexture(1, 1, 1, 0.04) end + end) + tile:SetScript("OnLeave", function() + bg:SetColorTexture(1, 1, 1, opts.selected and 0.06 or 0) + end) + tile:SetScript("OnClick", function() + if opts.onSelect then opts.onSelect() end + end) + + if opts.showToggle then + local toggleH = 16 + local toggleBtn = CreateFrame("Button", nil, tile) + toggleBtn:SetSize(32, toggleH) + toggleBtn:SetPoint("TOPRIGHT", tile, "TOPRIGHT", -8, -8) + toggleBtn:SetFrameLevel(tile:GetFrameLevel() + 2) + local toggleBg = toggleBtn:CreateTexture(nil, "BACKGROUND") + toggleBg:SetAllPoints() + local toggleKnob = toggleBtn:CreateTexture(nil, "ARTWORK") + toggleKnob:SetSize(toggleH - 4, toggleH - 4) + local function UpdateToggleVisual() + toggleKnob:ClearAllPoints() + if opts.enabled then + local acr, acg, acb = 0.05, 0.82, 0.62 + if EllesmereUI.ResolveActiveAccent then + acr, acg, acb = EllesmereUI.ResolveActiveAccent() + end + toggleBg:SetColorTexture(acr, acg, acb, 1) + toggleKnob:SetPoint("RIGHT", toggleBtn, "RIGHT", -2, 0) + toggleKnob:SetColorTexture(1, 1, 1, 1) + else + toggleBg:SetColorTexture(0.25, 0.25, 0.25, 1) + toggleKnob:SetPoint("LEFT", toggleBtn, "LEFT", 2, 0) + toggleKnob:SetColorTexture(0.5, 0.5, 0.5, 1) + end + end + UpdateToggleVisual() + toggleBtn:SetScript("OnClick", function() + if opts.onToggle then opts.onToggle(not opts.enabled) end + end) + end + + local delBtn + if opts.onDelete then + delBtn = CreateFrame("Button", nil, tile) + delBtn:SetSize(16, 16) + delBtn:SetPoint("BOTTOMRIGHT", tile, "BOTTOMRIGHT", -8, 6) + delBtn:SetFrameLevel(tile:GetFrameLevel() + 2) + local delTex = delBtn:CreateTexture(nil, "OVERLAY") + delTex:SetAllPoints() + delTex:SetAtlas("common-icon-delete") + delTex:SetDesaturated(true) + delTex:SetVertexColor(0.75, 0.75, 0.75) + delBtn:SetAlpha(0.5) + delBtn:SetScript("OnEnter", function(self) self:SetAlpha(0.9) end) + delBtn:SetScript("OnLeave", function(self) self:SetAlpha(0.5) end) + delBtn:SetScript("OnClick", function() opts.onDelete() end) + end + + -- Rename icon, left of the delete icon -- same eui-edit.png pencil the + -- Filter Editor sidebar uses for its own rename affordance + -- (PABMP_ShowFilterEditor), so renaming reads consistently across the + -- whole page instead of only being reachable from the Name field. + if opts.onRename then + local editBtn = CreateFrame("Button", nil, tile) + editBtn:SetSize(14, 14) + if delBtn then + editBtn:SetPoint("RIGHT", delBtn, "LEFT", -4, 0) + else + editBtn:SetPoint("BOTTOMRIGHT", tile, "BOTTOMRIGHT", -8, 6) + end + editBtn:SetFrameLevel(tile:GetFrameLevel() + 2) + local editTex = editBtn:CreateTexture(nil, "OVERLAY") + editTex:SetAllPoints() + editTex:SetTexture("Interface\\AddOns\\EllesmereUI\\media\\icons\\eui-edit.png") + editBtn:SetAlpha(0.5) + editBtn:SetScript("OnEnter", function(self) self:SetAlpha(0.9) end) + editBtn:SetScript("OnLeave", function(self) self:SetAlpha(0.5) end) + editBtn:SetScript("OnClick", function() opts.onRename() end) + end + + local sep = tile:CreateTexture(nil, "ARTWORK") + sep:SetHeight(1) + sep:SetPoint("BOTTOMLEFT", tile, "BOTTOMLEFT", 0, 0) + sep:SetPoint("BOTTOMRIGHT", tile, "BOTTOMRIGHT", 0, 0) + sep:SetColorTexture(1, 1, 1, 0.04) + + return TILE_H +end + +local function AddNewButton(parentFrame, y, width, label, onClick) + local addBtn = CreateFrame("Button", nil, parentFrame) + addBtn:SetSize(width - 24, 30) + addBtn:SetPoint("TOPLEFT", parentFrame, "TOPLEFT", 12, y - 12) + local stx = EllesmereUI.SolidTex(addBtn, "BACKGROUND", 0.10, 0.10, 0.11, 0.9); stx:SetAllPoints() + local brd = EllesmereUI.MakeBorder(addBtn, 1, 1, 1, 0.22) + local lbl = EllesmereUI.MakeFont(addBtn, 12, nil, 1, 1, 1, 0.85) + lbl:SetPoint("CENTER") + lbl:SetText(label) + local eg = EllesmereUI.ELLESMERE_GREEN or { r = 0.05, g = 0.83, b = 0.62 } + addBtn:SetScript("OnEnter", function() + if brd and brd.SetColor then brd:SetColor(eg.r, eg.g, eg.b, 0.9) end + end) + addBtn:SetScript("OnLeave", function() + if brd and brd.SetColor then brd:SetColor(1, 1, 1, 0.22) end + end) + addBtn:SetScript("OnClick", onClick) + return 54 +end + +------------------------------------------------------------------------------- +-- Custom Buff Bar detail pane +-- Shares BuildAssignedBuffsFields/BuildCoreFields/BuildDisplayFields with +-- the default Buffs bar -- same cfg shape (filters/spells), same fields. +------------------------------------------------------------------------------- + +local function Apply(isBuff, barId) + if isBuff then + if ns.PAB_ReloadCustomBuffBar then ns.PAB_ReloadCustomBuffBar(barId) end + else + if ns.PAB_ReloadCustomDebuffBar then ns.PAB_ReloadCustomDebuffBar(barId) end + end +end + +-- Title bar: bar.name (falls back to the placeholder used at creation) + +-- a static subtitle, same TOPLEFT title/BOTTOMLEFT desc metrics as +-- BuildDefaultBarDetail's "Buffs"/"Built-in bar. Cannot be deleted." +-- header, so both default and custom bar detail panes look consistent. +-- Naming itself is handled exclusively through the sidebar now -- the +-- "Add New" popup's Name field at creation, and the tile's edit-pencil +-- icon for renaming afterward (see ShowAddBarPopup / BuildTile's +-- onRename) -- both go through EllesmereUI:RefreshPage(true), which +-- rebuilds this detail pane (and therefore this title) on every rename, +-- so a plain SetText at build time is already live. +local function BuildBarTitle(frame, fontPath, name, subtitle) + local title = frame:CreateFontString(nil, "OVERLAY") + title:SetFont(fontPath, 15, "") + title:SetPoint("TOPLEFT", frame, "TOPLEFT", 20, -14) + title:SetText(name) + title:SetTextColor(1, 1, 1, 0.95) + local desc = frame:CreateFontString(nil, "OVERLAY") + desc:SetFont(fontPath, 11, "") + desc:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -4) + desc:SetText(subtitle) + desc:SetTextColor(1, 1, 1, 0.45) +end + +local function BuildBuffBarDetail(frame, fontPath, bar) + local W = EllesmereUI.Widgets + if not W then return end + local function ApplyBar() Apply(true, bar.id) end + + BuildBarTitle(frame, fontPath, bar.name or L("Buff Bar"), L("Custom buff bar.")) + + local scrollTop = -50 + if ns.PAB_BuildPreviewBox then + scrollTop = ns.PAB_BuildPreviewBox(frame, fontPath, -50, "buff", bar.id, bar) + end + local body = WrapCompensatedBody(frame, scrollTop) + local by = 0 + by = BuildAssignedBuffsFields(body, fontPath, by, bar, ApplyBar) + by = BuildCoreFields(body, fontPath, by, bar, ApplyBar, true) + by = BuildDisplayFields(body, fontPath, by, bar, ApplyBar, true) + FinalizeCompensatedBody(body, by) +end + +------------------------------------------------------------------------------- +-- Custom Debuff Bar detail pane -- category/class-token based, no SpellID +-- popup (per Joel's decision: debuffs stay category-based, same as +-- RaidFrames). +------------------------------------------------------------------------------- + +local function BuildDebuffBarDetail(frame, fontPath, bar) + local W = EllesmereUI.Widgets + if not W then return end + local function ApplyBar() Apply(false, bar.id) end + + BuildBarTitle(frame, fontPath, bar.name or L("Debuff Bar"), L("Custom debuff bar.")) + + local scrollTop = -50 + if ns.PAB_BuildPreviewBox then + scrollTop = ns.PAB_BuildPreviewBox(frame, fontPath, -50, "debuff", bar.id, bar) + end + local body = WrapCompensatedBody(frame, scrollTop) + local by = 0 + by = BuildAssignedDebuffsFields(body, fontPath, by, bar, ApplyBar) + by = BuildCoreFields(body, fontPath, by, bar, ApplyBar, false) + by = BuildDisplayFields(body, fontPath, by, bar, ApplyBar, false) + by = BuildDispelColorFields(body, fontPath, by, bar, ApplyBar) + by = BuildFxEffects(body, by, bar, ApplyBar) + FinalizeCompensatedBody(body, by) +end + +------------------------------------------------------------------------------- +-- Filter Editor modal -- 1:1 structural port of ns.BMP_ShowFilterEditor +-- (EUI_RaidFrames_ManagerPages.lua): same dimmer+popup+sidebar+detail +-- shape, same smooth-scroll+thumb, same icon-based sidebar rename/delete, +-- same Search Spells dropdown + Add Spell ID button layout, same class- +-- grouped checkbox-style spell list, same Selected/Presets Extra Spells +-- grouping. Two things differ from a byte-identical port: +-- * ns.PAB_AllPresetSpells() (the universe both Search Spells and Extra +-- Spells' Presets group draw from) is built from BM2_FILTER_SEED, not +-- from a live BM2_DEFAULT_FILTER_SPELLS reference -- same content, +-- just PAB's own copy (see that seed table's doc comment for why). +-- * No preset/custom distinction at the SPELL level -- filters +-- themselves ARE protected once imported (f.preset, see PAB_Filters' +-- doc comment), but every spell row within a filter stays equally +-- editable, unlike BM2 which only shows the delete-X on "custom" +-- (non-curated) spells. +-- * Own-only tracking (ind.ownFilters/ownExtras) -- explicitly out of +-- scope for this pass, matches PAB_ResolveSpells' doc comment. +-- Class grouping/coloring uses ns.PAB_SPELL_CLASS_HINTS (display-only, +-- extracted from the same BM2 source data as the seed filters) -- +-- spells with no hint land in "Custom", exactly like BM2's own spells +-- with no curated class land in its Custom group. +------------------------------------------------------------------------------- + +local CLASS_ORDER = { "WARRIOR", "PALADIN", "HUNTER", "ROGUE", "PRIEST", + "DEATHKNIGHT", "SHAMAN", "MAGE", "WARLOCK", "MONK", "DRUID", + "DEMONHUNTER", "EVOKER" } + +local function PopupButton(parent, w, h, label, onClick) + local btn = CreateFrame("Button", nil, parent) + btn:SetSize(w, h) + btn:SetFrameLevel(parent:GetFrameLevel() + 2) + local bg = EllesmereUI.SolidTex(btn, "BACKGROUND", 0, 0, 0, 0.5); bg:SetAllPoints() + local brd = EllesmereUI.MakeBorder(btn, 1, 1, 1, 0.25) + local lbl = EllesmereUI.MakeFont(btn, 12, nil, 1, 1, 1) + lbl:SetAlpha(0.6) + lbl:SetPoint("CENTER") + lbl:SetText(L(label)) + local ar, ag, ab = 1, 0.82, 0.30 + if EllesmereUI.GetAccentColor then ar, ag, ab = EllesmereUI.GetAccentColor() end + btn:SetScript("OnEnter", function() + lbl:SetAlpha(0.9) + if brd and brd.SetColor then brd:SetColor(ar, ag, ab, 0.6) end + end) + btn:SetScript("OnLeave", function() + lbl:SetAlpha(0.6) + if brd and brd.SetColor then brd:SetColor(1, 1, 1, 0.25) end + end) + btn:SetScript("OnClick", onClick) + return btn +end + +-- Standard smooth scroll + thin custom scrollbar (verbatim port of +-- AttachEditorScroll from EUI_RaidFrames_ManagerPages.lua). Track shows +-- only on overflow. Returns UpdateThumb and SetScrollTo(v). +-- rightInset (optional, default 2): distance from `scroll`'s OWN right edge +-- to the track. Only WrapCompensatedBody's call needs a bigger value here -- +-- since 2026-08-03 its `scroll` extends padDiff (~25px) past the pane's true +-- visible right edge (mirrors RaidFrames' settingsScroll), so the default 2 +-- would land the track deep inside the sidebar instead of near the visible +-- edge. The other two callers (Filter Editor's plain, unshifted scrolls) +-- keep the default. +AttachEditorScroll = function(scroll, child, onScroll, rightInset) + rightInset = rightInset or 2 + local SBAR_W = 4 + local track = CreateFrame("Frame", nil, scroll) + track:SetPoint("TOPRIGHT", scroll, "TOPRIGHT", -rightInset, -2) + track:SetPoint("BOTTOMRIGHT", scroll, "BOTTOMRIGHT", -rightInset, 2) + track:SetWidth(SBAR_W) + track:SetFrameLevel(scroll:GetFrameLevel() + 5) + do local tx = track:CreateTexture(nil, "BACKGROUND"); tx:SetAllPoints(); tx:SetColorTexture(1, 1, 1, 0.05) end + local thumb = CreateFrame("Frame", nil, track) + thumb:SetWidth(SBAR_W); thumb:SetHeight(30) + thumb:SetPoint("TOP", track, "TOP", 0, 0) + thumb:EnableMouse(true) + do local tx = thumb:CreateTexture(nil, "ARTWORK"); tx:SetAllPoints(); tx:SetColorTexture(1, 1, 1, 0.22) end + track:Hide() + + local function MaxScroll() return max(0, child:GetHeight() - scroll:GetHeight()) end + local function UpdateThumb() + local ms = MaxScroll() + if ms <= 0 then track:Hide(); return end + track:Show() + local trackH = track:GetHeight() + local visH = scroll:GetHeight() + local thumbH = max(20, trackH * (visH / (visH + ms))) + thumb:SetHeight(thumbH) + local ratio = (scroll:GetVerticalScroll() or 0) / ms + thumb:ClearAllPoints() + thumb:SetPoint("TOP", track, "TOP", 0, -(ratio * (trackH - thumbH))) + end + + local SCROLL_STEP, SMOOTH_SPEED = 60, 12 + local target = 0 + local smooth = CreateFrame("Frame", nil, scroll) + smooth:Hide() + smooth:SetScript("OnUpdate", function(_, elapsed) + local cur = scroll:GetVerticalScroll() + local ms = MaxScroll() + target = max(0, math.min(ms, target)) + local diff = target - cur + if math.abs(diff) < 0.3 then + scroll:SetVerticalScroll(target); UpdateThumb(); smooth:Hide() + if onScroll then onScroll(target) end + return + end + local nv = max(0, math.min(ms, cur + diff * math.min(1, SMOOTH_SPEED * elapsed))) + scroll:SetVerticalScroll(nv); UpdateThumb() + if onScroll then onScroll(nv) end + end) + scroll:EnableMouseWheel(true) + scroll:SetScript("OnMouseWheel", function(_, delta) + if MaxScroll() <= 0 then return end + local base = smooth:IsShown() and target or scroll:GetVerticalScroll() + target = max(0, math.min(MaxScroll(), base - delta * SCROLL_STEP)) + smooth:Show() + end) + thumb:SetScript("OnMouseDown", function() + smooth:Hide() + local _, cy0 = GetCursorPosition() + local startY = cy0 / scroll:GetEffectiveScale() + local startScroll = scroll:GetVerticalScroll() + thumb:SetScript("OnUpdate", function(self2) + if not IsMouseButtonDown("LeftButton") then self2:SetScript("OnUpdate", nil); return end + local ms = MaxScroll() + local travel = track:GetHeight() - thumb:GetHeight() + if travel <= 0 then return end + local _, cy = GetCursorPosition(); cy = cy / scroll:GetEffectiveScale() + local nv = max(0, math.min(ms, startScroll + ((startY - cy) / travel) * ms)) + target = nv + scroll:SetVerticalScroll(nv); UpdateThumb() + if onScroll then onScroll(nv) end + end) + end) + + local function SetScrollTo(v) + local ms = MaxScroll() + if v > ms then v = ms end + if v < 0 then v = 0 end + target = v + scroll:SetVerticalScroll(v) + UpdateThumb() + if onScroll then onScroll(v) end + end + return UpdateThumb, SetScrollTo +end + +function ns.PABMP_ShowFilterEditor() + if ns._pabFilterEditor then ns._pabFilterEditor:Hide(); ns._pabFilterEditor = nil end + local filters = SortFiltersByName((ns.PAB_Filters and ns.PAB_Filters()) or {}) + local fontPath = (EllesmereUI.GetFontPath and EllesmereUI.GetFontPath("unitFrames")) or "Fonts\\FRIZQT__.TTF" + local ar, ag, ab = 1, 0.82, 0.30 + if EllesmereUI.GetAccentColor then ar, ag, ab = EllesmereUI.GetAccentColor() end + local eg = EllesmereUI.ELLESMERE_GREEN or { r = 0.05, g = 0.82, b = 0.62 } + local MEDIA_FE = "Interface\\AddOns\\EllesmereUI\\media\\icons\\" + + local POPUP_W, POPUP_H, SIDE_W = 620, 520, 180 + + local dimmer = CreateFrame("Frame", nil, UIParent) + dimmer:SetFrameStrata("FULLSCREEN_DIALOG") + dimmer:SetAllPoints(UIParent) + dimmer:EnableMouse(true) + dimmer:EnableMouseWheel(true) + dimmer:SetScript("OnMouseWheel", function() end) + dimmer:SetScript("OnMouseDown", function() dimmer:Hide(); ns._pabFilterEditor = nil end) + local dimTex = EllesmereUI.SolidTex(dimmer, "BACKGROUND", 0, 0, 0, 0.25); dimTex:SetAllPoints() + ns._pabFilterEditor = dimmer + + local popup = CreateFrame("Frame", nil, dimmer) + popup:SetSize(POPUP_W, POPUP_H) + popup:SetPoint("CENTER", UIParent, "CENTER", 0, 20) + popup:SetFrameStrata("FULLSCREEN_DIALOG") + popup:SetFrameLevel(dimmer:GetFrameLevel() + 10) + popup:EnableMouse(true) + local popBg = EllesmereUI.SolidTex(popup, "BACKGROUND", 0.06, 0.08, 0.10, 1); popBg:SetAllPoints() + EllesmereUI.MakeBorder(popup, 1, 1, 1, 0.15) + if EllesmereUI.GetPopupScale then popup:SetScale(EllesmereUI.GetPopupScale()) end + + local title = EllesmereUI.MakeFont(popup, 16, "", 1, 1, 1) + title:SetPoint("TOP", popup, "TOP", 0, -18) + title:SetText(L("Edit Filters")) + + do + local close = CreateFrame("Button", nil, popup) + close:SetSize(19, 19) + close:SetPoint("TOPRIGHT", popup, "TOPRIGHT", -13, -8) + close:SetFrameLevel(popup:GetFrameLevel() + 5) + local closeIcon = close:CreateTexture(nil, "ARTWORK") + closeIcon:SetAllPoints() + closeIcon:SetTexture(MEDIA_FE .. "eui-close.png") + closeIcon:SetAlpha(0.40) + closeIcon:SetSnapToPixelGrid(false) + closeIcon:SetTexelSnappingBias(0) + close:SetScript("OnEnter", function() closeIcon:SetAlpha(0.50) end) + close:SetScript("OnLeave", function() closeIcon:SetAlpha(0.40) end) + close:SetScript("OnClick", function() dimmer:Hide(); ns._pabFilterEditor = nil end) + end + + if pabFilterSel then + local ok = false + for i = 1, #filters do if filters[i].id == pabFilterSel then ok = true end end + if not ok then pabFilterSel = nil end + end + if not pabFilterSel and filters[1] then pabFilterSel = filters[1].id end + + local function Rebuild() ns.PABMP_ShowFilterEditor() end + local function ApplyAll() + if ns.PAB_ApplyLiveConfig then ns.PAB_ApplyLiveConfig(true) end + local list = ns.PAB_CustomBuffBars and ns.PAB_CustomBuffBars() + if list then + for i = 1, #list do + if ns.PAB_ReloadCustomBuffBar then ns.PAB_ReloadCustomBuffBar(list[i].id) end + end + end + end + local function EditorInput(opts) + EllesmereUI:ShowInputPopup(opts) + local d = _G.EUIInputDimmer + if d and ns._pabFilterEditor then + d:SetFrameLevel(popup:GetFrameLevel() + 40) + local p = _G.EUIInputPopup + if p then p:SetFrameLevel(d:GetFrameLevel() + 10) end + end + end + + -- RIGHT: filter list. + local side = CreateFrame("Frame", nil, popup) + side:SetWidth(SIDE_W) + side:SetPoint("TOPRIGHT", popup, "TOPRIGHT", 0, -44) + side:SetPoint("BOTTOMRIGHT", popup, "BOTTOMRIGHT", 0, 0) + side:SetFrameLevel(popup:GetFrameLevel() + 1) + local sideBg = EllesmereUI.SolidTex(side, "BACKGROUND", 0, 0, 0, 0.35); sideBg:SetAllPoints() + EllesmereUI.MakeBorder(side, 1, 1, 1, 0.10) + + local sideScroll = CreateFrame("ScrollFrame", nil, side) + sideScroll:SetPoint("TOPLEFT", side, "TOPLEFT", 1, -1) + sideScroll:SetPoint("BOTTOMRIGHT", side, "BOTTOMRIGHT", -1, 1) + sideScroll:SetFrameLevel(side:GetFrameLevel() + 1) + local sideChild = CreateFrame("Frame", nil, sideScroll) + sideChild:SetWidth(SIDE_W - 2) + sideScroll:SetScrollChild(sideChild) + + local fy = -4 + for i = 1, #filters do + local f = filters[i] + local isSel = (pabFilterSel == f.id) + local frow = CreateFrame("Button", nil, sideChild) + frow:SetHeight(26) + frow:SetPoint("TOPLEFT", sideChild, "TOPLEFT", 0, fy) + frow:SetPoint("TOPRIGHT", sideChild, "TOPRIGHT", 0, fy) + frow:SetFrameLevel(sideChild:GetFrameLevel() + 1) + local rbg = frow:CreateTexture(nil, "BACKGROUND") + rbg:SetAllPoints(); rbg:SetColorTexture(1, 1, 1, isSel and 0.07 or 0) + local rl = EllesmereUI.MakeFont(frow, 12, nil, 1, 1, 1) + rl:SetAlpha(isSel and 0.95 or 0.6) + rl:SetPoint("LEFT", frow, "LEFT", 10, 0) + rl:SetPoint("RIGHT", frow, "RIGHT", f.preset and -8 or -42, 0) + rl:SetJustifyH("LEFT"); rl:SetWordWrap(false) + rl:SetText(f.name) + if isSel then + local accent = frow:CreateTexture(nil, "ARTWORK", nil, 2) + accent:SetSize(2, 26) + accent:SetPoint("TOPLEFT", frow, "TOPLEFT", 0, 0) + accent:SetColorTexture(eg.r, eg.g, eg.b, 0.9) + end + frow:SetScript("OnEnter", function() if not isSel then rbg:SetColorTexture(1, 1, 1, 0.04) end end) + frow:SetScript("OnLeave", function() rbg:SetColorTexture(1, 1, 1, isSel and 0.07 or 0) end) + frow:SetScript("OnClick", function() + pabFilterSel = f.id + pabFilterScrollPos = 0 + Rebuild() + end) + + -- Imported Buff Manager presets (f.preset = true) are protected -- + -- not renameable/deletable, matching BM2's own `if not f.preset` + -- guard exactly. User-created filters keep both icons. + if not f.preset then + local del = CreateFrame("Button", nil, frow) + del:SetSize(14, 14) + del:SetPoint("RIGHT", frow, "RIGHT", -6, 0) + del:SetFrameLevel(frow:GetFrameLevel() + 1) + del:SetAlpha(0.5) + local dx = del:CreateTexture(nil, "OVERLAY") + dx:SetAllPoints() + if dx.SetSnapToPixelGrid then dx:SetSnapToPixelGrid(false); dx:SetTexelSnappingBias(0) end + dx:SetTexture(MEDIA_FE .. "eui-close.png") + del:SetScript("OnEnter", function(self) self:SetAlpha(0.9); EllesmereUI.ShowWidgetTooltip(self, L("Delete")) end) + del:SetScript("OnLeave", function(self) self:SetAlpha(0.5); EllesmereUI.HideWidgetTooltip() end) + del:SetScript("OnClick", function() + EllesmereUI:ShowConfirmPopup({ + title = L("Delete Filter"), + message = L("Delete this filter? It is removed from every bar using it."), + confirmText = L("Delete"), cancelText = L("Cancel"), + onConfirm = function() + ns.PAB_DeleteFilter(f.id) + ApplyAll() + Rebuild() + end, + }) + end) + + local edit = CreateFrame("Button", nil, frow) + edit:SetSize(14, 14) + edit:SetPoint("RIGHT", del, "LEFT", -4, 0) + edit:SetFrameLevel(frow:GetFrameLevel() + 1) + edit:SetAlpha(0.5) + local ex = edit:CreateTexture(nil, "OVERLAY") + ex:SetAllPoints() + if ex.SetSnapToPixelGrid then ex:SetSnapToPixelGrid(false); ex:SetTexelSnappingBias(0) end + ex:SetTexture(MEDIA_FE .. "eui-edit.png") + edit:SetScript("OnEnter", function(self) self:SetAlpha(0.9); EllesmereUI.ShowWidgetTooltip(self, L("Edit")) end) + edit:SetScript("OnLeave", function(self) self:SetAlpha(0.5); EllesmereUI.HideWidgetTooltip() end) + edit:SetScript("OnClick", function() + EditorInput({ + title = L("Rename Filter"), placeholder = f.name, + confirmText = L("Rename"), cancelText = L("Cancel"), + onConfirm = function(text) ns.PAB_RenameFilter(f.id, text); Rebuild() end, + }) + end) + end + fy = fy - 27 + end + local addFilterBtn = PopupButton(sideChild, SIDE_W - 16, 26, "Add Filter", function() + EditorInput({ + title = L("Add Filter"), message = L("Name the new filter."), + confirmText = L("Add"), cancelText = L("Cancel"), + onConfirm = function(text) + local f = ns.PAB_AddFilter((text and text ~= "" and text) or L("New Filter")) + if f then pabFilterSel = f.id end + Rebuild() + end, + }) + end) + addFilterBtn:SetPoint("TOPLEFT", sideChild, "TOPLEFT", 8, fy - 8) + sideChild:SetHeight(math.abs(fy - 8 - 26) + 8) + local updSideThumb = AttachEditorScroll(sideScroll, sideChild) + updSideThumb() + + -- LEFT: selected filter detail. + local sel + for i = 1, #filters do if filters[i].id == pabFilterSel then sel = filters[i] end end + if not sel then return end + + local left = CreateFrame("Frame", nil, popup) + left:SetPoint("TOPLEFT", popup, "TOPLEFT", 16, -44) + left:SetPoint("BOTTOMRIGHT", popup, "BOTTOMRIGHT", -(SIDE_W + 12), 12) + left:SetFrameLevel(popup:GetFrameLevel() + 1) + + local nm = EllesmereUI.MakeFont(left, 13, nil, 1, 1, 1) + nm:SetAlpha(0.9) + nm:SetPoint("TOPLEFT", left, "TOPLEFT", 2, -2) + nm:SetText(sel.name) + if not sel.preset then + local ren = CreateFrame("Button", nil, left) + ren:SetSize(54, 16) + ren:SetPoint("LEFT", nm, "RIGHT", 10, 0) + ren:SetFrameLevel(left:GetFrameLevel() + 2) + local rl = EllesmereUI.MakeFont(ren, 11, nil, ar, ag, ab) + rl:SetAlpha(0.9) + rl:SetPoint("LEFT") + rl:SetText(L("Rename")) + ren:SetScript("OnEnter", function() rl:SetAlpha(1) end) + ren:SetScript("OnLeave", function() rl:SetAlpha(0.9) end) + ren:SetScript("OnClick", function() + EditorInput({ + title = L("Rename Filter"), placeholder = sel.name, + confirmText = L("Rename"), cancelText = L("Cancel"), + onConfirm = function(text) ns.PAB_RenameFilter(sel.id, text); Rebuild() end, + }) + end) + end + + -- Search Spells: same curated (deduped) universe as the Extra Spells + -- dropdown, matching BM2's own search exactly (BM2's comment: + -- "identical list to the Extra Spells dropdown"). + local searchDD = EllesmereUI.BuildVisOptsCBDropdown( + left, 170, left:GetFrameLevel() + 5, + function() + local universe = DedupedPresetSpellUniverse() + local out = {} + for i = 1, #universe do + local id = universe[i] + if sel.spells[id] == nil then + local nm2 = C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(id) + out[#out + 1] = { + key = id, label = nm2 or tostring(id), noCheck = true, + icon = C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(id), + } + end + end + table.sort(out, function(a, b) return tostring(a.label or a.key) < tostring(b.label or b.key) end) + return out + end, + function() return false end, + function(k, v) + if v and ns.PAB_AddSpellToFilter and ns.PAB_AddSpellToFilter(sel.id, k) then + ApplyAll() + Rebuild() + end + end, + nil, 10, true) + searchDD:ClearAllPoints() + searchDD:SetPoint("TOPLEFT", left, "TOPLEFT", 2, -23) + for _, r in ipairs({ searchDD:GetRegions() }) do + if r.SetText and r.GetText then + r:SetText(L("Search Spells")) + break + end + end + + local addSpellBtn = PopupButton(left, 110, 24, "Add Spell ID", function() + EditorInput({ + title = L("Add Spell ID"), message = L("Enter the spell ID to add to this filter."), + confirmText = L("Add"), cancelText = L("Cancel"), + onConfirm = function(text) + local id = tonumber(text or "") + if id and ns.PAB_AddSpellToFilter(sel.id, id) then + ApplyAll() + Rebuild() + end + end, + }) + end) + addSpellBtn:SetPoint("LEFT", searchDD, "RIGHT", 8, 0) + + -- Spell checkbox list: mirrors the checkbox-dropdown widget's visuals + -- exactly (16px box, accent fill inset 2, hover wash), grouped by + -- class (via PAB_SPELL_CLASS_HINTS) with a Custom group for anything + -- without a hint. + local scroll = CreateFrame("ScrollFrame", nil, left) + scroll:SetPoint("TOPLEFT", left, "TOPLEFT", 0, -58) + scroll:SetPoint("BOTTOMRIGHT", left, "BOTTOMRIGHT", 0, 0) + local child = CreateFrame("Frame", nil, scroll) + child:SetWidth(POPUP_W - SIDE_W - 40) + scroll:SetScrollChild(child) + local _updSpellThumb, setSpellScroll = AttachEditorScroll(scroll, child, + function(v) pabFilterScrollPos = v end) + + local function NameOf(id) + return (C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(id)) or tostring(id) + end + + -- Display-only dedup by name, same reasoning/pattern as + -- DedupedPresetSpellUniverse: sel.spells legitimately contains both a + -- primary spellID and its rank/talent `alts` as separate keys (BM2_ + -- FILTER_SEED already flattens alts in -- see that table's own doc + -- comment), which BM2's own checkbox list never shows as separate rows + -- (it only iterates primary keys). Without this, curated filters like + -- "Raid CDs" show the same buff (e.g. Rallying Cry) twice. First + -- (lowest) spellID per name wins and keeps its row -- ids are visited + -- in sorted order (pairs() has no defined order) so the winner is + -- deterministic across rebuilds. ResolveSpells still unions every + -- enabled id in sel.spells regardless of which one has a visible + -- checkbox, so this changes nothing about which auras get tracked -- + -- only which row the user toggles them from. + local hints = ns.PAB_SPELL_CLASS_HINTS or {} + local allIds = {} + for id in pairs(sel.spells) do allIds[#allIds + 1] = id end + table.sort(allIds) + local byClass, customList = {}, {} + local seenNames = {} + for i = 1, #allIds do + local id = allIds[i] + local dedupKey = NameOf(id) + if not seenNames[dedupKey] then + seenNames[dedupKey] = true + local cls = hints[id] + if cls then + byClass[cls] = byClass[cls] or {} + table.insert(byClass[cls], id) + else + table.insert(customList, id) + end + end + end + local function ByName(a, b) + local na, nb = NameOf(a), NameOf(b) + if na == nb then return a < b end + return na < nb + end + for _, list in pairs(byClass) do table.sort(list, ByName) end + table.sort(customList, ByName) + + local cy = 0 + local function SpellRow(id, classColor) + local srow = CreateFrame("Button", nil, child) + srow:SetHeight(24) + srow:SetPoint("TOPLEFT", child, "TOPLEFT", 2, cy) + srow:SetPoint("TOPRIGHT", child, "TOPRIGHT", -2, cy) + srow:SetFrameLevel(child:GetFrameLevel() + 1) + local hl = srow:CreateTexture(nil, "ARTWORK") + hl:SetAllPoints(); hl:SetColorTexture(1, 1, 1, 0) + local box = CreateFrame("Frame", nil, srow) + box:SetSize(16, 16) + box:SetPoint("LEFT", srow, "LEFT", 6, 0) + local boxBg = box:CreateTexture(nil, "BACKGROUND") + boxBg:SetAllPoints(); boxBg:SetColorTexture(0.12, 0.12, 0.14, 1) + local boxBrd = EllesmereUI.MakeBorder(box, 0.4, 0.4, 0.4, 0.6) + local chk = box:CreateTexture(nil, "ARTWORK") + chk:SetPoint("TOPLEFT", box, "TOPLEFT", 2, -2) + chk:SetPoint("BOTTOMRIGHT", box, "BOTTOMRIGHT", -2, 2) + chk:SetColorTexture(eg.r, eg.g, eg.b, 1) + local on = sel.spells[id] and true or false + local ico = srow:CreateTexture(nil, "ARTWORK") + ico:SetSize(22, 22) + ico:SetPoint("LEFT", box, "RIGHT", 6, 0) + local tex = C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(id) + if tex then ico:SetTexture(tex) end + ico:SetTexCoord(0.08, 0.92, 0.08, 0.92) + local name = C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(id) + local lr, lg2, lb2 = 1, 1, 1 + if classColor then lr, lg2, lb2 = classColor.r, classColor.g, classColor.b end + local lbl = EllesmereUI.MakeFont(srow, 13, nil, lr, lg2, lb2) + lbl:SetPoint("LEFT", ico, "RIGHT", 6, 0) + lbl:SetPoint("RIGHT", srow, "RIGHT", -24, 0) -- every PAB row is deletable, always reserve the X's space + lbl:SetJustifyH("LEFT"); lbl:SetWordWrap(false) + lbl:SetText(name or ("Spell " .. tostring(id))) + local function UpdateRow() + on = sel.spells[id] and true or false + if on then + chk:Show() + if boxBrd and boxBrd.SetColor then boxBrd:SetColor(eg.r, eg.g, eg.b, 0.8) end + else + chk:Hide() + if boxBrd and boxBrd.SetColor then boxBrd:SetColor(0.4, 0.4, 0.4, 0.6) end + end + lbl:SetAlpha(on and 0.9 or 0.45) + ico:SetAlpha(on and 1 or 0.45) + ico:SetDesaturated(not on) + end + UpdateRow() + srow:SetScript("OnEnter", function() hl:SetColorTexture(1, 1, 1, 0.04) end) + srow:SetScript("OnLeave", function() hl:SetColorTexture(1, 1, 1, 0) end) + srow:SetScript("OnClick", function() + ns.PAB_SetSpellState(sel.id, id, not on) + ApplyAll() + UpdateRow() + end) + local del = CreateFrame("Button", nil, srow) + del:SetSize(14, 14) + del:SetPoint("RIGHT", srow, "RIGHT", -6, 0) + del:SetFrameLevel(srow:GetFrameLevel() + 1) + del:SetAlpha(0.5) + local dx = del:CreateTexture(nil, "OVERLAY") + dx:SetAllPoints() + if dx.SetSnapToPixelGrid then dx:SetSnapToPixelGrid(false); dx:SetTexelSnappingBias(0) end + dx:SetTexture(MEDIA_FE .. "eui-close.png") + del:SetScript("OnEnter", function(self) self:SetAlpha(0.9) end) + del:SetScript("OnLeave", function(self) self:SetAlpha(0.5) end) + del:SetScript("OnClick", function() + ns.PAB_SetSpellState(sel.id, id, nil) + ApplyAll() + Rebuild() + end) + cy = cy - 29 + end + local function GroupHeader(text) + local hdr = EllesmereUI.MakeFont(child, 14, nil, 0.5, 0.5, 0.5) + hdr:SetPoint("TOPLEFT", child, "TOPLEFT", 2, cy - 10) + hdr:SetText(text) + local line = child:CreateTexture(nil, "ARTWORK") + line:SetHeight(1) + line:SetPoint("LEFT", hdr, "RIGHT", 6, 0) + line:SetPoint("RIGHT", child, "RIGHT", -10, 0) + line:SetColorTexture(0.3, 0.3, 0.3, 0.5) + cy = cy - 30 + end + if #customList > 0 then + GroupHeader(L("Custom")) + for i = 1, #customList do SpellRow(customList[i]) end + end + for c = 1, #CLASS_ORDER do + local cls = CLASS_ORDER[c] + local list = byClass[cls] + if list and #list > 0 then + local cc = RAID_CLASS_COLORS and RAID_CLASS_COLORS[cls] + local cname = LOCALIZED_CLASS_NAMES_MALE and LOCALIZED_CLASS_NAMES_MALE[cls] or cls + GroupHeader(cc and ("|c" .. cc.colorStr .. cname .. "|r") or cname) + for i = 1, #list do SpellRow(list[i], cc) end + end + end + if cy == 0 then + local empty = EllesmereUI.MakeFont(child, 12, nil, 1, 1, 1) + empty:SetAlpha(0.4) + empty:SetPoint("TOPLEFT", child, "TOPLEFT", 4, -6) + empty:SetText(L("No spells yet. Add spell IDs above.")) + cy = -30 + end + child:SetHeight(math.abs(cy) + 10) + + setSpellScroll(pabFilterScrollPos or 0) +end + +------------------------------------------------------------------------------- +-- "Add New" popup -- small Name-only popup shown below the Add Buff Bar / +-- Add Debuff Bar button, mirrors RaidFrames' DebuffManager "Add New" +-- popup chrome (dark fill + border, POPUP_PAD/ROW_H metrics, accent +-- Create button, auto-close on outside click, EUI_RaidFrames_ManagerPages +-- .lua ~line 2244) -- simplified to a single Name field since PAB custom +-- bars don't need a type/filter picker at creation time (everything else +-- is editable afterward in the detail pane, unlike DM's tiles). One +-- shared popup instance toggled/repurposed for both buff and debuff +-- creation via `kind`, same as DM's single ns._dmAddPopup. +------------------------------------------------------------------------------- + +local pabAddPopup + +local function ShowAddBarPopup(anchorBtn, kind, fontPath) + if pabAddPopup and pabAddPopup:IsShown() and pabAddPopup._kind == kind then + pabAddPopup:Hide() + return + end + + if not pabAddPopup then + local POPUP_W, POPUP_PAD, ROW_H, LABEL_H, LBL_GAP, GAP = 220, 10, 30, 14, 4, 10 + local popup = CreateFrame("Frame", nil, UIParent) + popup:SetFrameStrata("DIALOG") + popup:SetFrameLevel(200) + popup:SetSize(POPUP_W, POPUP_PAD + LABEL_H + LBL_GAP + ROW_H + GAP + ROW_H + POPUP_PAD) + popup:EnableMouse(true) + popup:SetClampedToScreen(true) + + local bg = popup:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints() + bg:SetColorTexture(0.067, 0.067, 0.067, 0.95) + EllesmereUI.MakeBorder(popup, 1, 1, 1, 0.2) + + -- Auto-close on outside click, same pattern as DM's Add New popup. + popup:SetScript("OnShow", function(p2) + p2:SetScript("OnUpdate", function(m) + if not (m._anchorBtn and m._anchorBtn:IsMouseOver()) and not m:IsMouseOver() then + if IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton") then + m:Hide() + end + end + end) + end) + popup:SetScript("OnHide", function(p2) + p2:SetScript("OnUpdate", nil) + if p2._nameBox then p2._nameBox:SetText("") end + end) + + local py = -POPUP_PAD + local nmLbl = popup:CreateFontString(nil, "OVERLAY") + nmLbl:SetFont(fontPath, 11, "") + nmLbl:SetPoint("TOPLEFT", popup, "TOPLEFT", POPUP_PAD, py) + nmLbl:SetText(L("Name")) + nmLbl:SetTextColor(1, 1, 1, 0.6) + py = py - LABEL_H - LBL_GAP + + local ddW = POPUP_W - POPUP_PAD * 2 + local nameBox = CreateFrame("EditBox", nil, popup) + nameBox:SetSize(ddW, ROW_H) + nameBox:SetPoint("TOPLEFT", popup, "TOPLEFT", POPUP_PAD, py) + nameBox:SetAutoFocus(true) + nameBox:SetFont(fontPath, 12, "") + nameBox:SetJustifyH("LEFT") + nameBox:SetTextColor(1, 1, 1, 0.9) + nameBox:SetTextInsets(10, 10, 0, 0) + local nbBg = nameBox:CreateTexture(nil, "BACKGROUND") + nbBg:SetAllPoints() + nbBg:SetColorTexture(0, 0, 0, 0.5) + EllesmereUI.MakeBorder(nameBox, 1, 1, 1, 0.2) + popup._nameBox = nameBox + py = py - ROW_H - GAP + + local accentColor = EllesmereUI.ELLESMERE_GREEN or { r = 0.05, g = 0.82, b = 0.62 } + local cBtn = CreateFrame("Button", nil, popup) + cBtn:SetSize(ddW, ROW_H) + cBtn:SetPoint("TOPLEFT", popup, "TOPLEFT", POPUP_PAD, py) + cBtn:SetFrameLevel(popup:GetFrameLevel() + 1) + local cBg = cBtn:CreateTexture(nil, "BACKGROUND") + cBg:SetAllPoints() + cBg:SetColorTexture(accentColor.r, accentColor.g, accentColor.b, 0.8) + local cTx = cBtn:CreateFontString(nil, "OVERLAY") + cTx:SetPoint("CENTER") + cTx:SetFont(fontPath, 12, "") + cTx:SetText(L("Create")) + cTx:SetTextColor(1, 1, 1) + cBtn:SetScript("OnEnter", function() cBg:SetColorTexture(accentColor.r, accentColor.g, accentColor.b, 1) end) + cBtn:SetScript("OnLeave", function() cBg:SetColorTexture(accentColor.r, accentColor.g, accentColor.b, 0.8) end) + popup._createBtn = cBtn + + nameBox:SetScript("OnEnterPressed", function() cBtn:Click() end) + nameBox:SetScript("OnEscapePressed", function(self) self:ClearFocus(); popup:Hide() end) + + pabAddPopup = popup + end + + local popup = pabAddPopup + popup._kind = kind + popup._anchorBtn = anchorBtn + popup._nameBox:SetText("") + popup._createBtn:SetScript("OnClick", function() + local text = popup._nameBox:GetText() + local name = (text and text ~= "") and text or nil + local bar + if kind == "buff" then + bar = ns.PAB_AddCustomBuffBar and ns.PAB_AddCustomBuffBar(name) + else + bar = ns.PAB_AddCustomDebuffBar and ns.PAB_AddCustomDebuffBar(name) + end + popup:Hide() + if bar then + pabSel = { kind = kind, id = bar.id } + Apply(kind == "buff", bar.id) + EllesmereUI:RefreshPage(true) + end + end) + + popup:ClearAllPoints() + local sc = anchorBtn:GetEffectiveScale() / UIParent:GetEffectiveScale() + popup:SetScale(sc) + popup:SetPoint("TOP", anchorBtn, "BOTTOM", 0, -12) + popup:Show() + popup._nameBox:SetFocus() +end + +------------------------------------------------------------------------------- +-- Page entry point +------------------------------------------------------------------------------- + +function ns.PABMP_BuildPage(pageName, parent, yOffset) + local scrollFrame = EllesmereUI._scrollFrame + if not scrollFrame then return 0 end + local fontPath = (EllesmereUI.GetFontPath and EllesmereUI.GetFontPath("unitFrames")) or "Fonts\\FRIZQT__.TTF" + + -- Runs every time this page opens, not just when empty: creates any + -- missing curated presets AND retroactively re-flags already-existing + -- ones as protected (f.preset = true) -- needed once to fix filters + -- imported before that flag existed, and keeps future seed-data + -- changes in sync. Fully idempotent and cheap (name lookup over 10 + -- entries). Presets are no longer deletable via the UI, so there's no + -- "silently reappears after deletion" concern any more either. + if ns.PAB_ImportBM2Filters then + ns.PAB_ImportBM2Filters() + end + + local parentW = scrollFrame:GetWidth() + local fullH = scrollFrame:GetHeight() + local sidebarW = floor(parentW * 0.28) + local leftW = parentW - sidebarW + + local outerRoot = CreateFrame("Frame", nil, scrollFrame) + outerRoot:SetAllPoints(scrollFrame) + outerRoot:SetFrameLevel(scrollFrame:GetFrameLevel() + 5) + if ns._pabRoot then ns._pabRoot:Hide(); ns._pabRoot:SetParent(nil) end + ns._pabRoot = outerRoot + + local buffBars = ns.PAB_CustomBuffBars and ns.PAB_CustomBuffBars() or {} + local debuffBars = ns.PAB_CustomDebuffBars and ns.PAB_CustomDebuffBars() or {} + + -- Validate selection against current data (bar may have been deleted + -- elsewhere, e.g. profile switch). "default" and "extdef" are always + -- valid (fixed built-in bars, not custom-bar list entries). + if pabSel and pabSel.id ~= "default" and pabSel.id ~= "extdef" then + local ok = false + local list = (pabSel.kind == "buff") and buffBars or debuffBars + for i = 1, #list do if list[i].id == pabSel.id then ok = true end end + if not ok then pabSel = { kind = "buff", id = "default" } end + end + + -- Page-level "Player Aura Bars" header card removed (2026-08-02, Joel: + -- it competed visually with the new per-bar preview box now sitting at + -- the top of each detail pane). HEADER_H kept at 0 rather than removed + -- outright so `root`'s offset math below still reads clearly as "below + -- the (now empty) header band". + local HEADER_H = 0 + + local root = CreateFrame("Frame", nil, outerRoot) + root:SetPoint("TOPLEFT", outerRoot, "TOPLEFT", 0, -HEADER_H) + root:SetPoint("BOTTOMRIGHT", outerRoot, "BOTTOMRIGHT", 0, 0) + root:SetFrameLevel(outerRoot:GetFrameLevel() + 1) + local visibleH = fullH - HEADER_H + + local sidebarOuter = CreateFrame("Frame", nil, root) + sidebarOuter:SetSize(sidebarW, visibleH) + sidebarOuter:SetPoint("TOPRIGHT", root, "TOPRIGHT", 0, -1) + sidebarOuter:SetFrameLevel(root:GetFrameLevel() + 1) + local sbBg = sidebarOuter:CreateTexture(nil, "BACKGROUND") + sbBg:SetAllPoints() + sbBg:SetColorTexture(0, 0, 0, 0.25) + local sidebarScroll = CreateFrame("ScrollFrame", nil, sidebarOuter) + sidebarScroll:SetAllPoints() + local sidebarChild = CreateFrame("Frame", nil, sidebarScroll) + sidebarChild:SetWidth(sidebarW) + sidebarScroll:SetScrollChild(sidebarChild) + sidebarScroll:EnableMouseWheel(true) + sidebarScroll:SetScript("OnMouseWheel", function(self, delta) + local maxS = max(0, sidebarChild:GetHeight() - self:GetHeight()) + self:SetVerticalScroll(math.min(maxS, math.max(0, self:GetVerticalScroll() - delta * 40))) + end) + + local tileY = 0 + + -- Buff bars section: fixed "Buffs" default bar first, then custom bars. + do + local hdr = sidebarChild:CreateFontString(nil, "OVERLAY") + hdr:SetFont(fontPath, 11, "") + hdr:SetPoint("TOPLEFT", sidebarChild, "TOPLEFT", 12, tileY - 8) + hdr:SetText(L("BUFF BARS")) + hdr:SetTextColor(0.6, 0.6, 0.6) + tileY = tileY - 22 + + tileY = tileY - BuildTile(sidebarChild, tileY, { + width = sidebarW, fontPath = fontPath, + title = L("Buffs"), + subtitle = L("Default"), + selected = (pabSel and pabSel.kind == "buff" and pabSel.id == "default"), + showToggle = false, + onSelect = function() pabSel = { kind = "buff", id = "default" }; EllesmereUI:RefreshPage(true) end, + }) + + -- Second fixed/built-in bar (migrated from the retired standalone + -- ExternalDefensives module) -- unlike "Buffs"/"Debuffs" above, this + -- one has its own enable/disable toggle (showToggle=true) since it + -- has no "Assigned" content of its own to gate visibility on. + tileY = tileY - BuildTile(sidebarChild, tileY, { + width = sidebarW, fontPath = fontPath, + title = L("External Defensives"), + subtitle = L("Shows external defensives cast on you."), + selected = (pabSel and pabSel.kind == "buff" and pabSel.id == "extdef"), + enabled = (function() + local s = ns.db and ns.db.profile and ns.db.profile.playerAuraBars + local cfg = s and ns.PAB_DefaultExternalDefensivesCfg and ns.PAB_DefaultExternalDefensivesCfg(s) + return cfg and cfg.enabled ~= false or false + end)(), + showToggle = true, + onSelect = function() pabSel = { kind = "buff", id = "extdef" }; EllesmereUI:RefreshPage(true) end, + onToggle = function(v) + local s = ns.db and ns.db.profile and ns.db.profile.playerAuraBars + local cfg = s and ns.PAB_DefaultExternalDefensivesCfg and ns.PAB_DefaultExternalDefensivesCfg(s) + if not cfg then return end + cfg.enabled = v and true or false + if ns.PAB_ApplyExtDefLiveConfig then ns.PAB_ApplyExtDefLiveConfig() end + EllesmereUI:RefreshPage(true) + end, + }) + + for i = 1, #buffBars do + local bar = buffBars[i] + tileY = tileY - BuildTile(sidebarChild, tileY, { + width = sidebarW, fontPath = fontPath, + title = bar.name or L("Buff Bar"), + subtitleFn = function() return BuildBuffBarSubtitle(bar) end, + selected = (pabSel and pabSel.kind == "buff" and pabSel.id == bar.id), + enabled = bar.enabled and true or false, + showToggle = true, + onSelect = function() pabSel = { kind = "buff", id = bar.id }; EllesmereUI:RefreshPage(true) end, + onToggle = function(v) + bar.enabled = v and true or false + Apply(true, bar.id) + EllesmereUI:RefreshPage(true) + end, + onRename = function() + EllesmereUI:ShowInputPopup({ + title = L("Rename Bar"), placeholder = bar.name or L("Buff Bar"), + confirmText = L("Rename"), cancelText = L("Cancel"), + onConfirm = function(text) + if text and text ~= "" then + bar.name = text + -- Re-registers this bar's Unlock Mode label from + -- the new bar.name (RegisterPABCustomUnlock reads + -- it live, but only ever re-runs from here) -- + -- without this, Unlock Mode kept showing the old + -- name until the next /reload. + Apply(true, bar.id) + end + EllesmereUI:RefreshPage(true) + end, + }) + end, + onDelete = function() + EllesmereUI:ShowConfirmPopup({ + title = L("Delete Bar"), + message = L("Delete this buff bar?"), + confirmText = L("Delete"), cancelText = L("Cancel"), + onConfirm = function() + ns.PAB_DeleteCustomBuffBar(bar.id) + if pabSel and pabSel.kind == "buff" and pabSel.id == bar.id then + pabSel = { kind = "buff", id = "default" } + end + Apply(true, bar.id) + EllesmereUI:RefreshPage(true) + end, + }) + end, + }) + end + tileY = tileY - AddNewButton(sidebarChild, tileY, sidebarW, L("Add Buff Bar"), function(self) + ShowAddBarPopup(self, "buff", fontPath) + end) + end + + -- Debuff bars section: fixed "Debuffs" default bar first, then custom. + do + local hdr = sidebarChild:CreateFontString(nil, "OVERLAY") + hdr:SetFont(fontPath, 11, "") + hdr:SetPoint("TOPLEFT", sidebarChild, "TOPLEFT", 12, tileY - 8) + hdr:SetText(L("DEBUFF BARS")) + hdr:SetTextColor(0.6, 0.6, 0.6) + tileY = tileY - 22 + + tileY = tileY - BuildTile(sidebarChild, tileY, { + width = sidebarW, fontPath = fontPath, + title = L("Debuffs"), + subtitle = L("Default"), + selected = (pabSel and pabSel.kind == "debuff" and pabSel.id == "default"), + showToggle = false, + onSelect = function() pabSel = { kind = "debuff", id = "default" }; EllesmereUI:RefreshPage(true) end, + }) + + for i = 1, #debuffBars do + local bar = debuffBars[i] + tileY = tileY - BuildTile(sidebarChild, tileY, { + width = sidebarW, fontPath = fontPath, + title = bar.name or L("Debuff Bar"), + subtitleFn = function() return BuildDebuffBarSubtitle(bar) end, + selected = (pabSel and pabSel.kind == "debuff" and pabSel.id == bar.id), + enabled = bar.enabled and true or false, + showToggle = true, + onSelect = function() pabSel = { kind = "debuff", id = bar.id }; EllesmereUI:RefreshPage(true) end, + onToggle = function(v) + bar.enabled = v and true or false + Apply(false, bar.id) + EllesmereUI:RefreshPage(true) + end, + onRename = function() + EllesmereUI:ShowInputPopup({ + title = L("Rename Bar"), placeholder = bar.name or L("Debuff Bar"), + confirmText = L("Rename"), cancelText = L("Cancel"), + onConfirm = function(text) + if text and text ~= "" then + bar.name = text + -- Re-registers this bar's Unlock Mode label from + -- the new bar.name (RegisterPABCustomUnlock reads + -- it live, but only ever re-runs from here) -- + -- without this, Unlock Mode kept showing the old + -- name until the next /reload. + Apply(false, bar.id) + end + EllesmereUI:RefreshPage(true) + end, + }) + end, + onDelete = function() + EllesmereUI:ShowConfirmPopup({ + title = L("Delete Bar"), + message = L("Delete this debuff bar?"), + confirmText = L("Delete"), cancelText = L("Cancel"), + onConfirm = function() + ns.PAB_DeleteCustomDebuffBar(bar.id) + if pabSel and pabSel.kind == "debuff" and pabSel.id == bar.id then + pabSel = { kind = "buff", id = "default" } + end + Apply(false, bar.id) + EllesmereUI:RefreshPage(true) + end, + }) + end, + }) + end + tileY = tileY - AddNewButton(sidebarChild, tileY, sidebarW, L("Add Debuff Bar"), function(self) + ShowAddBarPopup(self, "debuff", fontPath) + end) + end + + sidebarChild:SetHeight(max(10, math.abs(tileY))) + + local detail = CreateFrame("Frame", nil, root) + detail:SetPoint("TOPLEFT", root, "TOPLEFT", 0, 0) + detail:SetSize(leftW, visibleH) + detail:SetFrameLevel(root:GetFrameLevel() + 1) + + if pabSel then + if pabSel.id == "default" then + BuildDefaultBarDetail(detail, fontPath, pabSel.kind == "buff") + elseif pabSel.id == "extdef" then + BuildExternalDefensivesBarDetail(detail, fontPath) + elseif pabSel.kind == "buff" then + local bar = ns.PAB_GetCustomBuffBar and ns.PAB_GetCustomBuffBar(pabSel.id) + if bar then BuildBuffBarDetail(detail, fontPath, bar) end + else + local bar = ns.PAB_GetCustomDebuffBar and ns.PAB_GetCustomDebuffBar(pabSel.id) + if bar then BuildDebuffBarDetail(detail, fontPath, bar) end + end + end + + return 0 +end diff --git a/EllesmereUIUnitFrames/EUI_UnitFrames_AuraContainers.lua b/EllesmereUIUnitFrames/EUI_UnitFrames_AuraContainers.lua index 17914e1c..15ff7073 100644 --- a/EllesmereUIUnitFrames/EUI_UnitFrames_AuraContainers.lua +++ b/EllesmereUIUnitFrames/EUI_UnitFrames_AuraContainers.lua @@ -72,13 +72,36 @@ local TOKEN_CLASSES = { { key = "nonplayer", token = "!PLAYER", skey = "NonPlayer", neg = "PLAYER", debuffOnly = true, playerUnitOnly = true }, } +-- Any debuff carrying a dispel type (Magic/Curse/Disease/Poison/Bleed), +-- regardless of whether the PLAYER can remove it -- distinct from +-- "dispellable" above (RAID_PLAYER_DISPELLABLE, dispellable-by-you only). +-- Same native set Raid Frames' DebuffManager already verified against +-- Blizzard's PTR source (EUI_RaidFrames_DebuffManager.lua's TYPED_DEBUFFS) -- +-- kept as this module's own copy rather than a cross-addon reference. +local TYPED_DEBUFF_TYPES = { Magic = true, Curse = true, Disease = true, Poison = true, Bleed = true } + local CANDIDATE_CLASSES = { { key = "bossaura", cand = "isBossAura", skey = "BossAura", debuffOnly = true }, { key = "roleaura", cand = "isRoleAura", skey = "RoleAura", debuffOnly = true }, { key = "priority", cand = "isPriorityAura", skey = "PriorityAura", debuffOnly = true }, { key = "steal", cand = "isStealable", skey = "Stealable", buffOnly = true }, + -- candValue: unlike the boolean candidate classes above, includeDispelTypes + -- takes a SET table, not `true` -- BuildChain/ApplyGroupConfig/DeclareElementGroup + -- (this file) and PAB's own equivalents thread candValue through and use + -- `groupCand[cand] = candValue or true` at apply time so both shapes work + -- through the same mechanism. + { key = "dispeltyped", cand = "includeDispelTypes", candValue = TYPED_DEBUFF_TYPES, + skey = "DispelTyped", debuffOnly = true }, } +-- Shared with EllesmereUIUnitFrames_PlayerAuraBars.lua: same class vocabulary, +-- same mutual-exclusion semantics (token classes negate every enabled class +-- before them; candidate classes are engine boolean selectors). One source +-- of truth so a future class addition/removal here does not silently drift +-- from what Player Aura Bars offers. +ns.UF_TokenClasses = TOKEN_CLASSES +ns.UF_CandidateClasses = CANDIDATE_CLASSES + local function ClassEnabled(class, isBuff, s, unit) if class.buffOnly and not isBuff then return false end if class.debuffOnly and isBuff then return false end @@ -106,7 +129,7 @@ local function BuildChain(base, isBuff, s, unit) if ClassEnabled(class, isBuff, s, unit) then local tokens = { base } for n = 1, #negations do tokens[#tokens + 1] = negations[n] end - chain[#chain + 1] = { key = class.key, tokens = tokens, cand = class.cand } + chain[#chain + 1] = { key = class.key, tokens = tokens, cand = class.cand, candValue = class.candValue } end end return chain @@ -222,6 +245,12 @@ local STACK_POINTS = { center = { "CENTER", 0 }, } +local function CK(c) + if not c then return "-" end + return string.format("%.3f,%.3f,%.3f", + c.r or c[1] or 0, c.g or c[2] or 0, c.b or c[3] or 0) +end + -- Module text pass: fonts through the shared icon-text pipeline (outline slug -- rules live there), duration text centered like cooldown countdown text. -- Restyles hit every registered button, so SetFont is change-guarded (it @@ -230,9 +259,11 @@ local STACK_POINTS = { -- same as the RF pass). The duration string is ALWAYS fonted, hidden or -- not: the engine SetText()s every registered duration string on display -- updates, and an unfonted FontString hard-errors inside that engine call --- (visibility is handled by AuraKit via SetShown). +-- (visibility is handled by AuraKit via SetShown). Text color is likewise +-- change-guarded via CK() fingerprints (d.ufDurColor/d.ufStackColor) -- +-- SetTextColor costs real time too, same reasoning as the font guard above. local function ApplyUFText(button, d, style) - local path = (EllesmereUI.GetFontPath and EllesmereUI.GetFontPath("unitFrames")) or FALLBACK_FONT + local path = style.fontPath or FALLBACK_FONT if d.duration then local fontKey = path .. "|" .. (style.cdTextSize or 10) if d.ufDurFont ~= fontKey then @@ -240,7 +271,11 @@ local function ApplyUFText(button, d, style) EllesmereUI.ApplyIconTextFont(d.duration, path, style.cdTextSize or 10, "unitFrames") end local c = style.cdTextColor - d.duration:SetTextColor(c and c.r or 1, c and c.g or 1, c and c.b or 1) + local cKey = CK(c) + if d.ufDurColor ~= cKey then + d.ufDurColor = cKey + d.duration:SetTextColor(c and c.r or 1, c and c.g or 1, c and c.b or 1) + end -- Anchor change-guarded (stamp AFTER the calls): SetPoint with the -- button as the relative frame is policed by the 12.1 button access -- restriction while auras are secret; unchanged offsets must make @@ -259,7 +294,11 @@ local function ApplyUFText(button, d, style) EllesmereUI.ApplyIconTextFont(d.stack, path, style.stackSize or 14, "unitFrames") end local c = style.stackColor - d.stack:SetTextColor(c and c.r or 1, c and c.g or 1, c and c.b or 1) + local cKey = CK(c) + if d.ufStackColor ~= cKey then + d.ufStackColor = cKey + d.stack:SetTextColor(c and c.r or 1, c and c.g or 1, c and c.b or 1) + end local sp = STACK_POINTS[style.stackPos or "bottomright"] or STACK_POINTS.bottomright local sKey = sp[1] .. "|" .. (sp[2] + (style.stackOffX or 0)) .. "|" .. (style.stackOffY or 0) if d.ufStackAnchor ~= sKey then @@ -296,12 +335,6 @@ local function FP(...) return table.concat(t, "|") end -local function CK(c) - if not c then return "-" end - return string.format("%.3f,%.3f,%.3f", - c.r or c[1] or 0, c.g or c[2] or 0, c.b or c[3] or 0) -end - -- Fingerprint of a BUILT style table (BuildStyle is a pure function of the -- settings, so hashing its scalar output covers every input, including the -- boss-simple sizing and scale). Constant cooldown/cancel fields are omitted; @@ -332,7 +365,7 @@ end -- element's declared-set registry. Used at creation and by the additive -- reload path (AddAuraGroup on an existing container is combat-legal -- -- probe T1/T1b). -local function DeclareElementGroup(container, declared, styleKey, base, key, tokens, cand, own) +local function DeclareElementGroup(container, declared, styleKey, base, key, tokens, cand, own, candValue) local eff = EffKey(key, own) local ftokens = tokens if own then @@ -343,7 +376,7 @@ local function DeclareElementGroup(container, declared, styleKey, base, key, tok AK.AddGroupToContainer(container, { key = eff, filter = ftokens, maxFrameCount = 0, style = styleKey, }) - declared[eff] = { cand = cand or false } + declared[eff] = { cand = cand or false, candValue = candValue } end -- Explicit either/or (never `cond and a or b`: a falsy setting must not fall @@ -436,6 +469,10 @@ local function BuildStyle(unit, base, s, unitFrame) -- dispel color itself -- the user palette cannot apply under secrecy -- (same documented delta as the RF debuff border). dispelBorder = (not isBuff and s.debuffDispelBorder) and true or nil, + -- Resolved once per (fingerprint-gated) style rebuild instead of on + -- every ApplyUFText call -- GetFontPath's result only changes when + -- font settings change, which already forces a fresh style table. + fontPath = (EllesmereUI.GetFontPath and EllesmereUI.GetFontPath("unitFrames")) or FALLBACK_FONT, applyExtra = ApplyUFText, } end @@ -457,9 +494,7 @@ end -- Container anchoring: mirrors the legacy element's SetPoint(ia, frame, fp, -- ox + userX, oy + castbarPush + userY) with gap = 1. --- buffContainer (HARMFUL calls only): the unit's buff container, needed by --- the Anchor Buffs with Debuffs mode below. -local function AnchorContainer(container, frame, unit, base, s, buffContainer) +local function AnchorContainer(container, frame, unit, base, s) local isBuff = (base == "HELPFUL") -- Boss simple side display: forced side anchoring flush with the frame @@ -485,24 +520,9 @@ local function AnchorContainer(container, frame, unit, base, s, buffContainer) local anchor = Pick(isBuff, s.buffAnchor, s.debuffAnchor) if anchor == nil then anchor = Pick(isBuff, "topleft", "none") end - - -- Anchor Buffs with Debuffs (per-unit debuffAnchorBuffs, non-boss): - -- buffs adopt the debuff anchor/growth/offsets and become the stack's - -- first rows; the debuff container then rides the BUFF CONTAINER's - -- leading edge. The engine re-sizes that container to its rows every - -- layout pass, so the push is engine-driven -- full rows only, and no - -- aura reads (secret-safe in combat). - -- The merge OWNS buff visibility: it renders buffs even with Buff - -- Display at None (showBuffs false), which is the state the options - -- auto-select on enable. - local merged = s.debuffAnchorBuffs == true and not unit:match("^boss") - and (s.debuffAnchor or "none") ~= "none" - local mergedBuff = merged and isBuff - if mergedBuff then anchor = s.debuffAnchor end if anchor == "none" then return anchor end local growth = Pick(isBuff, s.buffGrowth, s.debuffGrowth) - if mergedBuff then growth = s.debuffGrowth end local ia, fp, ox, oy, gX, gY = ResolveLayout(anchor, growth) local cbOff = 0 @@ -520,32 +540,10 @@ local function AnchorContainer(container, frame, unit, base, s, buffContainer) local offX = Pick(isBuff, s.buffOffsetX, s.debuffOffsetX) or 0 local offY = Pick(isBuff, s.buffOffsetY, s.debuffOffsetY) or 0 - if mergedBuff then - offX = s.debuffOffsetX or 0 - offY = s.debuffOffsetY or 0 - end container:ClearAllPoints() - if merged and not isBuff and buffContainer then - -- Ride the buff container: horizontal side from the layout anchor, - -- vertical side from the wrap direction, one debuff line-gap - -- between the blocks. Debuffs never share a row with buffs. - local PP = EllesmereUI.PP - local horiz = "" - if ia:find("LEFT") then horiz = "LEFT" elseif ia:find("RIGHT") then horiz = "RIGHT" end - local vert, relVert, gapSign - if gY == "UP" then - vert, relVert, gapSign = "BOTTOM", "TOP", 1 - else - vert, relVert, gapSign = "TOP", "BOTTOM", -1 - end - local gap = PP.FromPixels(s.debuffSpacingY or 1) - container:SetPoint(vert .. horiz, buffContainer, relVert .. horiz, 0, gap * gapSign) - AK.SetContainerAnchor(container, vert .. horiz) - else - container:SetPoint(ia, frame, fp, ox + offX, oy + cbOff + offY) - AK.SetContainerAnchor(container, ia) - end + container:SetPoint(ia, frame, fp, ox + offX, oy + cbOff + offY) + AK.SetContainerAnchor(container, ia) AK.SetContainerGrowth(container, FlowDir(gX), FlowDir(gY)) return anchor @@ -558,15 +556,9 @@ local function ApplyGroupConfig(container, unit, base, s, chain, own, declared) local simpleOn = BossSimple(unit, base, s) - -- Anchor Buffs with Debuffs: the merge owns buff visibility (Buff - -- Display reads None while the stack renders the buffs), and the buff - -- groups wrap like the debuff stack they join. - local mergedAB = s.debuffAnchorBuffs == true and not unit:match("^boss") - and (s.debuffAnchor or "none") ~= "none" - local shown if isBuff then - shown = (s.showBuffs ~= false) or simpleOn or mergedAB + shown = (s.showBuffs ~= false) or simpleOn else shown = ((s.debuffAnchor or "none") ~= "none") or simpleOn end @@ -592,7 +584,6 @@ local function ApplyGroupConfig(container, unit, base, s, chain, own, declared) local growth = Pick(isBuff, s.buffGrowth, s.debuffGrowth) if simpleOn then growth = "auto" end - if mergedAB and isBuff then growth = s.debuffGrowth end local maxPerRow = Pick(isBuff, s.buffMaxPerRow, s.debuffMaxPerRow) local cols = ResolveColumns(growth, num > 0 and num or 1, maxPerRow) local rowWidth = nil @@ -641,7 +632,7 @@ local function ApplyGroupConfig(container, unit, base, s, chain, own, declared) if cand then for k, v in pairs(cand) do groupCand[k] = v end end - groupCand[info.cand] = true + groupCand[info.cand] = info.candValue or true end container:SetAuraGroupCandidateFilters(eff, groupCand) container:SetAuraGroupLayout(eff, layout) @@ -990,7 +981,7 @@ function ns.UF_ReloadAuraContainers(frame, unit) for i = 1, #chain do local c = chain[i] if not declared[EffKey(c.key, own)] then - DeclareElementGroup(container, declared, key, base, c.key, c.tokens, c.cand, own) + DeclareElementGroup(container, declared, key, base, c.key, c.tokens, c.cand, own, c.candValue) end end force = true @@ -1000,7 +991,7 @@ function ns.UF_ReloadAuraContainers(frame, unit) local cfgV = CfgFP(unit, base, s) if force or st.cfg ~= cfgV then st.cfg = cfgV - AnchorContainer(container, frame, unit, base, s, entry.buffs) -- self-skips on anchor "none" + AnchorContainer(container, frame, unit, base, s) -- self-skips on anchor "none" ApplyGroupConfig(container, unit, base, s, chain, own, declared) end end @@ -1123,7 +1114,7 @@ local function BuildUnitContainers(frame, unit) for i = 1, #chain do local c = chain[i] if not declared[EffKey(c.key, own)] then - DeclareElementGroup(entry[field], declared, styleKey, base, c.key, c.tokens, c.cand, own) + DeclareElementGroup(entry[field], declared, styleKey, base, c.key, c.tokens, c.cand, own, c.candValue) return "again" end end diff --git a/EllesmereUIUnitFrames/EUI_UnitFrames_Options.lua b/EllesmereUIUnitFrames/EUI_UnitFrames_Options.lua index 68ff12a9..efa32ecd 100644 --- a/EllesmereUIUnitFrames/EUI_UnitFrames_Options.lua +++ b/EllesmereUIUnitFrames/EUI_UnitFrames_Options.lua @@ -1,14 +1,17 @@ ------------------------------------------------------------------------------- -- EUI_UnitFrames_Options.lua -- Registers the Unit Frames module with EllesmereUI --- 4 tabs: Main Frames, Boss Frames, Mini Frames, Blizzard Aura Frames +-- 4 tabs: Main Frames, Boss Frames, Mini Frames, Player Aura Bars +-- (the former standalone "External Defensives" tab was removed 2026-08-02 +-- -- migrated into Player Aura Bars as a third built-in bar, see +-- EllesmereUIUnitFrames_PlayerAuraBars.lua's DefaultExternalDefensivesCfg) ------------------------------------------------------------------------------- local ADDON_NAME, ns = ... local PAGE_DISPLAY = "Main Frames" local PAGE_BOSS = "Boss Frames" local PAGE_MINI = "Mini Frames" -local PAGE_AURAS = "Blizzard Aura Frames" +local PAGE_AURA_BARS = "Player Aura Bars" local PAGE_UNLOCK = "Unlock Mode" local initFrame = CreateFrame("Frame") @@ -99,8 +102,56 @@ initFrame:SetScript("OnEvent", function(self) if ns._bossPreviewActive and ns.SetBossPreview then ns.SetBossPreview(false) end + -- Player Aura Bars root: built onto the shared live scrollFrame, not + -- under a page's own `parent`. This fires when the WHOLE options + -- window closes (confirmed against EUI_RaidFrames_Options.lua's + -- identical _bmRoot/_dmRoot/_ccRoot cleanup here) -- NOT on + -- switching to a different top-level module; that's a separate + -- event, handled below via SelectModule. + if ns._pabRoot then + ns._pabRoot:Hide() + ns._pabRoot:SetParent(nil) + ns._pabRoot = nil + end end) + -- Rebuild Player Aura Bars when the options window re-opens while + -- already sitting on that page. RegisterOnHide above tore _pabRoot down + -- on close; nothing else rebuilds it on a plain re-open (unlike a tab + -- switch, which goes through buildPage/onPageCacheRestore). Mirrors + -- EUI_RaidFrames_Options.lua's identical "Show preview / rebuild BM when + -- panel re-opens on RF page" RegisterOnShow block. + if EllesmereUI.RegisterOnShow then + EllesmereUI:RegisterOnShow(function() + if EllesmereUI:GetActiveModule() == "EllesmereUIUnitFrames" + and EllesmereUI:GetActivePage() == PAGE_AURA_BARS + and not ns._pabRoot then + C_Timer.After(0, function() + if EllesmereUI:GetActiveModule() == "EllesmereUIUnitFrames" and ns.PABMP_BuildPage then + ns.PABMP_BuildPage(PAGE_AURA_BARS, nil, -6) + end + end) + end + end) + end + + -- Clean up Player Aura Bars root when switching to a DIFFERENT + -- top-level module (Nameplates, Raid Frames, ...). This is NOT the same + -- event as RegisterOnHide (the whole options window closing) -- while + -- switching modules the window stays open, so RegisterOnHide never + -- fires and _pabRoot was left overlapping the newly-shown module's + -- content. Mirrors EUI_RaidFrames_Options.lua's identical + -- hooksecurefunc(EllesmereUI, "SelectModule", ...) cleanup. + if EllesmereUI.SelectModule then + hooksecurefunc(EllesmereUI, "SelectModule", function(_, folderName) + if folderName ~= "EllesmereUIUnitFrames" and ns._pabRoot then + ns._pabRoot:Hide() + ns._pabRoot:SetParent(nil) + ns._pabRoot = nil + end + end) + end + --------------------------------------------------------------------------- -- Individual Display unit selector --------------------------------------------------------------------------- @@ -311,7 +362,7 @@ initFrame:SetScript("OnEvent", function(self) local healthTextOrder = { "none", "---", "name", "levelname", "namelevel", "level", "perhp", "perhpnosign", "curhpshort", "perhpnum", "both" } -- Boss frames also get "Name > Target" (the boss's current target); the other -- mini frames (Target of Target / Focus Target / Pet) do not. - local healthTextOrderBoss = { "none", "---", "name", "nametotarget", "levelname", "namelevel", "level", "perhp", "perhpnosign", "curhpshort", "perhpnum", "both", "bothdash", "perhpnumdash", "absorb", "absorbshort", "healabsorb", "healabsorbshort" } + local healthTextOrderBoss = { "none", "---", "name", "nametotarget", "levelname", "namelevel", "level", "perhp", "perhpnosign", "curhpshort", "perhpnum", "both", "bothdash", "perhpnumdash" } local healthTextOrderPlayer = { "none", "---", "name", "nametotarget", "levelname", "namelevel", "level", "perhp", "perhpnosign", "curhpshort", "perhpnum", "both", "bothdash", "perhpnumdash", "absorb", "absorbshort", "healabsorb", "healabsorbshort", "group" } -- Target/Focus get the same absorb text options as player, minus "group" -- (Group Number is the player's own raid group; it is meaningless on a target/focus). @@ -3904,7 +3955,7 @@ initFrame:SetScript("OnEvent", function(self) block:SetAllPoints() block:SetFrameLevel(rgn:GetFrameLevel() + 50) block:EnableMouse(true) - block:SetScript("OnEnter", function() EllesmereUI.ShowWidgetTooltip(block, "Not available in Dark Mode. Dark Mode colors can be adjusted in Global Settings -> Fonts & Colors.") end) + block:SetScript("OnEnter", function() EllesmereUI.ShowWidgetTooltip(block, "Not available in Dark Mode") end) block:SetScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) local function Update() if db and db.profile and db.profile.darkTheme then @@ -4112,7 +4163,7 @@ initFrame:SetScript("OnEvent", function(self) cogTex:SetTexture(EllesmereUI.COGS_ICON) cogBtn:SetScript("OnEnter", function(self) self:SetAlpha(0.7) - EllesmereUI.ShowWidgetTooltip(self, EllesmereUI.L(opts.cogTooltip or "Frame Source")) + EllesmereUI.ShowWidgetTooltip(self, opts.cogTooltip and EllesmereUI.L(opts.cogTooltip) or "Frame Source") end) cogBtn:SetScript("OnLeave", function(self) self:SetAlpha(0.4) @@ -4634,18 +4685,8 @@ initFrame:SetScript("OnEvent", function(self) setValue=function(v) db.profile.darkTheme = v ReloadAndUpdate(); UpdatePreview() - -- Dark Mode feeds the conditional-override condition. - if EllesmereUI.Conditions_Recheck then EllesmereUI.Conditions_Recheck() end EllesmereUI:RefreshPage() end }); y = y - h - -- This toggle IS the Dark Mode condition's input for Unit Frames: - -- lock it while a Dark Mode conditional is being edited, or the - -- override could capture a value that flips its own condition. - if EllesmereUI.SpecOverrides_AttachEditLock then - EllesmereUI.SpecOverrides_AttachEditLock(barTexRow._rightRegion, - "Dark Mode drives a Dark Mode override condition and can't be changed while editing an override", - EllesmereUI.SpecOverrides_DarkCondEditActive) - end -- Sync icon: Bar Texture (left region) -- pushes this unit's texture to frames do local rgn = barTexRow._leftRegion @@ -4970,60 +5011,9 @@ initFrame:SetScript("OnEvent", function(self) ReloadAndUpdate() end }); y = y - h - -- Show Tooltip For checkbox-dropdown (left region) - do - local rgn = tipStrataRow._leftRegion - if rgn._control then rgn._control:Hide() end - local tipItems = { - { key = "unit", label = "Unit Frame", - tooltip = "Show the unit's tooltip when hovering the frame itself." }, - { key = "auras", label = "Main Frames Buffs & Debuffs", - tooltip = "Show aura tooltips when hovering buff and debuff icons on all unit frames except boss frames." }, - { key = "bossauras", label = "Boss Frames Buffs & Debuffs", - tooltip = "Show aura tooltips when hovering buff and debuff icons on boss frames." }, - } - -- Both aura items are views over the same per-unit showAuraTooltips - -- key the runtime already reads per element; they only differ in - -- which unit keys the setter fans out to. - local ALL_UNITS = { "player", "target", "focus", "targettarget", "focustarget", "pet", "boss" } - local MAIN_UNITS = { "player", "target", "focus", "targettarget", "focustarget", "pet" } - local PP = EllesmereUI.PP - local cbDD, cbDDRefresh = EllesmereUI.BuildVisOptsCBDropdown( - rgn, 210, rgn:GetFrameLevel() + 2, - tipItems, - function(k) - if k == "unit" then return SVal("showUnitTooltip", true) end - if k == "auras" then return SVal("showAuraTooltips", true) end - if k == "bossauras" then - return UNIT_DB_MAP["boss"]().showAuraTooltips ~= false - end - return false - end, - function(k, v) - if k == "unit" then - for _, key in ipairs(ALL_UNITS) do - UNIT_DB_MAP[key]().showUnitTooltip = v - end - elseif k == "auras" then - for _, key in ipairs(MAIN_UNITS) do - UNIT_DB_MAP[key]().showAuraTooltips = v - end - elseif k == "bossauras" then - UNIT_DB_MAP["boss"]().showAuraTooltips = v - else - return - end - ReloadAndUpdate() - end) - PP.Point(cbDD, "RIGHT", rgn, "RIGHT", -20, 0) - rgn._control = cbDD - rgn._lastInline = nil - EllesmereUI.RegisterWidgetRefresh(cbDDRefresh) - end - -- Cog on Frame Strata: custom bar stratas for detached power/text bar do - local strataRgn = tipStrataRow + local strataRgn = _ if strataRgn and strataRgn._rightRegion then strataRgn = strataRgn._rightRegion end local barStrataValues = EllesmereUI.FRAME_STRATA_LABELS local barStrataOrder = EllesmereUI.FRAME_STRATA_ORDER_BASE @@ -6253,11 +6243,7 @@ initFrame:SetScript("OnEvent", function(self) PP.Point(ltClassSwatch, "RIGHT", ltAnchor, "LEFT", -8, 0) ltClassSwatch:SetScript("OnClick", function() if SVal("leftTextContent", "name") == "none" then return end - SSet("leftTextClassColor", true) - -- Bespoke write: notify for exact Spec Overrides attribution - -- (the forced RefreshPage below would otherwise resync-absorb it). - if EllesmereUI._NotifySettingWrite then EllesmereUI._NotifySettingWrite(ltClassSwatch) end - UpdatePreview(); EllesmereUI:RefreshPage() + SSet("leftTextClassColor", true); UpdatePreview(); EllesmereUI:RefreshPage() end) ltClassSwatch:SetScript("OnEnter", function() EllesmereUI.ShowWidgetTooltip(ltClassSwatch, "Class Colored") end) ltClassSwatch:SetScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) @@ -6276,9 +6262,7 @@ initFrame:SetScript("OnEvent", function(self) ltSwatch:SetScript("OnClick", function(self, ...) if SVal("leftTextContent", "name") == "none" then return end if SVal("leftTextClassColor", false) then - SSet("leftTextClassColor", false) - if EllesmereUI._NotifySettingWrite then EllesmereUI._NotifySettingWrite(self) end - UpdatePreview(); EllesmereUI:RefreshPage(); return + SSet("leftTextClassColor", false); UpdatePreview(); EllesmereUI:RefreshPage(); return end if ltOrigClick then ltOrigClick(self, ...) end end) @@ -6292,18 +6276,6 @@ initFrame:SetScript("OnEvent", function(self) end RegisterWidgetRefresh(function() ltUpdateSwatch(); ltUpdateClassSwatch(); UpdateLtSwatches() end) UpdateLtSwatches() - -- The class-color mode flag is written only by the bespoke swatch - -- OnClicks above and read by no widget getter, so the Spec - -- Overrides read-trace could never connect an override on it to - -- this row (no gold border / overlay for class-color overrides). - -- Declare it as a capture accessor: the gold walk traces it. - if EllesmereUI.AddCaptureAccessor then - EllesmereUI.AddCaptureAccessor(leftRgn, { - type = "toggle", text = "Left Text Class Color", - getValue = function() return SVal("leftTextClassColor", false) end, - setValue = function(v) SSet("leftTextClassColor", v) end, - }) - end end -- Cogwheel on Left Text (left region) do @@ -6445,11 +6417,7 @@ initFrame:SetScript("OnEvent", function(self) PP.Point(rtClassSwatch, "RIGHT", rtAnchor, "LEFT", -8, 0) rtClassSwatch:SetScript("OnClick", function() if SVal("rightTextContent", "both") == "none" then return end - SSet("rightTextClassColor", true) - -- Bespoke write: notify for exact Spec Overrides attribution - -- (the forced RefreshPage below would otherwise resync-absorb it). - if EllesmereUI._NotifySettingWrite then EllesmereUI._NotifySettingWrite(rtClassSwatch) end - UpdatePreview(); EllesmereUI:RefreshPage() + SSet("rightTextClassColor", true); UpdatePreview(); EllesmereUI:RefreshPage() end) rtClassSwatch:SetScript("OnEnter", function() EllesmereUI.ShowWidgetTooltip(rtClassSwatch, "Class Colored") end) rtClassSwatch:SetScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) @@ -6467,9 +6435,7 @@ initFrame:SetScript("OnEvent", function(self) rtSwatch:SetScript("OnClick", function(self, ...) if SVal("rightTextContent", "both") == "none" then return end if SVal("rightTextClassColor", false) then - SSet("rightTextClassColor", false) - if EllesmereUI._NotifySettingWrite then EllesmereUI._NotifySettingWrite(self) end - UpdatePreview(); EllesmereUI:RefreshPage(); return + SSet("rightTextClassColor", false); UpdatePreview(); EllesmereUI:RefreshPage(); return end if rtOrigClick then rtOrigClick(self, ...) end end) @@ -6483,14 +6449,6 @@ initFrame:SetScript("OnEvent", function(self) end RegisterWidgetRefresh(function() rtUpdateSwatch(); rtUpdateClassSwatch(); UpdateRtSwatches() end) UpdateRtSwatches() - -- Mirror of the Left Text class-flag accessor: see that comment. - if EllesmereUI.AddCaptureAccessor then - EllesmereUI.AddCaptureAccessor(rightRgn, { - type = "toggle", text = "Right Text Class Color", - getValue = function() return SVal("rightTextClassColor", false) end, - setValue = function(v) SSet("rightTextClassColor", v) end, - }) - end end -- Cogwheel on Right Text (right region) do @@ -7233,7 +7191,7 @@ initFrame:SetScript("OnEvent", function(self) refreshAlpha = function() return SVal("powerPercentPowerColor", true) and 0.3 or 1 end }, - { tooltip = "Power Colored Fill. Power colors can be adjusted in Global Settings -> Fonts & Colors.", + { tooltip = "Power Colored Fill", hasAlpha = false, getValue = function() local _, pToken = UnitPowerType("player") @@ -7271,7 +7229,7 @@ initFrame:SetScript("OnEvent", function(self) SSet("powerBgPowerColored", true) ReloadAndUpdate(); UpdatePreview(); EllesmereUI:RefreshPage() end) - bgPwrSw:HookScript("OnEnter", function() EllesmereUI.ShowWidgetTooltip(bgPwrSw, "Power Colored Background. Power colors can be adjusted in Global Settings -> Fonts & Colors.") end) + bgPwrSw:HookScript("OnEnter", function() EllesmereUI.ShowWidgetTooltip(bgPwrSw, "Power Colored Background") end) bgPwrSw:HookScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) PP.Point(bgPwrSw, "RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) rgn._lastInline = bgPwrSw @@ -8041,11 +7999,11 @@ initFrame:SetScript("OnEvent", function(self) UNIT_DB_MAP[selectedUnit]().castFillOpacity = v ReloadAndUpdate(); UpdatePreview() end }, - -- Global (not per-frame, not synced): lift all + -- Global (not per-frame, not synced): lift the player/target/focus -- cast bars to HIGH strata. Default on = existing behavior; off leaves - -- them at the frame's strata. One db.profile key drives every unit. + -- them at the frame's strata. A single db.profile key drives all three. { type = "toggle", label = "Raise Cast Bar Strata (All)", - tooltip = "Lifts player, target, focus, and boss cast bars above other frames so they are never hidden behind them.", + tooltip = "Lifts the player, target, and focus cast bars above other frames so they are never hidden behind them.", get = function() return db.profile.raiseCastbarStrata ~= false end, set = function(v) db.profile.raiseCastbarStrata = v @@ -10055,58 +10013,18 @@ initFrame:SetScript("OnEvent", function(self) -- When Buff/Debuff Display is "none", everything in that column is disabled. local function BuffDisabled() local s = UNIT_DB_MAP[selectedUnit]() - if not s then return false end - -- Anchor Buffs with Debuffs renders the buffs inside the debuff - -- stack, so the buff appearance settings stay live while Buff - -- Display reads None (visibility belongs to the merge toggle). - if s.debuffAnchorBuffs and SValSupported("debuffAnchor", "bottomleft") ~= "none" then - return false - end - return s.showBuffs == false + return s and s.showBuffs == false end local function DebuffDisabled() return SValSupported("debuffAnchor", "bottomleft") == "none" end - -- Buff Display gains "Anchor to Debuffs" -- a pure VIEW over the same - -- stored keys the merge has always used (debuffAnchorBuffs = true + - -- showBuffs = false), so profiles that enabled it through the old cog - -- toggle read back identically. The shared anchor tables serve three - -- other dropdowns, hence the per-site copy. - local buffDispValues = { ["anchor_debuffs"] = "Anchor to Debuffs" } - for k, v in pairs(buffAnchorValues) do buffDispValues[k] = v end - local buffDispOrder = {} - for i = 1, #buffAnchorOrder do buffDispOrder[i] = buffAnchorOrder[i] end - buffDispOrder[#buffDispOrder + 1] = "anchor_debuffs" - buffDispValues._menuOpts = { - onItemHover = function(key, item) - if key == "anchor_debuffs" and item then - EllesmereUI.ShowWidgetTooltip(item, "Buffs join the debuff stack as its first rows; debuffs continue on the next row and move as buff rows change.") - end - end, - onItemLeave = function(key) - if key == "anchor_debuffs" then EllesmereUI.HideWidgetTooltip() end - end, - } - -- Buffs: Location | Icon Size + inline directions cog (X/Y) local sharedAddRow2 sharedAddRow2, h = W:DualRow(parent, y, - { type="dropdown", text="Buff Display", values=buffDispValues, order=buffDispOrder, - itemDisabled=function(v) - return v == "anchor_debuffs" and DebuffDisabled() - end, - itemDisabledTooltip=function(v) - if v == "anchor_debuffs" then return "Requires a Debuff Display" end - end, + { type="dropdown", text="Buff Display", values=buffAnchorValues, order=buffAnchorOrder, getValue=function() local s = UNIT_DB_MAP[selectedUnit]() - -- Active merge presents as its own display choice; an inert - -- merge (Debuff Display None) falls through to the truthful - -- None readout. - if s.debuffAnchorBuffs and SValSupported("debuffAnchor", "bottomleft") ~= "none" then - return "anchor_debuffs" - end if s.showBuffs == false then return "none" end return SValSupported("buffAnchor", "topleft") end, @@ -10117,20 +10035,11 @@ initFrame:SetScript("OnEvent", function(self) function() return not BuffDisabled() end, function(v) local s = UNIT_DB_MAP[selectedUnit]() - if v == "anchor_debuffs" then - -- Same stored shape the old cog toggle wrote: the - -- merge owns visibility, Buff Display stores None. - s.debuffAnchorBuffs = true - s.showBuffs = false - elseif v == "none" then + if v == "none" then s.showBuffs = false - s.debuffAnchorBuffs = nil else s.showBuffs = true SwapAuraSlot(s, "buffAnchor", v) - -- Choosing a standalone Buff Display exits the - -- merged mode (which forces this dropdown to None). - s.debuffAnchorBuffs = nil end ReloadAndUpdate(); UpdatePreview(); EllesmereUI:RefreshPage() end) }, @@ -10567,11 +10476,11 @@ initFrame:SetScript("OnEvent", function(self) { key = "externalDefensive", label = "External Defensive", tooltip = "Shows only external defensive cooldowns cast on the unit" }, { key = "bossAura", label = "Boss Auras", tooltip = "Shows only debuffs applied by bosses" }, { key = "roleAura", label = "Role Auras", tooltip = "Shows only debuffs flagged for your role" }, - { key = "priorityAura", label = "Important", tooltip = "Shows only debuffs Blizzard flags as important" }, + { key = "priorityAura", label = "Priority", tooltip = "Shows only priority debuffs" }, { key = "ownOnly", label = "Own Only", tooltip = "Shows only the Debuffs you apply" }, } BUFF_FILTER_KEYS = { ownOnly = "onlyPlayerBuffs", raidFrames = "buffRaid", raidInCombat = "buffRaidInCombat", dispellable = "buffDispellable", crowdControl = "buffCrowdControl", bigDefensive = "buffBigDefensive", externalDefensive = "buffExternalDefensive", cancelable = "buffCancelable", stealable = "buffStealable" } - DEBUFF_FILTER_KEYS = { ownOnly = "onlyPlayerDebuffs", raidFrames = "debuffRaid", raidInCombat = "debuffRaidInCombat", dispellable = "debuffDispellable", crowdControl = "debuffCrowdControl", bigDefensive = "debuffBigDefensive", externalDefensive = "debuffExternalDefensive", bossAura = "debuffBossAura", roleAura = "debuffRoleAura", priorityAura = "debuffPriorityAura", nonplayer = "debuffNonPlayer" } + DEBUFF_FILTER_KEYS = { ownOnly = "onlyPlayerDebuffs", raidFrames = "debuffRaid", raidInCombat = "debuffRaidInCombat", dispellable = "debuffDispellable", crowdControl = "debuffCrowdControl", bigDefensive = "debuffBigDefensive", externalDefensive = "debuffExternalDefensive", bossAura = "debuffBossAura", roleAura = "debuffRoleAura", priorityAura = "debuffPriorityAura" } else buffFilterItems = { { key = "raidFrames", label = "Raid Frames", tooltip = "Shows only the Buffs/Debuffs that appear on Raid Frames" }, @@ -10588,23 +10497,9 @@ initFrame:SetScript("OnEvent", function(self) -- your own debuffs to yourself); any stale onlyPlayerDebuffs value is -- ignored at runtime. if selectedUnit == "player" then - -- Player list is re-ordered: Important leads (the most common - -- pick) with the 12.1-only Non-Player Debuffs classification - -- (the negated PLAYER engine token) right below it; the rest - -- keep their relative order. On 12.0 neither key exists in - -- the list, so this reduces to the plain ownOnly trim. local trimmed = {} for _, it in ipairs(debuffFilterItems) do - if it.key == "priorityAura" then trimmed[#trimmed + 1] = it end - end - if EllesmereUI.IS_121 then - trimmed[#trimmed + 1] = { key = "nonplayer", label = "Non-Player Debuffs", - tooltip = "Shows every debuff except the ones you or your pet apply" } - end - for _, it in ipairs(debuffFilterItems) do - if it.key ~= "ownOnly" and it.key ~= "priorityAura" then - trimmed[#trimmed + 1] = it - end + if it.key ~= "ownOnly" then trimmed[#trimmed + 1] = it end end debuffFilterItems = trimmed end @@ -13332,7 +13227,7 @@ initFrame:SetScript("OnEvent", function(self) settingsTable.powerBgPowerColored = true ReloadAndUpdate(); EllesmereUI:RefreshPage() end) - bgPwrSw:HookScript("OnEnter", function() EllesmereUI.ShowWidgetTooltip(bgPwrSw, "Power Colored Background. Power colors can be adjusted in Global Settings -> Fonts & Colors.") end) + bgPwrSw:HookScript("OnEnter", function() EllesmereUI.ShowWidgetTooltip(bgPwrSw, "Power Colored Background") end) bgPwrSw:HookScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) PP.Point(bgPwrSw, "RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) rgn._lastInline = bgPwrSw @@ -13387,7 +13282,7 @@ initFrame:SetScript("OnEvent", function(self) settingsTable.powerPercentPowerColor = true ReloadAndUpdate(); EllesmereUI:RefreshPage() end) - fPwrSw:HookScript("OnEnter", function() EllesmereUI.ShowWidgetTooltip(fPwrSw, "Power Colored Fill. Power colors can be adjusted in Global Settings -> Fonts & Colors.") end) + fPwrSw:HookScript("OnEnter", function() EllesmereUI.ShowWidgetTooltip(fPwrSw, "Power Colored Fill") end) fPwrSw:HookScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) PP.Point(fPwrSw, "RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) rgn._lastInline = fPwrSw @@ -13567,36 +13462,6 @@ initFrame:SetScript("OnEvent", function(self) Upd() RegisterWidgetRefresh(Upd) end - - -- Power bar border size and color, matching Player/Target/Focus. - local pwrBorderRow - pwrBorderRow, h = W:DualRow(parent, y, - { type="slider", text="Border Size", min=0, max=4, step=1, trackWidth=120, - getValue=function() return MVal("powerBorderSize", 0) end, - setValue=function(v) MSet("powerBorderSize", v) end }, - { type="label", text="" }); y = y - h - do - local rgn = pwrBorderRow._leftRegion - local swatch, updateSwatch = EllesmereUI.BuildColorSwatch( - rgn, pwrBorderRow:GetFrameLevel() + 3, - function() - local c = MGet("powerBorderColor") or { r=0, g=0, b=0 } - return c.r, c.g, c.b, MVal("powerBorderAlpha", 1) - end, - function(r, g, b, a) - settingsTable.powerBorderColor = { r=r, g=g, b=b } - settingsTable.powerBorderAlpha = a - ReloadAndUpdate() - end, - true, 20) - PP.Point(swatch, "RIGHT", rgn._control, "LEFT", -8, 0) - swatch:SetScript("OnEnter", function() - EllesmereUI.ShowWidgetTooltip(swatch, "Border Color") - end) - swatch:SetScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) - rgn._lastInline = swatch - RegisterWidgetRefresh(updateSwatch) - end end -- Extra section rendered at the very bottom, below the Power Bar (boss "Indicators"). @@ -13977,11 +13842,6 @@ initFrame:SetScript("OnEvent", function(self) { type="slider", label="Max Count", min=1, max=20, step=1, get=function() return db.profile.boss.maxBuffs or 4 end, set=function(v) db.profile.boss.maxBuffs = v; ReloadAndUpdate(); if ns.RefreshBossPreviewDebuffs then ns.RefreshBossPreviewDebuffs() end end }, - -- Shares the boss buffMaxPerRow key with Buffs Location - -- (mutually exclusive modes), like Max Count above. - { type="slider", label="Max Per Row", min=1, max=20, step=1, - get=function() return db.profile.boss.buffMaxPerRow or db.profile.boss.maxBuffs or 4 end, - set=function(v) db.profile.boss.buffMaxPerRow = v; ReloadAndUpdate(); if ns.RefreshBossPreviewDebuffs then ns.RefreshBossPreviewDebuffs() end end }, { type="slider", label="Offset X", min=-200, max=200, step=1, get=function() local x = ns.GetBossSimpleBuffOffset(db.profile.boss); return x end, set=function(v) db.profile.boss.simpleBuffOffsetX = v; ReloadAndUpdate(); if ns.RefreshBossPreviewDebuffs then ns.RefreshBossPreviewDebuffs() end end }, @@ -14121,11 +13981,6 @@ initFrame:SetScript("OnEvent", function(self) { type="slider", label="Max Count", min=1, max=20, step=1, get=function() return db.profile.boss.maxDebuffs or 10 end, set=function(v) db.profile.boss.maxDebuffs = v; ReloadAndUpdate(); if ns.RefreshBossPreviewDebuffs then ns.RefreshBossPreviewDebuffs() end end }, - -- Shares the boss debuffMaxPerRow key with Debuffs Location - -- (mutually exclusive modes), like Max Count above. - { type="slider", label="Max Per Row", min=1, max=20, step=1, - get=function() return db.profile.boss.debuffMaxPerRow or db.profile.boss.maxDebuffs or 10 end, - set=function(v) db.profile.boss.debuffMaxPerRow = v; ReloadAndUpdate(); if ns.RefreshBossPreviewDebuffs then ns.RefreshBossPreviewDebuffs() end end }, { type="slider", label="Offset X", min=-200, max=200, step=1, get=function() local x = ns.GetBossSimpleDebuffOffset(db.profile.boss); return x end, set=function(v) db.profile.boss.simpleDebuffOffsetX = v; ReloadAndUpdate(); if ns.RefreshBossPreviewDebuffs then ns.RefreshBossPreviewDebuffs() end end }, @@ -14416,7 +14271,7 @@ initFrame:SetScript("OnEvent", function(self) { key = "externalDefensive", label = "External Defensive", tooltip = "Shows only external defensive cooldowns cast on the unit" }, { key = "bossAura", label = "Boss Auras", tooltip = "Shows only debuffs applied by bosses" }, { key = "roleAura", label = "Role Auras", tooltip = "Shows only debuffs flagged for your role" }, - { key = "priorityAura", label = "Important", tooltip = "Shows only debuffs Blizzard flags as important" }, + { key = "priorityAura", label = "Priority", tooltip = "Shows only priority debuffs" }, { key = "ownOnly", label = "Own Only", tooltip = "Shows only the Debuffs you apply" }, } DEBUFF_FILTER_KEYS = { ownOnly = "onlyPlayerDebuffs", raidFrames = "debuffRaid", raidInCombat = "debuffRaidInCombat", dispellable = "debuffDispellable", crowdControl = "debuffCrowdControl", bigDefensive = "debuffBigDefensive", externalDefensive = "debuffExternalDefensive", bossAura = "debuffBossAura", roleAura = "debuffRoleAura", priorityAura = "debuffPriorityAura" } @@ -14727,9 +14582,6 @@ initFrame:SetScript("OnEvent", function(self) { type="slider", label="Max Count", min=1, max=20, step=1, get=function() return db.profile.boss.maxBuffs or 4 end, set=function(v) db.profile.boss.maxBuffs = v; ReloadAndUpdate() end }, - { type="slider", label="Max Per Row", min=1, max=20, step=1, - get=function() return db.profile.boss.buffMaxPerRow or db.profile.boss.maxBuffs or 4 end, - set=function(v) db.profile.boss.buffMaxPerRow = v; ReloadAndUpdate() end }, }, }) local cogBtn = BossCogBtn(leftRgn, bBuffCogShowRaw) @@ -14773,9 +14625,6 @@ initFrame:SetScript("OnEvent", function(self) { type="slider", label="Max Count", min=1, max=20, step=1, get=function() return db.profile.boss.maxDebuffs or 10 end, set=function(v) db.profile.boss.maxDebuffs = v; ReloadAndUpdate() end }, - { type="slider", label="Max Per Row", min=1, max=20, step=1, - get=function() return db.profile.boss.debuffMaxPerRow or db.profile.boss.maxDebuffs or 10 end, - set=function(v) db.profile.boss.debuffMaxPerRow = v; ReloadAndUpdate() end }, }, }) local cogBtn = BossCogBtn(rightRgn, bDebuffCogShowRaw) @@ -14922,10 +14771,10 @@ initFrame:SetScript("OnEvent", function(self) -- greys a region (its slider/dropdown/toggle plus any inline swatch or -- cog) to 0.3 and drops an invisible mouse-blocker over it while the -- cast bar is off, tracking the toggle live via the widget-refresh fast - -- path. Mirrors AddDarkModeBlock. The Show Cast Bar toggle's own color - -- swatches are gated on their own so the toggle itself stays interactive. - local castColorSwatches = {} - local function AddCastBlock(rgn, enabledAlpha) + -- path. Mirrors AddDarkModeBlock. The Show Cast Bar toggle's own fill + -- swatch is gated on its own so the toggle itself stays interactive. + local castFillSwatch + local function AddCastBlock(rgn) if not rgn then return end local block = CreateFrame("Frame", nil, rgn) block:SetAllPoints() @@ -14939,7 +14788,7 @@ initFrame:SetScript("OnEvent", function(self) if B.showCastbar == false then rgn:SetAlpha(0.3); block:Show() else - rgn:SetAlpha(enabledAlpha and enabledAlpha() or 1); block:Hide() + rgn:SetAlpha(1); block:Hide() end end Update() @@ -14962,69 +14811,25 @@ initFrame:SetScript("OnEvent", function(self) disabledTooltip="Show Cast Bar", getValue=function() return B.castbarHeight or 14 end, setValue=function(v) B.castbarHeight = v; ReloadAndUpdate() end }); yy = yy - hh - -- Enemy cast colors on Show Cast Bar (left region), matching - -- target/focus. + -- Inline fill-color swatch on Show Cast Bar (left region). do local rgn = castMainRow._leftRegion - local function AddCastColorSwatch(tooltip, colorKey, fallback, disabledFn) - local sw, updateSw = EllesmereUI.BuildColorSwatch(rgn, rgn:GetFrameLevel() + 5, - function() - local c = B[colorKey] or fallback - return c.r, c.g, c.b, 1 - end, - function(r, g, b) - B[colorKey] = { r=r, g=g, b=b } - ReloadAndUpdate() - end, false, 20) - sw:SetPoint("RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) - sw:SetScript("OnEnter", function(self) EllesmereUI.ShowWidgetTooltip(self, tooltip) end) - sw:SetScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) - rgn._lastInline = sw - castColorSwatches[#castColorSwatches + 1] = { sw = sw, enabledAlpha = disabledFn - and function() return disabledFn() and 0.3 or 1 end } - if disabledFn then - local function Update() - local off = disabledFn() - sw:SetAlpha(off and 0.3 or 1) - sw:EnableMouse(not off) - if updateSw then updateSw() end - end - Update() - EllesmereUI.RegisterWidgetRefresh(Update) - end - end - AddCastColorSwatch("Interrupt Ready Mid-Cast", "castbarInterruptMidCastColor", - { r=0.318, g=0.820, b=0.357 }, - function() return B.castbarInterruptMidCastEnabled ~= true end) - AddCastColorSwatch("Interrupt on CD", "castbarInterruptReadyColor", { r=0.92, g=0.35, b=0.20 }) - AddCastColorSwatch("Uninterruptible Cast", "castbarUninterruptibleColor", { r=0.5, g=0.5, b=0.5 }) - AddCastColorSwatch("Interruptible Cast", "castbarFillColor", { r=0.863, g=0.820, b=0.639 }) + local sw = EllesmereUI.BuildColorSwatch(rgn, rgn:GetFrameLevel() + 5, + function() local c = B.castbarFillColor or { r=0.863, g=0.820, b=0.639 }; return c.r, c.g, c.b end, + function(r, g, b) B.castbarFillColor = { r=r, g=g, b=b }; ReloadAndUpdate() end, false, 20) + sw:SetPoint("RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) + sw:SetScript("OnEnter", function(self) EllesmereUI.ShowWidgetTooltip(self, "Fill Color") end) + sw:SetScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) + rgn._lastInline = sw + castFillSwatch = sw end - -- Inline settings cog matching target/focus, plus boss positioning. + -- Inline cog on Show Cast Bar (left region): Offset X/Y nudge the whole + -- cast bar (positive = right/up). Updates the live frames + both + -- previews via ReloadAndUpdate + the boss preview refresh. do - local _, cogShow = EllesmereUI.BuildCogPopup({ - title = "Cast Bar", + local _, offCogShow = EllesmereUI.BuildCogPopup({ + title = "Cast Bar Position", rows = { - { type="toggle", label="Hide When Idle", - tooltip="Only show the cast bar while a cast is in progress; hide it the rest of the time.", - get=function() return B.castbarHideWhenInactive ~= false end, - set=function(v) B.castbarHideWhenInactive = v; ReloadAndUpdate() end }, - { type="slider", label="Fill Opacity", min=0, max=100, step=1, - tooltip="Opacity of the cast bar fill; below 100 the world shows through the fill instead of the background.", - get=function() return B.castFillOpacity or 100 end, - set=function(v) B.castFillOpacity = v; ReloadAndUpdate() end }, - { type="toggle", label="Raise Cast Bar Strata (All)", - tooltip="Lifts player, target, focus, and boss cast bars above other frames so they are never hidden behind them.", - get=function() return db.profile.raiseCastbarStrata ~= false end, - set=function(v) db.profile.raiseCastbarStrata = v; ReloadAndUpdate() end }, - { type="toggle", label="Show Kick Ready Mid-Cast Tick", - tooltip="Shows a small white tick mark where the cast will be when your interrupt comes off cooldown.", - get=function() return B.castbarKickTickEnabled ~= false end, - set=function(v) B.castbarKickTickEnabled = v; ReloadAndUpdate() end }, - { type="toggle", label="Show Kick Ready Mid-Cast Bar", - tooltip="Colors the cast segment during which your interrupt will be available.", - get=function() return B.castbarInterruptMidCastEnabled == true end, - set=function(v) B.castbarInterruptMidCastEnabled = v; ReloadAndUpdate(); EllesmereUI:RefreshPage() end }, { type="slider", label="Offset X", min=-500, max=500, step=1, get=function() return B.castbarOffsetX or 0 end, set=function(v) B.castbarOffsetX = v; ReloadAndUpdate(); if ns.RefreshBossPreviewDebuffs then ns.RefreshBossPreviewDebuffs() end end }, @@ -15033,7 +14838,7 @@ initFrame:SetScript("OnEvent", function(self) set=function(v) B.castbarOffsetY = v; ReloadAndUpdate(); if ns.RefreshBossPreviewDebuffs then ns.RefreshBossPreviewDebuffs() end end }, }, }) - AddCastBlock(CCogBtn(castMainRow._leftRegion, cogShow)) + AddCastBlock(CCogBtn(castMainRow._leftRegion, offCogShow, EllesmereUI.DIRECTIONS_ICON)) end -- Rows 2-4 are HIDDEN entirely while Show Cast Bar is off (the @@ -15181,10 +14986,10 @@ initFrame:SetScript("OnEvent", function(self) end -- close boss Cast Bar hidden-while-disabled gate - -- The Show Cast Bar toggle's own color swatches stay gated grey + + -- The Show Cast Bar toggle's own fill swatch stays gated grey + -- blocked while the cast bar is off (the Height slider uses a -- native disabled state; the hidden rows need nothing). - for _, item in ipairs(castColorSwatches) do AddCastBlock(item.sw, item.enabledAlpha) end + if castFillSwatch then AddCastBlock(castFillSwatch) end return yy end @@ -15676,16 +15481,10 @@ initFrame:SetScript("OnEvent", function(self) local ufSearchTerms = {} for _, label in pairs(unitLabels) do ufSearchTerms[#ufSearchTerms + 1] = label end for _, label in pairs(miniUnitLabels) do ufSearchTerms[#ufSearchTerms + 1] = label end - -- "external defensives" stays on both clients: the External Defensive AURA - -- FILTER checkbox keeps that name. Only the two terms that exist purely to - -- find the retired External Defensives FRAME are dropped on 12.1, so a - -- search there cannot land on a section that no longer builds. - local _paTerms = { "buff", "debuff", "aura", "player buffs", "player debuffs", "icon zoom", "private auras", "external defensives" } - if not EllesmereUI.IS_121 then - _paTerms[#_paTerms + 1] = "externals" - _paTerms[#_paTerms + 1] = "pain suppression" - end + local _paTerms = { "external defensives", "externals", "pain suppression" } for _, t in ipairs(_paTerms) do ufSearchTerms[#ufSearchTerms + 1] = t end + local _pabTerms = { "aura bars", "player aura bars", "buff bar", "debuff bar", "cooldown bars", "dispel colors", "grow direction" } + for _, t in ipairs(_pabTerms) do ufSearchTerms[#ufSearchTerms + 1] = t end -- Rebuild preview when spec changes (class resource pips may appear/disappear) local ufOptSpecFrame = CreateFrame("Frame") @@ -15704,547 +15503,50 @@ initFrame:SetScript("OnEvent", function(self) end) --------------------------------------------------------------------------- - -- Player Buffs & Debuffs page + -- Player Aura Bars page (External Defensives now lives inside it, as a + -- third built-in bar -- see EUI_PlayerAuraBars_ManagerPages.lua) --------------------------------------------------------------------------- - local function BuildPlayerAurasPage(pageName, parent, yOffset) - local W = EllesmereUI.Widgets - local y = yOffset - local _, h - - local function PAGet(key) - local p = db and db.profile and db.profile.playerAuras - return p and p[key] - end - local function PASet(key, v) - if not db or not db.profile then return end - if not db.profile.playerAuras then db.profile.playerAuras = {} end - db.profile.playerAuras[key] = v - if ns.RefreshPlayerAuras then ns.RefreshPlayerAuras() end - if ns.ApplyPlayerAuraScale then ns.ApplyPlayerAuraScale() end - end - - _, h = W:Spacer(parent, y, 20); y = y - h - _, h = W:SectionHeader(parent, "PLAYER BUFFS & DEBUFFS", y); y = y - h - - parent._showRowDivider = true - - -- Row 1: Enable Styled Buffs & Debuffs | Icon Size - local function PAOff() return not PAGet("enabled") end - local paRow1 - paRow1, h = W:DualRow(parent, y, - { type = "toggle", text = "Enable Styled Buffs & Debuffs", - setValue = EllesmereUI.SectionToggleSetValue(function(v) - PASet("enabled", v) - EllesmereUI:ShowConfirmPopup({ - title = "Reload Required", - message = "This change requires a UI reload to take effect.", - confirmText = "Reload Now", - cancelText = "Later", - onConfirm = function() ReloadUI() end, - }) - end), - getValue = function() return PAGet("enabled") or false end }, - { type = "slider", text = "Icon Size", min = 16, max = 60, step = 1, - disabled = PAOff, disabledTooltip = "Enable Styled Buffs & Debuffs first", rawTooltip = true, - getValue = function() return PAGet("iconSize") or 32 end, - setValue = function(v) PASet("iconSize", v) end } - ); y = y - h - - -- Inline cog: Icon Zoom (next to "Icon Size"). Buffs and debuffs - -- crop independently. - do - local rgn = paRow1._rightRegion - local _, cogShow = EllesmereUI.BuildCogPopup({ - title = "Icon Zoom", - rows = { - { type = "slider", label = "Buff Zoom", min = 0, max = 0.20, step = 0.01, - get = function() return PAGet("buffIconZoom") or 0.055 end, - set = function(v) PASet("buffIconZoom", v) end }, - { type = "slider", label = "Debuff Zoom", min = 0, max = 0.20, step = 0.01, - get = function() return PAGet("debuffIconZoom") or 0.055 end, - set = function(v) PASet("debuffIconZoom", v) end }, - }, - }) - local cogBtn = CreateFrame("Button", nil, rgn) - cogBtn:SetSize(26, 26) - cogBtn:SetPoint("RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) - rgn._lastInline = cogBtn - cogBtn:SetFrameLevel(rgn:GetFrameLevel() + 5) - cogBtn:SetAlpha(PAOff() and 0.15 or 0.4) - local cogTex = cogBtn:CreateTexture(nil, "OVERLAY") - cogTex:SetAllPoints() - cogTex:SetTexture(EllesmereUI.COGS_ICON) - cogBtn:SetScript("OnEnter", function(self) self:SetAlpha(0.7) end) - cogBtn:SetScript("OnLeave", function(self) self:SetAlpha(PAOff() and 0.15 or 0.4) end) - cogBtn:SetScript("OnClick", function(self) cogShow(self) end) - local cogBlock = CreateFrame("Frame", nil, cogBtn) - cogBlock:SetAllPoints() - cogBlock:SetFrameLevel(cogBtn:GetFrameLevel() + 10) - cogBlock:EnableMouse(true) - cogBlock:SetScript("OnEnter", function() - EllesmereUI.ShowWidgetTooltip(cogBtn, EllesmereUI.DisabledTooltip("Enable Styled Buffs & Debuffs")) - end) - cogBlock:SetScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) - EllesmereUI.RegisterWidgetRefresh(function() - local off = PAOff() - cogBtn:SetAlpha(off and 0.15 or 0.4) - if off then cogBlock:Show() else cogBlock:Hide() end - end) - if PAOff() then cogBlock:Show() else cogBlock:Hide() end - end - - -- Rows 2-4 are hidden entirely while the section is disabled (the - -- enable toggle's SectionToggleSetValue wrapper forces the rebuild). - if PAGet("enabled") then - - -- Row 2: Show Text | Text Size (+ inline Duration Format cog) - local paRow2 - paRow2, h = W:DualRow(parent, y, - { type = "toggle", text = "Show Text", - getValue = function() return PAGet("showText") ~= false end, - setValue = function(v) PASet("showText", v) end }, - { type = "slider", text = "Text Size", min = 6, max = 24, step = 1, - getValue = function() return PAGet("textSize") or 11 end, - setValue = function(v) PASet("textSize", v) end } - ); y = y - h - - -- Inline cog: Duration Format (next to "Text Size"). - do - local rgn = paRow2._rightRegion - local _, cogShow = EllesmereUI.BuildCogPopup({ - title = "Duration Format", - rows = { - { type = "dropdown", label = "Format", - values = { - blizzard = { text = "Blizzard Default (2 min)" }, - compact = { text = "Standard (5m / 32)" }, - colon = { text = "Colon (5:32)" }, - seconds = { text = "Seconds (152)" }, - }, - order = { "blizzard", "compact", "colon", "seconds" }, - get = function() return PAGet("durationFormat") or "blizzard" end, - set = function(v) - -- Custom formats re-enter the per-frame UpdateDuration - -- hook path (per visible aura, every render frame), so - -- leaving the free Blizzard default gets the suite's - -- standard performance confirm, once per account. - local cur = PAGet("durationFormat") or "blizzard" - if v ~= "blizzard" and cur == "blizzard" - and not (EllesmereUIDB and EllesmereUIDB.dismissedDurFmtWarning) then - EllesmereUI:ShowConfirmPopup({ - title = "Custom Duration Format", - message = "Custom duration formats may cause a slight loss in performance efficiency. Do you want to enable them?", - confirmText = "Enable", - cancelText = "Cancel", - onConfirm = function() - if not EllesmereUIDB then EllesmereUIDB = {} end - EllesmereUIDB.dismissedDurFmtWarning = true - PASet("durationFormat", v) - if EllesmereUI.RefreshPage then EllesmereUI:RefreshPage(true) end - end, - onCancel = function() - if EllesmereUI.RefreshPage then EllesmereUI:RefreshPage() end - end, - }) - return - end - PASet("durationFormat", v) - end }, - }, - }) - local cogBtn = CreateFrame("Button", nil, rgn) - cogBtn:SetSize(26, 26) - cogBtn:SetPoint("RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) - rgn._lastInline = cogBtn - cogBtn:SetFrameLevel(rgn:GetFrameLevel() + 5) - cogBtn:SetAlpha(0.4) - local cogTex = cogBtn:CreateTexture(nil, "OVERLAY") - cogTex:SetAllPoints() - cogTex:SetTexture(EllesmereUI.COGS_ICON) - cogBtn:SetScript("OnEnter", function(self) self:SetAlpha(0.7) end) - cogBtn:SetScript("OnLeave", function(self) self:SetAlpha(0.4) end) - cogBtn:SetScript("OnClick", function(self) cogShow(self) end) - end - - -- Row 3: Border Style | Border Size (+ inline color swatch) - do - local texValues, texOrder = EllesmereUI.GetBorderTextureDropdown() - local borderSizeValues = { - none = "None", thin = "Thin", normal = "Normal", - heavy = "Heavy", strong = "Strong", - } - local borderSizeOrder = { "none", "thin", "normal", "heavy", "strong" } - local borderSizeToNumber = { none = 0, thin = 1, normal = 2, heavy = 3, strong = 4 } - local borderNumberToSize = { [0] = "none", [1] = "thin", [2] = "normal", [3] = "heavy", [4] = "strong" } - local bsRow - bsRow, h = W:DualRow(parent, y, - { type = "dropdown", text = "Border Style", - values = texValues, order = texOrder, - getValue = function() return PAGet("borderTexture") or "solid" end, - setValue = function(v) - local color, behind = EllesmereUI.GetBorderStyleSelectDefaults(v) - PASet("borderTexture", v) - PASet("borderTextureOffset", nil); PASet("borderTextureOffsetY", nil) - PASet("borderTextureShiftX", nil); PASet("borderTextureShiftY", nil) - PASet("borderBehind", behind) - PASet("borderR", color.r); PASet("borderG", color.g); PASet("borderB", color.b) - PASet("borderA", 1) - local defSz = EllesmereUI.GetBorderDefaultSize("unitframes", v) - if defSz then PASet("borderSize", defSz) end - end }, - { type = "dropdown", text = "Border Size", - values = borderSizeValues, order = borderSizeOrder, - getValue = function() - return borderNumberToSize[PAGet("borderSize") or 1] or "thin" - end, - setValue = function(v) PASet("borderSize", borderSizeToNumber[v] or 1) end } - ); y = y - h - - -- Textured-border offset and layer controls. - do - local rgn = bsRow._leftRegion - local _, cogShow = EllesmereUI.BuildCogPopup({ - title = "Border Offset", - rows = { - { type = "slider", label = "Offset X", min = -10, max = 10, step = 1, - get = function() - local v = PAGet("borderTextureOffset") - if v ~= nil then return v end - return EllesmereUI.GetBorderDefaults("unitframes", PAGet("borderTexture") or "solid", PAGet("borderSize") or 1) - end, - set = function(v) PASet("borderTextureOffset", v) end }, - { type = "slider", label = "Offset Y", min = -10, max = 10, step = 1, - get = function() - local v = PAGet("borderTextureOffsetY") - if v ~= nil then return v end - local _, oy = EllesmereUI.GetBorderDefaults("unitframes", PAGet("borderTexture") or "solid", PAGet("borderSize") or 1) - return oy - end, - set = function(v) PASet("borderTextureOffsetY", v) end }, - { type = "slider", label = "Shift X", min = -10, max = 10, step = 1, - get = function() - local v = PAGet("borderTextureShiftX") - if v ~= nil then return v end - local _, _, sx = EllesmereUI.GetBorderDefaults("unitframes", PAGet("borderTexture") or "solid", PAGet("borderSize") or 1) - return sx - end, - set = function(v) PASet("borderTextureShiftX", v == 0 and nil or v) end }, - { type = "slider", label = "Shift Y", min = -10, max = 10, step = 1, - get = function() - local v = PAGet("borderTextureShiftY") - if v ~= nil then return v end - local _, _, _, sy = EllesmereUI.GetBorderDefaults("unitframes", PAGet("borderTexture") or "solid", PAGet("borderSize") or 1) - return sy - end, - set = function(v) PASet("borderTextureShiftY", v == 0 and nil or v) end }, - { type = "toggle", label = "Show Behind", - get = function() return PAGet("borderBehind") or false end, - set = function(v) PASet("borderBehind", v) end }, - }, - }) - local cogBtn = CreateFrame("Button", nil, rgn) - cogBtn:SetSize(26, 26) - cogBtn:SetPoint("RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) - rgn._lastInline = cogBtn - cogBtn:SetFrameLevel(rgn:GetFrameLevel() + 5) - cogBtn:SetAlpha(0.4) - local cogTex = cogBtn:CreateTexture(nil, "OVERLAY") - cogTex:SetAllPoints() - cogTex:SetTexture(EllesmereUI.DIRECTIONS_ICON) - cogBtn:SetScript("OnEnter", function(self) self:SetAlpha(0.7) end) - cogBtn:SetScript("OnLeave", function(self) self:SetAlpha(0.4) end) - cogBtn:SetScript("OnClick", function(self) cogShow(self) end) - local function UpdateCogVis() - if (PAGet("borderTexture") or "solid") == "solid" then cogBtn:Hide() else cogBtn:Show() end - end - EllesmereUI.RegisterWidgetRefresh(UpdateCogVis) - UpdateCogVis() - end - - -- Inline border color swatch on Border Size dropdown - do - local rgn = bsRow._rightRegion - local borderSwatch, updateBorderSwatch = EllesmereUI.BuildColorSwatch( - rgn, bsRow:GetFrameLevel() + 3, - function() - return (PAGet("borderR") or 0), (PAGet("borderG") or 0), - (PAGet("borderB") or 0), (PAGet("borderA") or 1) - end, - function(r, g, b, a) - PASet("borderR", r); PASet("borderG", g); PASet("borderB", b); PASet("borderA", a) - end, - true, 20) - PP.Point(borderSwatch, "RIGHT", rgn._control, "LEFT", -8, 0) - -- Disable swatch when border size is 0 - local borderSwatchBlock = CreateFrame("Frame", nil, borderSwatch) - borderSwatchBlock:SetAllPoints() - borderSwatchBlock:SetFrameLevel(borderSwatch:GetFrameLevel() + 10) - borderSwatchBlock:EnableMouse(true) - borderSwatchBlock:SetScript("OnEnter", function() - EllesmereUI.ShowWidgetTooltip(borderSwatch, EllesmereUI.DisabledTooltip("This option requires a Border Size above 0.")) - end) - borderSwatchBlock:SetScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) - local function UpdateBorderSwatchState() - local noBorder = (PAGet("borderSize") or 0) == 0 - if noBorder then borderSwatch:SetAlpha(0.3); borderSwatchBlock:Show() - else borderSwatch:SetAlpha(1); borderSwatchBlock:Hide() end - end - EllesmereUI.RegisterWidgetRefresh(function() updateBorderSwatch(); UpdateBorderSwatchState() end) - UpdateBorderSwatchState() - end - end - - -- Row 4: Blizzard debuff borders | buff-frame expand button. - _, h = W:DualRow(parent, y, - { type = "toggle", text = "No Border on Debuffs", - tooltip = "When enabled, debuff icons keep Blizzard's colored border instead of using the custom border.", - getValue = function() return PAGet("noBorderDebuffs") ~= false end, - setValue = function(v) PASet("noBorderDebuffs", v) end }, - { type = "toggle", text = "Show Expand Button", - getValue = function() return PAGet("showExpandButton") ~= false end, - setValue = function(v) PASet("showExpandButton", v) end } - ); y = y - h - - end -- PAGet("enabled") section gate - - ----------------------------------------------------------------------- - -- External Defensives Frame (our own frame; live enable, no reload) - ----------------------------------------------------------------------- - -- 12.1 retires this frame, so the whole block (spacer and section - -- header included) is skipped there and nothing renders. The body is - -- left unindented so retail stays a byte-for-byte identical diff. - if not EllesmereUI.IS_121 then - local function EDGet(key) - local p = db and db.profile and db.profile.externalDefensives - return p and p[key] - end - local function EDSet(key, v) - if not db or not db.profile then return end - if not db.profile.externalDefensives then db.profile.externalDefensives = {} end - db.profile.externalDefensives[key] = v - if ns.RefreshExternalDefensives then ns.RefreshExternalDefensives() end - end - - _, h = W:Spacer(parent, y, 20); y = y - h - _, h = W:SectionHeader(parent, "EXTERNAL DEFENSIVES FRAME", y); y = y - h - - -- Row 1: Enable | Icon Size (+ Icon Zoom cog) - local function EDOff() return not EDGet("enabled") end - local edRow1 - edRow1, h = W:DualRow(parent, y, - { type = "toggle", text = "Enable External Defensives Frame", - tooltip = "Shows external defensives cast on you (Pain Suppression, Ironbark, etc.) in its own movable frame; position it via Unlock Mode.", - getValue = function() return EDGet("enabled") or false end, - setValue = EllesmereUI.SectionToggleSetValue(function(v) EDSet("enabled", v) end) }, - { type = "slider", text = "Icon Size", min = 16, max = 60, step = 1, - disabled = EDOff, disabledTooltip = "Enable External Defensives Frame first", rawTooltip = true, - getValue = function() return EDGet("iconSize") or 32 end, - setValue = function(v) EDSet("iconSize", v) end } - ); y = y - h - do - local rgn = edRow1._rightRegion - local _, cogShow = EllesmereUI.BuildCogPopup({ - title = "Icon Zoom", - rows = { - { type = "slider", label = "Icon Zoom", min = 0, max = 0.20, step = 0.01, - get = function() return EDGet("iconZoom") or 0.055 end, - set = function(v) EDSet("iconZoom", v) end }, - }, - }) - local cogBtn = CreateFrame("Button", nil, rgn) - cogBtn:SetSize(26, 26) - cogBtn:SetPoint("RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) - rgn._lastInline = cogBtn - cogBtn:SetFrameLevel(rgn:GetFrameLevel() + 5) - cogBtn:SetAlpha(EDOff() and 0.15 or 0.4) - local cogTex = cogBtn:CreateTexture(nil, "OVERLAY") - cogTex:SetAllPoints() - cogTex:SetTexture(EllesmereUI.COGS_ICON) - cogBtn:SetScript("OnEnter", function(self) self:SetAlpha(0.7) end) - cogBtn:SetScript("OnLeave", function(self) self:SetAlpha(EDOff() and 0.15 or 0.4) end) - cogBtn:SetScript("OnClick", function(self) cogShow(self) end) - local cogBlock = CreateFrame("Frame", nil, cogBtn) - cogBlock:SetAllPoints() - cogBlock:SetFrameLevel(cogBtn:GetFrameLevel() + 10) - cogBlock:EnableMouse(true) - cogBlock:SetScript("OnEnter", function() - EllesmereUI.ShowWidgetTooltip(cogBtn, EllesmereUI.DisabledTooltip("Enable External Defensives Frame")) - end) - cogBlock:SetScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) - EllesmereUI.RegisterWidgetRefresh(function() - local off = EDOff() - cogBtn:SetAlpha(off and 0.15 or 0.4) - if off then cogBlock:Show() else cogBlock:Hide() end - end) - if EDOff() then cogBlock:Show() else cogBlock:Hide() end - end - - -- Rows 2-4 are hidden entirely while the section is disabled (the - -- enable toggle's SectionToggleSetValue wrapper forces the rebuild). - if EDGet("enabled") then - - -- Row 2: Show Text | Text Size (+ Duration Format cog) - local edRow2 - edRow2, h = W:DualRow(parent, y, - { type = "toggle", text = "Show Text", - getValue = function() return EDGet("showText") ~= false end, - setValue = function(v) EDSet("showText", v) end }, - { type = "slider", text = "Text Size", min = 6, max = 24, step = 1, - getValue = function() return EDGet("textSize") or 11 end, - setValue = function(v) EDSet("textSize", v) end } - ); y = y - h - do - local rgn = edRow2._rightRegion - local _, cogShow = EllesmereUI.BuildCogPopup({ - title = "Duration Format", - rows = { - { type = "dropdown", label = "Format", - values = { - blizzard = { text = "Blizzard Default (2 min)" }, - compact = { text = "Standard (5m / 32)" }, - colon = { text = "Colon (5:32)" }, - seconds = { text = "Seconds (152)" }, - }, - order = { "blizzard", "compact", "colon", "seconds" }, - get = function() return EDGet("durationFormat") or "blizzard" end, - set = function(v) EDSet("durationFormat", v) end }, - }, - }) - local cogBtn = CreateFrame("Button", nil, rgn) - cogBtn:SetSize(26, 26) - cogBtn:SetPoint("RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) - rgn._lastInline = cogBtn - cogBtn:SetFrameLevel(rgn:GetFrameLevel() + 5) - cogBtn:SetAlpha(0.4) - local cogTex = cogBtn:CreateTexture(nil, "OVERLAY") - cogTex:SetAllPoints() - cogTex:SetTexture(EllesmereUI.COGS_ICON) - cogBtn:SetScript("OnEnter", function(self) self:SetAlpha(0.7) end) - cogBtn:SetScript("OnLeave", function(self) self:SetAlpha(0.4) end) - cogBtn:SetScript("OnClick", function(self) cogShow(self) end) - end - - -- Row 3: Border Style (+ offset cog) | Border Size (+ color swatch) - do - local texValues, texOrder = EllesmereUI.GetBorderTextureDropdown() - local borderSizeValues = { none="None", thin="Thin", normal="Normal", heavy="Heavy", strong="Strong" } - local borderSizeOrder = { "none", "thin", "normal", "heavy", "strong" } - local sizeToNumber = { none=0, thin=1, normal=2, heavy=3, strong=4 } - local numberToSize = { [0]="none", [1]="thin", [2]="normal", [3]="heavy", [4]="strong" } - local edBsRow - edBsRow, h = W:DualRow(parent, y, - { type="dropdown", text="Border Style", values=texValues, order=texOrder, - getValue=function() return EDGet("borderTexture") or "solid" end, - setValue=function(v) - local color, behind = EllesmereUI.GetBorderStyleSelectDefaults(v) - local p = db.profile.externalDefensives - p.borderTexture = v - p.borderTextureOffset = nil; p.borderTextureOffsetY = nil - p.borderTextureShiftX = nil; p.borderTextureShiftY = nil - p.borderBehind = behind - p.borderR = color.r; p.borderG = color.g; p.borderB = color.b; p.borderA = 1 - local defSz = EllesmereUI.GetBorderDefaultSize("unitframes", v) - if defSz then p.borderSize = defSz end - if ns.RefreshExternalDefensives then ns.RefreshExternalDefensives() end - end }, - { type="dropdown", text="Border Size", values=borderSizeValues, order=borderSizeOrder, - getValue=function() return numberToSize[EDGet("borderSize") or 1] or "thin" end, - setValue=function(v) EDSet("borderSize", sizeToNumber[v] or 1) end } - ); y = y - h - - do - local rgn = edBsRow._leftRegion - local _, cogShow = EllesmereUI.BuildCogPopup({ - title="Border Offset", - rows={ - { type="slider", label="Offset X", min=-10, max=10, step=1, - get=function() - local v=EDGet("borderTextureOffset"); if v ~= nil then return v end - return EllesmereUI.GetBorderDefaults("unitframes", EDGet("borderTexture") or "solid", EDGet("borderSize") or 1) - end, set=function(v) EDSet("borderTextureOffset", v) end }, - { type="slider", label="Offset Y", min=-10, max=10, step=1, - get=function() - local v=EDGet("borderTextureOffsetY"); if v ~= nil then return v end - local _,oy=EllesmereUI.GetBorderDefaults("unitframes", EDGet("borderTexture") or "solid", EDGet("borderSize") or 1); return oy - end, set=function(v) EDSet("borderTextureOffsetY", v) end }, - { type="slider", label="Shift X", min=-10, max=10, step=1, - get=function() - local v=EDGet("borderTextureShiftX"); if v ~= nil then return v end - local _,_,sx=EllesmereUI.GetBorderDefaults("unitframes", EDGet("borderTexture") or "solid", EDGet("borderSize") or 1); return sx - end, set=function(v) EDSet("borderTextureShiftX", v == 0 and nil or v) end }, - { type="slider", label="Shift Y", min=-10, max=10, step=1, - get=function() - local v=EDGet("borderTextureShiftY"); if v ~= nil then return v end - local _,_,_,sy=EllesmereUI.GetBorderDefaults("unitframes", EDGet("borderTexture") or "solid", EDGet("borderSize") or 1); return sy - end, set=function(v) EDSet("borderTextureShiftY", v == 0 and nil or v) end }, - { type="toggle", label="Show Behind", - get=function() return EDGet("borderBehind") or false end, - set=function(v) EDSet("borderBehind", v) end }, - }, - }) - local cogBtn = CreateFrame("Button", nil, rgn) - cogBtn:SetSize(26,26); cogBtn:SetPoint("RIGHT", rgn._lastInline or rgn._control, "LEFT", -8,0) - rgn._lastInline=cogBtn; cogBtn:SetFrameLevel(rgn:GetFrameLevel()+5); cogBtn:SetAlpha(0.4) - local tex=cogBtn:CreateTexture(nil,"OVERLAY"); tex:SetAllPoints(); tex:SetTexture(EllesmereUI.DIRECTIONS_ICON) - cogBtn:SetScript("OnEnter",function(self) self:SetAlpha(0.7) end) - cogBtn:SetScript("OnLeave",function(self) self:SetAlpha(0.4) end) - cogBtn:SetScript("OnClick",function(self) cogShow(self) end) - local function UpdateCogVis() if (EDGet("borderTexture") or "solid") == "solid" then cogBtn:Hide() else cogBtn:Show() end end - EllesmereUI.RegisterWidgetRefresh(UpdateCogVis); UpdateCogVis() - end - do - local rgn = edBsRow._rightRegion - local borderSwatch, updateBorderSwatch = EllesmereUI.BuildColorSwatch( - rgn, edBsRow:GetFrameLevel() + 3, - function() - return (EDGet("borderR") or 0), (EDGet("borderG") or 0), - (EDGet("borderB") or 0), (EDGet("borderA") or 1) - end, - function(r, g, b, a) - EDSet("borderR", r); EDSet("borderG", g); EDSet("borderB", b); EDSet("borderA", a) - end, - true, 20) - PP.Point(borderSwatch, "RIGHT", rgn._control, "LEFT", -8, 0) - local borderSwatchBlock = CreateFrame("Frame", nil, borderSwatch) - borderSwatchBlock:SetAllPoints() - borderSwatchBlock:SetFrameLevel(borderSwatch:GetFrameLevel() + 10) - borderSwatchBlock:EnableMouse(true) - borderSwatchBlock:SetScript("OnEnter", function() - EllesmereUI.ShowWidgetTooltip(borderSwatch, EllesmereUI.DisabledTooltip("This option requires a Border Size above 0.")) - end) - borderSwatchBlock:SetScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) - local function UpdateEDBorderSwatchState() - local noBorder = (EDGet("borderSize") or 0) == 0 - if noBorder then borderSwatch:SetAlpha(0.3); borderSwatchBlock:Show() - else borderSwatch:SetAlpha(1); borderSwatchBlock:Hide() end - end - EllesmereUI.RegisterWidgetRefresh(function() updateBorderSwatch(); UpdateEDBorderSwatchState() end) - UpdateEDBorderSwatchState() - end - end - - -- Row 4: icon growth direction. - _, h = W:DualRow(parent, y, - { type = "dropdown", text = "Growth Direction", - tooltip = "Which direction new icons extend from the frame's edge.", - values = { right = "Right", left = "Left" }, order = { "right", "left" }, - getValue = function() return EDGet("growDirection") or "right" end, - setValue = function(v) EDSet("growDirection", v) end }, - { type = "label", text = "" } - ); y = y - h - - end -- EDGet("enabled") section gate - end -- not IS_121: External Defensives Frame retired on 12.1 - - return math.abs(y) + -- Local cog-button helper (mirrors the shared MakeCogBtn pattern used + -- elsewhere, but that helper is scoped to BuildSharedSettings only). + local function PAMakeCogBtn(rgn, showFn) + local cogBtn = CreateFrame("Button", nil, rgn) + cogBtn:SetSize(26, 26) + cogBtn:SetPoint("RIGHT", rgn._lastInline or rgn._control, "LEFT", -8, 0) + rgn._lastInline = cogBtn + cogBtn:SetFrameLevel(rgn:GetFrameLevel() + 5) + local cogTex = cogBtn:CreateTexture(nil, "OVERLAY") + cogTex:SetAllPoints() + cogTex:SetTexture(EllesmereUI.COGS_ICON) + cogBtn:SetScript("OnEnter", function(self) self:SetAlpha(0.7) end) + cogBtn:SetScript("OnLeave", function(self) self:SetAlpha(0.4) end) + cogBtn:SetScript("OnClick", function(self) showFn(self) end) + cogBtn:SetAlpha(0.4) + return cogBtn end + ns._PAMakeCogBtn = PAMakeCogBtn -- bridge for EUI_PlayerAuraBars_ManagerPages.lua EllesmereUI:RegisterModule("EllesmereUIUnitFrames", { - title = "Unit Frames", - description = "Configure unit frame appearance and behavior.", - pages = { PAGE_DISPLAY, PAGE_BOSS, PAGE_MINI, PAGE_AURAS }, - searchTerms = ufSearchTerms, - buildPage = function(pageName, parent, yOffset) + title = "Unit Frames", + description = "Configure unit frame appearance and behavior.", + pages = { PAGE_DISPLAY, PAGE_BOSS, PAGE_MINI, PAGE_AURA_BARS }, + searchTerms = ufSearchTerms, + buildPage = function(pageName, parent, yOffset) + if EllesmereUI._prebuilding and pageName == PAGE_AURA_BARS then + return + end + -- Clean up Player Aura Bars root when switching away. Needed + -- here AND in onPageCacheRestore below -- this branch covers a + -- fresh build of the destination page, onPageCacheRestore + -- covers a cache-restored destination page. Without both, the + -- root only ever gets cleaned up via whichever path the + -- destination page happens to take, which is inconsistent + -- across repeated tab switches (see EUI_RaidFrames_Options.lua's + -- identical _bmRoot/_dmRoot/_ccRoot pattern for the same fix). + if pageName ~= PAGE_AURA_BARS and ns._pabRoot then + ns._pabRoot:Hide() + ns._pabRoot:SetParent(nil) + ns._pabRoot = nil + end -- Randomize preview creature IDs on every tab switch RandomizePreviewCreatures() if pageName == PAGE_DISPLAY then @@ -16253,11 +15555,11 @@ initFrame:SetScript("OnEvent", function(self) return ns._BuildBossPage(pageName, parent, yOffset) elseif pageName == PAGE_MINI then return BuildMiniPage(pageName, parent, yOffset) - elseif pageName == PAGE_AURAS then - return BuildPlayerAurasPage(pageName, parent, yOffset) + elseif pageName == PAGE_AURA_BARS then + if ns.PABMP_BuildPage then return ns.PABMP_BuildPage(pageName, parent, yOffset) end end end, - getHeaderBuilder = function(pageName) + getHeaderBuilder = function(pageName) if pageName == PAGE_DISPLAY then return _displayHeaderBuilder elseif pageName == PAGE_BOSS then @@ -16281,7 +15583,31 @@ initFrame:SetScript("OnEvent", function(self) end return nil end, - onPageCacheRestore = function(pageName) + onPageCacheRestore = function(pageName) + -- Clean up Player Aura Bars root when switching away + if pageName ~= PAGE_AURA_BARS and ns._pabRoot then + ns._pabRoot:Hide() + ns._pabRoot:SetParent(nil) + ns._pabRoot = nil + elseif pageName == PAGE_AURA_BARS and not ns._pabRoot then + -- PABMP_BuildPage bypasses `parent` and builds onto the + -- live shared scrollFrame into ns._pabRoot (see buildPage's + -- _prebuilding guard above for why). The framework's page + -- cache has no knowledge of that self-managed root -- when + -- it decides this page doesn't need a fresh buildPage call + -- (cache-restore instead), our real content, if it was + -- already torn down by an earlier switch-away, never gets + -- rebuilt. Mirrors EUI__General_Options.lua's identical + -- PAGE_PROFILES/PAGE_OVERRIDES fix (CleanupProfilesRoot + + -- deferred RefreshPage(true) guarded by GetActiveModule/ + -- GetActivePage so a since-superseded switch doesn't fire). + C_Timer.After(0, function() + if EllesmereUI:GetActiveModule() == "EllesmereUIUnitFrames" + and EllesmereUI:GetActivePage() == pageName then + EllesmereUI:RefreshPage(true) + end + end) + end RandomizePreviewCreatures() -- Hide all UIParent-parented disabled overlays before restoring -- (they persist across tab switches since they're not children of pf) @@ -16310,7 +15636,7 @@ initFrame:SetScript("OnEvent", function(self) end end end, - onReset = function() + onReset = function() db:ResetProfile() ReloadUI() end, diff --git a/EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua b/EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua index 9a459d56..e1f2bb83 100644 --- a/EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua +++ b/EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua @@ -201,34 +201,48 @@ EllesmereUI._ufPortraitSide = EllesmereUI._ufPortraitSide or setmetatable({}, { local db local defaults = { profile = { - playerAuras = { - enabled = false, - iconSize = 32, - showText = true, - textSize = 11, - borderTexture = "solid", - borderSize = 1, - borderBehind = false, - borderR = 0, borderG = 0, borderB = 0, borderA = 1, - noBorderDebuffs = true, - buffIconZoom = 0.055, + playerAuraBars = { + iconSize = 32, + showText = true, + durationPosition = "BOTTOM", + durationTextSize = 11, + durationOffsetX = 0, + durationOffsetY = 0, + stackPosition = "TOP", + stackTextSize = 11, + stackOffsetX = 0, + stackOffsetY = 0, + buffIconZoom = 0.055, debuffIconZoom = 0.055, - durationFormat = "blizzard", - }, - externalDefensives = { - enabled = false, - iconSize = 32, - iconZoom = 0.055, - growDirection = "right", -- "right" | "left": which way icons extend from the frame edge - showText = true, - textSize = 11, - borderTexture = "solid", - borderSize = 1, - borderBehind = false, - borderR = 0, borderG = 0, borderB = 0, borderA = 1, - durationFormat = "blizzard", - unlockPos = nil, + buffBorderSize = 1, + debuffBorderSize = 1, + buffBorderR = 0, buffBorderG = 0, buffBorderB = 0, buffBorderA = 1, + debuffBorderR = 0, debuffBorderG = 0, debuffBorderB = 0, debuffBorderA = 1, + dispelColorMagic = { r = 0.349, g = 0.475, b = 1.0 }, + dispelColorCurse = { r = 0.636, g = 0.0, b = 0.64 } , + dispelColorDisease = { r = 0.671, g = 0.384, b = 0.098 }, + dispelColorPoison = { r = 0.0, g = 0.706, b = 0.286 }, + dispelColorBleed = { r = 0.75, g = 0.15, b = 0.15 }, + paddingBuffs = 5, + paddingDebuffs = 5, + iconsPerRowBuffs = 11, + iconsPerRowDebuffs = 8, + maxRowsBuffs = 3, + maxRowsDebuffs = 2, + maxBuffs = 32, + maxDebuffs = 16, }, + -- playerAuras (BuffFrame/DebuffFrame reskin) removed -- retired, + -- superseded by playerAuraBars. externalDefensives (the standalone + -- EllesmereUIUnitFrames_ExternalDefensives.lua module) removed the + -- same way 2026-08-02: migrated into playerAuraBars. + -- defaultExternalDefensives as a third built-in bar (see + -- EllesmereUIUnitFrames_PlayerAuraBars.lua's MigrateExternalDefensives + -- -- it reads any EXISTING db.profile.externalDefensives from a + -- user's old saved profile once, on first access, entirely + -- independent of this defaults table). No entry needed here any + -- more: a brand new profile has no old data to migrate and simply + -- starts at PAB's own built-in fallback values. castbarOpacity = 1.0, castbarColor = { r = 0.114, g = 0.655, b = 0.514 }, portraitMode = "2d", @@ -1534,22 +1548,8 @@ local function UF_SecretSafeHealthColor(self, event, unit) or (element.colorClassNPC and not (UnitIsPlayer(unit) or UnitInPartyIsAI(unit))) or (element.colorClassPet and UnitPlayerControlled(unit) and not UnitIsPlayer(unit)) then local _, class = UnitClass(unit) - if issecretvalue(class) then - -- 12.1 (68914) added SecretWhenUnitIdentityRestricted to UnitClass, - -- which is what took class color off focus, focus-target and - -- target-of-target: the token can be neither read nor used as a - -- table key, so the old lookup degraded to the green reaction tier. - -- C_ClassColor.GetClassColor and SetStatusBarColor are BOTH - -- documented SecretArguments = "AllowedWhenTainted", so the real - -- color still reaches the bar with Lua never inspecting the class. - -- Custom class colors cannot apply on this path (they are an - -- addon-side table lookup, which is the thing a secret key forbids). - if C_ClassColor and C_ClassColor.GetClassColor then - color = C_ClassColor.GetClassColor(class) - end - else - color = class and self.colors.class[class] - end + if issecretvalue(class) then class = nil end + color = class and self.colors.class[class] if not color then -- Unreadable class: fall to the tiers the lib chain would have -- reached had the class branch not matched. @@ -2571,49 +2571,6 @@ local function ResolveBuffLayout(anchor, growth) return m.fp, ia, gx, gy, m.ox, m.oy end --- Anchor Buffs with Debuffs (per-unit debuffAnchorBuffs): buffs render as --- the first rows of the debuff stack, so the debuff container must sit a --- whole row step past the buff block per visible buff row -- debuffs never --- share a row with buffs. Installed as Buffs:PostUpdate by the reload --- blocks; config rides element._euiMerge (nil = feature off, zero work --- beyond one table read per aura update). Row math mirrors the aura --- element's SetPosition grid exactly (size + spacing, maxCols else --- width-derived columns), so cropped heights and spacing edits self-heal --- on the next aura update without a reload. -ns.UF_MergedBuffsPostUpdate = function(element) - local m = element._euiMerge - if not m then return end - local deb = m.deb - if not deb or deb.num == 0 then return end - local rows = 0 - local n = element.visibleButtons or 0 - if n > 0 then - local width = element.width or element.size or 16 - local sizeX = width + (element.spacingX or element.spacing or 0) - local cols = element.maxCols or math.floor(element:GetWidth() / sizeX + 0.5) - if not cols or cols < 1 then cols = 1 end - rows = math.ceil(n / cols) - end - local height = element.height or element.size or 16 - local rowH = height + (element.spacingY or element.spacing or 0) - if element._euiMergeRows == rows and element._euiMergeRowH == rowH then return end - element._euiMergeRows = rows - element._euiMergeRowH = rowH - local shift = rows * rowH - if m.gy ~= "UP" then shift = -shift end - deb:ClearAllPoints() - deb:SetPoint(m.dia, m.parent, m.dfp, m.x, m.y + shift) -end - --- True when a unit's buffs should ride the debuff stack: toggle on and a --- real debuff anchor to join. The toggle OWNS buff visibility -- merged --- buffs render even with Buff Display at None (showBuffs false), which is --- exactly the state the options auto-select on enable. Per-unit debuff --- anchor defaults differ, so callers pass their resolved dAnc. -ns.UF_MergedAuras = function(settings, dAnc) - return settings.debuffAnchorBuffs == true and dAnc ~= "none" -end - -- Boss "Simple Debuff Display" mode: "none" | "left" | "right". -- Tolerates legacy boolean values (true/nil = "left", false = "none") so existing -- and imported profiles read correctly without a migration pass. "left"/"right" @@ -9764,11 +9721,7 @@ local function ReloadFrames() -- Live toggle player buffs if frame.Buffs then - -- Anchor Buffs with Debuffs forces the element on: - -- the merge owns buff visibility while Buff Display - -- reads None (the option it overrides). - local mergedB = ns.UF_MergedAuras(settings, settings.debuffAnchor or "none") - if settings.showBuffs or mergedB then + if settings.showBuffs then if not frame:IsElementEnabled("Buffs") then frame:EnableElement("Buffs") end @@ -9787,42 +9740,20 @@ local function ReloadFrames() if cbH <= 0 then cbH = 14 end buffCbOff = -cbH end - -- Anchor Buffs with Debuffs: buffs become the first - -- rows of the debuff stack -- adopt the debuff - -- anchor/growth/offsets wholesale (the debuff stack - -- shifts past the buff rows; UF_MergedBuffsPostUpdate). - local bOffX = settings.buffOffsetX or 0 - local bOffY = settings.buffOffsetY or 0 - if mergedB then - local dAncM = settings.debuffAnchor or "none" - bfp, bia, bgx, bgy, box, boy = ResolveBuffLayout(dAncM, settings.debuffGrowth or "auto") - buffCbOff = 0 - if (dAncM == "bottomleft" or dAncM == "bottomright") and settings.showPlayerCastbar then - local cbH = settings.playerCastbarHeight or 0 - if cbH <= 0 then cbH = 14 end - buffCbOff = -cbH - end - bOffX = settings.debuffOffsetX or 0 - bOffY = settings.debuffOffsetY or 0 - end -- Only reanchor + ForceUpdate when layout actually changed local buffFilter = ns.ComposeAuraFilter("HELPFUL", settings) - local buffKey = string.format("%s%s%d%d%d%s%d%d%d%d", bia or "", bfp or "", box or 0, boy or 0, buffCbOff, settings.buffGrowth or "auto", settings.maxBuffs or 4, settings.buffSize or 22, bOffX, bOffY) .. "p" .. (settings.buffMaxPerRow or 0) .. "spx" .. (settings.buffSpacingX or 1) .. "spy" .. (settings.buffSpacingY or 1) .. buffFilter .. (settings.showAuraTooltips == false and "ttOff" or "") .. (mergedB and ("M1" .. (settings.debuffGrowth or "auto")) or "") + local buffKey = string.format("%s%s%d%d%d%s%d%d%d%d", bia or "", bfp or "", box or 0, boy or 0, buffCbOff, settings.buffGrowth or "auto", settings.maxBuffs or 4, settings.buffSize or 22, settings.buffOffsetX or 0, settings.buffOffsetY or 0) .. "p" .. (settings.buffMaxPerRow or 0) .. "spx" .. (settings.buffSpacingX or 1) .. "spy" .. (settings.buffSpacingY or 1) .. buffFilter .. (settings.showAuraTooltips == false and "ttOff" or "") if frame.Buffs._lastBuffKey ~= buffKey then frame.Buffs._lastBuffKey = buffKey ns.ApplyEUIAuraFilter(frame.Buffs, "HELPFUL", settings) frame.Buffs.size = settings.buffSize or 22 frame.Buffs.spacingX = PP.FromPixels(settings.buffSpacingX or 1); frame.Buffs.spacingY = PP.FromPixels(settings.buffSpacingY or 1) frame.Buffs:ClearAllPoints() - frame.Buffs:SetPoint(bia, frame, bfp, box * 1 + bOffX, boy * 1 + buffCbOff + bOffY) + frame.Buffs:SetPoint(bia, frame, bfp, box * 1 + (settings.buffOffsetX or 0), boy * 1 + buffCbOff + (settings.buffOffsetY or 0)) frame.Buffs.initialAnchor = bia frame.Buffs.growthX = bgx frame.Buffs.growthY = bgy - -- Merged: wrap like the debuff stack it joins - -- (growth was resolved from the debuff config). - local bColsGrowth = settings.buffGrowth - if mergedB then bColsGrowth = settings.debuffGrowth or "auto" end - frame.Buffs.maxCols = AuraMaxCols(bColsGrowth, settings.maxBuffs or 4, settings.buffMaxPerRow) + frame.Buffs.maxCols = AuraMaxCols(settings.buffGrowth, settings.maxBuffs or 4, settings.buffMaxPerRow) if frame.Buffs.ForceUpdate then frame.Buffs:ForceUpdate() end @@ -9860,7 +9791,7 @@ local function ReloadFrames() debuffCbOff = -cbH end local debuffFilter = ns.ComposeAuraFilter("HARMFUL", settings) .. (settings.showLustDebuff and "|LUST" or "") - local debuffKey = string.format("%s%s%d%d%d%s%d%d%d%d", dia or "", dfp or "", dox or 0, doy or 0, debuffCbOff, settings.debuffGrowth or "auto", settings.maxDebuffs or 10, settings.debuffSize or 22, settings.debuffOffsetX or 0, settings.debuffOffsetY or 0) .. "p" .. (settings.debuffMaxPerRow or 0) .. "spx" .. (settings.debuffSpacingX or 1) .. "spy" .. (settings.debuffSpacingY or 1) .. debuffFilter .. (settings.showAuraTooltips == false and "ttOff" or "") .. (settings.debuffAnchorBuffs and "M1" or "") + local debuffKey = string.format("%s%s%d%d%d%s%d%d%d%d", dia or "", dfp or "", dox or 0, doy or 0, debuffCbOff, settings.debuffGrowth or "auto", settings.maxDebuffs or 10, settings.debuffSize or 22, settings.debuffOffsetX or 0, settings.debuffOffsetY or 0) .. "p" .. (settings.debuffMaxPerRow or 0) .. "spx" .. (settings.debuffSpacingX or 1) .. "spy" .. (settings.debuffSpacingY or 1) .. debuffFilter .. (settings.showAuraTooltips == false and "ttOff" or "") if frame.Debuffs._lastDebuffKey ~= debuffKey then frame.Debuffs._lastDebuffKey = debuffKey ns.ApplyEUIAuraFilter(frame.Debuffs, "HARMFUL", settings) @@ -9873,31 +9804,6 @@ local function ReloadFrames() frame.Debuffs.growthX = dgx frame.Debuffs.growthY = dgy frame.Debuffs.maxCols = AuraMaxCols(settings.debuffGrowth, settings.maxDebuffs or 10, settings.debuffMaxPerRow) - -- Anchor Buffs with Debuffs: stash this stack's - -- base point for the buff element's PostUpdate, - -- which pushes the stack past the buff rows. - if frame.Buffs then - if ns.UF_MergedAuras(settings, dAnc) then - frame.Buffs._euiMerge = { - deb = frame.Debuffs, parent = frame, dia = dia, dfp = dfp, - x = dox * 1 + (settings.debuffOffsetX or 0), - y = doy * 1 + debuffCbOff + (settings.debuffOffsetY or 0), - gy = dgy, - } - frame.Buffs._euiMergeRows = nil - frame.Buffs.PostUpdate = ns.UF_MergedBuffsPostUpdate - ns.UF_MergedBuffsPostUpdate(frame.Buffs) - else - -- Feature off: leave the element exactly - -- as stock oUF runs it -- no PostUpdate - -- installed, zero per-update work. - frame.Buffs._euiMerge = nil - frame.Buffs._euiMergeRows = nil - if frame.Buffs.PostUpdate == ns.UF_MergedBuffsPostUpdate then - frame.Buffs.PostUpdate = nil - end - end - end if frame.Debuffs.ForceUpdate then frame.Debuffs:ForceUpdate() end @@ -10241,11 +10147,7 @@ local function ReloadFrames() -- Buffs if frame.Buffs then - -- Anchor Buffs with Debuffs forces the element on: - -- the merge owns buff visibility while Buff Display - -- reads None (the option it overrides). - local mergedB = ns.UF_MergedAuras(settings, settings.debuffAnchor or "bottomleft") - local showBuffs = settings.showBuffs ~= false or mergedB + local showBuffs = settings.showBuffs ~= false if showBuffs then if not frame:IsElementEnabled("Buffs") then frame:EnableElement("Buffs") @@ -10264,41 +10166,19 @@ local function ReloadFrames() liveCbOff = -cbH end end - -- Anchor Buffs with Debuffs: buffs become the first - -- rows of the debuff stack -- adopt the debuff - -- anchor/growth/offsets wholesale (the debuff stack - -- shifts past the buff rows; UF_MergedBuffsPostUpdate). - local bOffX = settings.buffOffsetX or 0 - local bOffY = settings.buffOffsetY or 0 - if mergedB then - local dAncM = settings.debuffAnchor or "bottomleft" - bfp, bia, bgx, bgy, box, boy = ResolveBuffLayout(dAncM, settings.debuffGrowth or "auto") - liveCbOff = 0 - if settings.showCastbar ~= false and (dAncM == "bottomleft" or dAncM == "bottomright") then - local cbH = settings.castbarHeight or 14 - if cbH <= 0 then cbH = 14 end - liveCbOff = -cbH - end - bOffX = settings.debuffOffsetX or 0 - bOffY = settings.debuffOffsetY or 0 - end local buffFilter = ns.ComposeAuraFilter("HELPFUL", settings) - local buffKey = string.format("%s%s%d%d%s%d%d%d%d%d", bia or "", bfp or "", box or 0, boy or 0, settings.buffGrowth or "auto", settings.maxBuffs or 20, liveCbOff, settings.buffSize or 22, bOffX, bOffY) .. "p" .. (settings.buffMaxPerRow or 0) .. "spx" .. (settings.buffSpacingX or 1) .. "spy" .. (settings.buffSpacingY or 1) .. buffFilter .. (settings.showAuraTooltips == false and "ttOff" or "") .. (mergedB and ("M1" .. (settings.debuffGrowth or "auto")) or "") + local buffKey = string.format("%s%s%d%d%s%d%d%d%d%d", bia or "", bfp or "", box or 0, boy or 0, settings.buffGrowth or "auto", settings.maxBuffs or 20, liveCbOff, settings.buffSize or 22, settings.buffOffsetX or 0, settings.buffOffsetY or 0) .. "p" .. (settings.buffMaxPerRow or 0) .. "spx" .. (settings.buffSpacingX or 1) .. "spy" .. (settings.buffSpacingY or 1) .. buffFilter .. (settings.showAuraTooltips == false and "ttOff" or "") if frame.Buffs._lastBuffKey ~= buffKey then frame.Buffs._lastBuffKey = buffKey ns.ApplyEUIAuraFilter(frame.Buffs, "HELPFUL", settings) frame.Buffs.size = settings.buffSize or 22 frame.Buffs.spacingX = PP.FromPixels(settings.buffSpacingX or 1); frame.Buffs.spacingY = PP.FromPixels(settings.buffSpacingY or 1) frame.Buffs:ClearAllPoints() - frame.Buffs:SetPoint(bia, frame, bfp, box * 1 + bOffX, boy * 1 + liveCbOff + bOffY) + frame.Buffs:SetPoint(bia, frame, bfp, box * 1 + (settings.buffOffsetX or 0), boy * 1 + liveCbOff + (settings.buffOffsetY or 0)) frame.Buffs.initialAnchor = bia frame.Buffs.growthX = bgx frame.Buffs.growthY = bgy - -- Merged: wrap like the debuff stack it joins - -- (growth was resolved from the debuff config). - local bColsGrowth = settings.buffGrowth - if mergedB then bColsGrowth = settings.debuffGrowth or "auto" end - frame.Buffs.maxCols = AuraMaxCols(bColsGrowth, settings.maxBuffs or 4, settings.buffMaxPerRow) + frame.Buffs.maxCols = AuraMaxCols(settings.buffGrowth, settings.maxBuffs or 4, settings.buffMaxPerRow) if frame.Buffs.ForceUpdate then frame.Buffs:ForceUpdate() end @@ -10338,7 +10218,7 @@ local function ReloadFrames() end end local debuffFilter = ns.ComposeAuraFilter("HARMFUL", settings) .. (settings.showLustDebuff and "|LUST" or "") - local debuffKey = string.format("%s%s%d%d%s%d%d%d%d%d%d", dia or "", dfp or "", dox or 0, doy or 0, settings.debuffGrowth or "auto", settings.maxDebuffs or 20, liveDbCbOff, settings.debuffSize or 22, settings.debuffOffsetX or 0, settings.debuffOffsetY or 0, settings.onlyPlayerDebuffs and 1 or 0) .. "p" .. (settings.debuffMaxPerRow or 0) .. "spx" .. (settings.debuffSpacingX or 1) .. "spy" .. (settings.debuffSpacingY or 1) .. debuffFilter .. (settings.showAuraTooltips == false and "ttOff" or "") .. (settings.debuffAnchorBuffs and "M1" or "") + local debuffKey = string.format("%s%s%d%d%s%d%d%d%d%d%d", dia or "", dfp or "", dox or 0, doy or 0, settings.debuffGrowth or "auto", settings.maxDebuffs or 20, liveDbCbOff, settings.debuffSize or 22, settings.debuffOffsetX or 0, settings.debuffOffsetY or 0, settings.onlyPlayerDebuffs and 1 or 0) .. "p" .. (settings.debuffMaxPerRow or 0) .. "spx" .. (settings.debuffSpacingX or 1) .. "spy" .. (settings.debuffSpacingY or 1) .. debuffFilter .. (settings.showAuraTooltips == false and "ttOff" or "") if frame.Debuffs._lastDebuffKey ~= debuffKey then frame.Debuffs._lastDebuffKey = debuffKey ns.ApplyEUIAuraFilter(frame.Debuffs, "HARMFUL", settings) @@ -10351,31 +10231,6 @@ local function ReloadFrames() frame.Debuffs.growthX = dgx frame.Debuffs.growthY = dgy frame.Debuffs.maxCols = AuraMaxCols(settings.debuffGrowth, settings.maxDebuffs or 10, settings.debuffMaxPerRow) - -- Anchor Buffs with Debuffs: stash this stack's - -- base point for the buff element's PostUpdate, - -- which pushes the stack past the buff rows. - if frame.Buffs then - if ns.UF_MergedAuras(settings, dAnc) then - frame.Buffs._euiMerge = { - deb = frame.Debuffs, parent = frame, dia = dia, dfp = dfp, - x = dox * 1 + (settings.debuffOffsetX or 0), - y = doy * 1 + liveDbCbOff + (settings.debuffOffsetY or 0), - gy = dgy, - } - frame.Buffs._euiMergeRows = nil - frame.Buffs.PostUpdate = ns.UF_MergedBuffsPostUpdate - ns.UF_MergedBuffsPostUpdate(frame.Buffs) - else - -- Feature off: leave the element exactly - -- as stock oUF runs it -- no PostUpdate - -- installed, zero per-update work. - frame.Buffs._euiMerge = nil - frame.Buffs._euiMergeRows = nil - if frame.Buffs.PostUpdate == ns.UF_MergedBuffsPostUpdate then - frame.Buffs.PostUpdate = nil - end - end - end if frame.Debuffs.ForceUpdate then frame.Debuffs:ForceUpdate() end @@ -10685,7 +10540,7 @@ local function ReloadFrames() end end local debuffFilter = ns.ComposeAuraFilter("HARMFUL", settings) .. (settings.showLustDebuff and "|LUST" or "") - local debuffKey = string.format("%s%s%d%d%s%d%d%d%d%d%d", dia or "", dfp or "", dox or 0, doy or 0, settings.debuffGrowth or "auto", settings.maxDebuffs or 10, focusDbCbOff, settings.debuffSize or 22, settings.debuffOffsetX or 0, settings.debuffOffsetY or 0, settings.onlyPlayerDebuffs and 1 or 0) .. "p" .. (settings.debuffMaxPerRow or 0) .. "spx" .. (settings.debuffSpacingX or 1) .. "spy" .. (settings.debuffSpacingY or 1) .. debuffFilter .. (settings.showAuraTooltips == false and "ttOff" or "") .. (settings.debuffAnchorBuffs and "M1" or "") + local debuffKey = string.format("%s%s%d%d%s%d%d%d%d%d%d", dia or "", dfp or "", dox or 0, doy or 0, settings.debuffGrowth or "auto", settings.maxDebuffs or 10, focusDbCbOff, settings.debuffSize or 22, settings.debuffOffsetX or 0, settings.debuffOffsetY or 0, settings.onlyPlayerDebuffs and 1 or 0) .. "p" .. (settings.debuffMaxPerRow or 0) .. "spx" .. (settings.debuffSpacingX or 1) .. "spy" .. (settings.debuffSpacingY or 1) .. debuffFilter .. (settings.showAuraTooltips == false and "ttOff" or "") if frame.Debuffs._lastDebuffKey ~= debuffKey then frame.Debuffs._lastDebuffKey = debuffKey ns.ApplyEUIAuraFilter(frame.Debuffs, "HARMFUL", settings) @@ -10698,31 +10553,6 @@ local function ReloadFrames() frame.Debuffs.growthX = dgx frame.Debuffs.growthY = dgy frame.Debuffs.maxCols = AuraMaxCols(settings.debuffGrowth, settings.maxDebuffs or 10, settings.debuffMaxPerRow) - -- Anchor Buffs with Debuffs: stash this stack's - -- base point for the buff element's PostUpdate, - -- which pushes the stack past the buff rows. - if frame.Buffs then - if ns.UF_MergedAuras(settings, dAnc) then - frame.Buffs._euiMerge = { - deb = frame.Debuffs, parent = frame, dia = dia, dfp = dfp, - x = dox * 1 + (settings.debuffOffsetX or 0), - y = doy * 1 + focusDbCbOff + (settings.debuffOffsetY or 0), - gy = dgy, - } - frame.Buffs._euiMergeRows = nil - frame.Buffs.PostUpdate = ns.UF_MergedBuffsPostUpdate - ns.UF_MergedBuffsPostUpdate(frame.Buffs) - else - -- Feature off: leave the element exactly - -- as stock oUF runs it -- no PostUpdate - -- installed, zero per-update work. - frame.Buffs._euiMerge = nil - frame.Buffs._euiMergeRows = nil - if frame.Buffs.PostUpdate == ns.UF_MergedBuffsPostUpdate then - frame.Buffs.PostUpdate = nil - end - end - end if frame.Debuffs.ForceUpdate then frame.Debuffs:ForceUpdate() end @@ -10733,11 +10563,7 @@ local function ReloadFrames() -- Buffs (focus) if frame.Buffs then - -- Anchor Buffs with Debuffs forces the element on: the - -- merge owns buff visibility while Buff Display reads - -- None (the option it overrides). - local mergedB = ns.UF_MergedAuras(settings, settings.debuffAnchor or "bottomleft") - local showBuffs = settings.showBuffs ~= false or mergedB + local showBuffs = settings.showBuffs ~= false if showBuffs then if not frame:IsElementEnabled("Buffs") then frame:EnableElement("Buffs") @@ -10756,41 +10582,19 @@ local function ReloadFrames() focusBfCbOff = -cbH end end - -- Anchor Buffs with Debuffs: buffs become the first - -- rows of the debuff stack -- adopt the debuff - -- anchor/growth/offsets wholesale (the debuff stack - -- shifts past the buff rows; UF_MergedBuffsPostUpdate). - local bOffX = settings.buffOffsetX or 0 - local bOffY = settings.buffOffsetY or 0 - if mergedB then - local dAncM = settings.debuffAnchor or "bottomleft" - bfp, bia, bgx, bgy, box, boy = ResolveBuffLayout(dAncM, settings.debuffGrowth or "auto") - focusBfCbOff = 0 - if settings.showCastbar ~= false and (dAncM == "bottomleft" or dAncM == "bottomright") then - local cbH = settings.castbarHeight or 14 - if cbH <= 0 then cbH = 14 end - focusBfCbOff = -cbH - end - bOffX = settings.debuffOffsetX or 0 - bOffY = settings.debuffOffsetY or 0 - end local buffFilter = ns.ComposeAuraFilter("HELPFUL", settings) - local buffKey = string.format("%s%s%d%d%s%d%d%d%d%d", bia or "", bfp or "", box or 0, boy or 0, settings.buffGrowth or "auto", settings.maxBuffs or 4, focusBfCbOff, settings.buffSize or 22, bOffX, bOffY) .. "p" .. (settings.buffMaxPerRow or 0) .. "spx" .. (settings.buffSpacingX or 1) .. "spy" .. (settings.buffSpacingY or 1) .. buffFilter .. (settings.showAuraTooltips == false and "ttOff" or "") .. (mergedB and ("M1" .. (settings.debuffGrowth or "auto")) or "") + local buffKey = string.format("%s%s%d%d%s%d%d%d%d%d", bia or "", bfp or "", box or 0, boy or 0, settings.buffGrowth or "auto", settings.maxBuffs or 4, focusBfCbOff, settings.buffSize or 22, settings.buffOffsetX or 0, settings.buffOffsetY or 0) .. "p" .. (settings.buffMaxPerRow or 0) .. "spx" .. (settings.buffSpacingX or 1) .. "spy" .. (settings.buffSpacingY or 1) .. buffFilter .. (settings.showAuraTooltips == false and "ttOff" or "") if frame.Buffs._lastBuffKey ~= buffKey then frame.Buffs._lastBuffKey = buffKey ns.ApplyEUIAuraFilter(frame.Buffs, "HELPFUL", settings) frame.Buffs.size = settings.buffSize or 22 frame.Buffs.spacingX = PP.FromPixels(settings.buffSpacingX or 1); frame.Buffs.spacingY = PP.FromPixels(settings.buffSpacingY or 1) frame.Buffs:ClearAllPoints() - frame.Buffs:SetPoint(bia, frame, bfp, box * 1 + bOffX, boy * 1 + focusBfCbOff + bOffY) + frame.Buffs:SetPoint(bia, frame, bfp, box * 1 + (settings.buffOffsetX or 0), boy * 1 + focusBfCbOff + (settings.buffOffsetY or 0)) frame.Buffs.initialAnchor = bia frame.Buffs.growthX = bgx frame.Buffs.growthY = bgy - -- Merged: wrap like the debuff stack it joins - -- (growth was resolved from the debuff config). - local bColsGrowth = settings.buffGrowth - if mergedB then bColsGrowth = settings.debuffGrowth or "auto" end - frame.Buffs.maxCols = AuraMaxCols(bColsGrowth, settings.maxBuffs or 4, settings.buffMaxPerRow) + frame.Buffs.maxCols = AuraMaxCols(settings.buffGrowth, settings.maxBuffs or 4, settings.buffMaxPerRow) if frame.Buffs.ForceUpdate then frame.Buffs:ForceUpdate() end @@ -14865,6 +14669,21 @@ do end end +-- Called by EUI_UnlockMode.lua's Grow Direction dropdown for barKey == +-- "PAB_Buffs" / "PAB_Debuffs". Thin delegation to +-- EllesmereUIUnitFrames_PlayerAuraBars.lua's ns.PAB_Get/SetGrowDirection so +-- the settings field names stay defined in exactly one file. + +function EllesmereUF:GetGrowDirectionForBar(barKey) + return ns.PAB_GetGrowDirection and ns.PAB_GetGrowDirection(barKey) +end + +function EllesmereUF:SetGrowDirectionForBar(barKey, dir) + if ns.PAB_SetGrowDirection then + ns.PAB_SetGrowDirection(barKey, dir) + end +end + ------------------------------------------------------------------------------- -- Boss Frame Range Dimming -- Boss units sit outside UnitInRange's group-member domain, so range is diff --git a/EllesmereUIUnitFrames/EllesmereUIUnitFrames.toc b/EllesmereUIUnitFrames/EllesmereUIUnitFrames.toc index f1066e37..1145cdc9 100644 --- a/EllesmereUIUnitFrames/EllesmereUIUnitFrames.toc +++ b/EllesmereUIUnitFrames/EllesmereUIUnitFrames.toc @@ -16,8 +16,14 @@ Libs\oUF\oUF.xml # Main Luas EllesmereUIUnitFrames.lua EUI_UnitFrames_AuraElement.lua -EllesmereUIUnitFrames_PlayerAuras.lua +# EllesmereUIUnitFrames_PlayerAuras.lua retired (BuffFrame/DebuffFrame +# reskin, superseded by PlayerAuraBars). Its External Defensives sub-feature +# (later split into its own file) was migrated into PlayerAuraBars itself +# 2026-08-02 as a third built-in bar; EllesmereUIUnitFrames_ +# ExternalDefensives.lua is retired too, no longer loaded. +EllesmereUIUnitFrames_PlayerAuraBars.lua EUI_UnitFrames_AuraContainers.lua # Options EUI_UnitFrames_Options.lua +EUI_PlayerAuraBars_ManagerPages.lua diff --git a/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua b/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua new file mode 100644 index 00000000..11ee8aa3 --- /dev/null +++ b/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua @@ -0,0 +1,4380 @@ +------------------------------------------------------------------------------- +-- EllesmereUIUnitFrames_PlayerAuraBars.lua +-- 12.1 AuraKit-based replacement for the old BuffFrame/DebuffFrame reskin +-- (EllesmereUIUnitFrames_PlayerAuras.lua, retired -- 12.0.7 support dropped). +-- +-- STEP A of the build plan: gate, shared class vocabulary, container +-- creation with default + per-class groups. Styling (icon/duration/stack +-- fonts, padding, border) and the dispel-type border are STEP B/C and are +-- intentionally left as minimal placeholders here (see TODO markers) so +-- this step is independently testable: containers should appear showing +-- bare icons before any cosmetic work is layered on. +------------------------------------------------------------------------------- + +local _, ns = ... + +-- 12.1 ONLY. 12.0.7 support was dropped for Player Aura Bars (decided +-- 2026-07-29) -- unlike every other AuraKit consumer in the suite, there is +-- no legacy module left running behind this gate. The guard stays anyway as +-- a defensive no-op on a stale/mismatched client rather than an assumption +-- that IS_121 is always true by the time this file loads. +if not (EllesmereUI and EllesmereUI.IS_121) then return end + +local AK -- EllesmereUI.AuraKit, resolved at first use (parent file loads first) + +------------------------------------------------------------------------------- +-- Settings accessor +------------------------------------------------------------------------------- + +-- Falls back to an empty table (never nil) so CreateBars() does not silently +-- bail just because no Options UI has written to this profile table yet. +-- An empty table means every class toggle reads false -- BuildChain() still +-- produces the "all" catch-all group per polarity, so bars should render +-- showing every buff/debuff even before any settings exist. +local function PAB() + local db = ns.db + return db and db.profile and db.profile.playerAuraBars +end + +------------------------------------------------------------------------------- +-- Shared class vocabulary (from EUI_UnitFrames_AuraContainers.lua) +-- +-- "raid"/"raidcombat" were excluded 2026-07-29 (assumed roster-context +-- tokens that don't apply to a standalone player-only display) -- reversed +-- 2026-08-03 (Joel, for Icon Effects Per-Filter parity with Raid Frames' +-- own "Raid"/"Raid In Combat" filters): RAID/RAID_IN_COMBAT are per-aura +-- flags (Blizzard's own raid-frame debuff curation baked into the aura +-- definition itself), not roster-size-dependent, so they filter +-- meaningfully even solo. Now wired up as real, functional debuff +-- categories exactly like every other class here. +------------------------------------------------------------------------------- + +local HIDDEN_CLASSES = {} + +local function VisibleTokenClasses() + local uf = ns.UF_TokenClasses + if not uf then return nil end + local out = {} + for i = 1, #uf do + if not HIDDEN_CLASSES[uf[i].key] then out[#out + 1] = uf[i] end + end + return out +end + +-- Candidate classes carry no hidden entries currently (bossaura/roleaura/ +-- priority/steal all apply to a player-only display) -- passed through +-- unfiltered, but through the same accessor so a future hide is one line. +local function VisibleCandidateClasses() + return ns.UF_CandidateClasses +end + +-- Display metadata for the Options UI's class-toggle dropdown (one entry per +-- VisibleTokenClasses/VisibleCandidateClasses entry, keyed by .skey to match +-- ClassEnabled's db-field convention: "buff"/"debuff" .. skey). Label/ +-- tooltip text is copied VERBATIM from EUI_UnitFrames_Options.lua's existing +-- buffFilterItems/debuffFilterItems list (same skey vocabulary already used +-- for Target/Focus/Boss frames -- confirmed consistent wording across the +-- suite), with ONE exception: "NonPlayer" has no counterpart anywhere else +-- in the codebase (that per-unit list never offers it). Its label below is +-- my own pick, not sourced from existing UI text -- flag if different +-- wording is wanted. +local CLASS_LABELS = { + Dispellable = { "Dispellable By You", "Shows only auras with a dispel type you can dispel" }, + CrowdControl = { "Crowd Control", "Shows only crowd-control auras" }, + BigDefensive = { "Big Defensive", "Shows only major defensive cooldowns" }, + ExternalDefensive = { "External Defensive", "Shows only external defensive cooldowns cast on the unit" }, + Cancelable = { "Cancelable", "Shows only buffs that can be canceled" }, + Stealable = { "Stealable", "Shows only buffs you can spellsteal or purge" }, + BossAura = { "Boss Debuffs", "Shows only debuffs applied by bosses" }, + RoleAura = { "Role Debuffs", "Shows only debuffs flagged for your role" }, + -- "Important" (2026-08-03, renamed from "Priority" for parity with Raid + -- Frames' own wording for this same isPriorityAura flag). + PriorityAura = { "Important", "Shows only priority debuffs" }, + NonPlayer = { "Not Cast By You", "Shows only debuffs not applied by you" }, -- ASSUMPTION, see note above + -- Any debuff carrying a dispel type, regardless of whether YOU can + -- remove it -- distinct from "Dispellable By You" above. Mirrors Raid + -- Frames' "Dispels" filter. + DispelTyped = { "Dispels", "Shows any debuff with a dispel type (Magic, Curse, Disease, Poison, Bleed), even if you cannot remove it" }, + Raid = { "Raid", "Shows only debuffs from Blizzard's curated raid-frame debuff set" }, + RaidInCombat = { "Raid In Combat", "Shows only the stricter in-combat subset of the raid set" }, +} + +-- Curated debuff filter list (2026-08-03, Joel): exact parity with Raid +-- Frames' own debuff filter vocabulary and order (EUI_RaidFrames_ +-- ManagerPages.lua's TILE_FILTER_ITEMS) -- both PAB's Base Filters (display +-- restriction, ns.PAB_ClassItems) and Icon Effects Filters (fx targeting, +-- ns.PAB_FxClassItems) dropdowns show this SAME curated, ordered set now, +-- replacing the previous "every visible class" generic enumeration (which +-- exposed Big Defensive/External Defensive/Not Cast By You -- concepts with +-- no Raid Frames debuff equivalent -- and omitted Raid/Raid In Combat +-- entirely). Values are the lowercase ENGINE keys (TOKEN_CLASSES/ +-- CANDIDATE_CLASSES' .key), resolved to either .key or .skey per dropdown +-- by ClassByKey below. Big Defensive/External Defensive/Not Cast By You +-- remain fully functional (BuildChain/ClassEnabled still read them) for any +-- profile that already has them set -- just no longer offered as a fresh +-- pick in either dropdown. +local DEBUFF_FILTER_ORDER = { + "priority", "cc", "bossaura", "roleaura", "raid", "raidcombat", "dispellable", "dispeltyped", +} + +local function ClassByKey(key) + local uf = ns.UF_TokenClasses + if uf then + for i = 1, #uf do if uf[i].key == key then return uf[i] end end + end + local cc = ns.UF_CandidateClasses + if cc then + for i = 1, #cc do if cc[i].key == key then return cc[i] end end + end +end + +function ns.PAB_ClassItems(isBuff) + if not isBuff then + local items = {} + for i = 1, #DEBUFF_FILTER_ORDER do + local class = ClassByKey(DEBUFF_FILTER_ORDER[i]) + if class then + local meta = CLASS_LABELS[class.skey] + items[#items + 1] = { + key = class.skey, + label = meta and meta[1] or class.skey, + tooltip = meta and meta[2] or nil, + } + end + end + return items + end + + -- Buffs: unchanged generic enumeration (kept for signature + -- compatibility -- ns.PAB_ClassItems is never actually called with + -- isBuff=true today, buffs use their own Filters/Extra Spells model). + local items = {} + local tokenClasses = VisibleTokenClasses() + local candidateClasses = VisibleCandidateClasses() + if not (tokenClasses and candidateClasses) then return items end + local function AddAll(list) + for i = 1, #list do + local class = list[i] + -- isBuff is always true in this branch (isBuff==false already + -- returned above), so the original ((buffOnly and not isBuff) or + -- (debuffOnly and isBuff)) exclusion simplifies to just debuffOnly. + if not class.debuffOnly then + local meta = CLASS_LABELS[class.skey] + items[#items + 1] = { + key = class.skey, + label = meta and meta[1] or class.skey, + tooltip = meta and meta[2] or nil, + } + end + end + end + AddAll(tokenClasses) + AddAll(candidateClasses) + return items +end + +------------------------------------------------------------------------------- +-- Icon Effects Per-Filter (debuffs only) -- ported from Raid Frames' +-- DebuffManager fxList system (EUI_RaidFrames_DebuffManager.lua / +-- EUI_RaidFrames_AuraContainers.lua), NOT shared code (same "adapted, not +-- shared" precedent as BuildChain/ClassEnabled below). Each cfg.fxList +-- entry pairs a set of debuff-category filters with an optional Icon Glow, +-- Border override, and Size override; the FIRST entry whose filters +-- include a button's category wins. +-- +-- Unlike Raid Frames' independent/overlapping category records, PAB's +-- debuff classes are a mutual-exclusion chain (BuildChain below) -- every +-- displayed debuff icon already belongs to exactly ONE engine group key +-- (a class's .key, or "all" for the catch-all), so matching here is a +-- single dictionary lookup, not a search across overlapping records. +------------------------------------------------------------------------------- + +local function PAB_FxEntryActive(e) + return e.filters ~= nil and next(e.filters) ~= nil + and (((e.glowType or 0) > 0) or ((e.borderSize or 0) > 0) + or ((tonumber(e.size) or 0) > 0)) +end + +local function PAB_FxListView(list) + if not list then return nil end + local out + for i = 1, #list do + if PAB_FxEntryActive(list[i]) then + out = out or {} + out[#out + 1] = list[i] + end + end + return out +end + +-- First ACTIVE block whose filters include `cat` wins (list is already +-- pre-filtered to active-only blocks by PAB_FxListView/style.fxList). +local function PAB_FxBlockFor(list, cat) + if not (list and cat) then return nil end + for i = 1, #list do + local f = list[i].filters + if f and f[cat] then return list[i] end + end +end + +-- Per-filter Size resolution for one engine group key: the first ACTIVE +-- block matching `cat` wins outright, same rule PAB_ApplyDmFx uses for +-- glow/border, so a later block's Size never reaches a category an earlier +-- block already claimed. +local function PAB_FxSizeFor(list, cat) + if not list then return nil end + for i = 1, #list do + local e = list[i] + if PAB_FxEntryActive(e) then + local f = e.filters + if f and f[cat] then + local sz = tonumber(e.size) + if sz and sz > 0 then return sz end + return nil + end + end + end +end + +-- True if any ACTIVE fx block targets this engine class key -- used to +-- force a real per-category group into existence even when Show All +-- Debuffs/Base Filters would otherwise route everything through the +-- catch-all group. Icon Effects can never match the catch-all's "all" key +-- (no such fx entry exists, see ns.PAB_FxClassItems' doc comment below), +-- and there is no way to read a shown aura's real category back from an +-- AK-engine button after the fact (per-aura data is engine-secret in 12.1, +-- confirmed via the dispel-ring mechanism's own doc comment further down -- +-- style.dispelBorder/dispelColorMap is a one-way write, never a read) -- so +-- the only fix is making sure Fx-targeted categories get their own group, +-- same as if the user had manually enabled that Base Filter. +local function PAB_FxWantsCategory(list, key) + if not (list and key) then return false end + for i = 1, #list do + local e = list[i] + if PAB_FxEntryActive(e) and e.filters and e.filters[key] then return true end + end + return false +end + +-- Whether PAB_FxWantsCategory is safe to force a class active while the +-- catch-all group is also present (2026-08-04, after field report: forcing +-- "dispeltyped" active this way duplicated every matching debuff -- once +-- via its own group, once via catch-all, since candidate classes have no +-- string token for BuildChain's negation chain to exclude them with). +-- Token classes are always safe (BuildChain already negates them with +-- "!TOKEN" into every later group). The dispel-typed candidate class is +-- now also safe -- BuildChain propagates a matching excludeDispelTypes +-- candidateFilter forward once it's active. The three boolean candidate +-- classes (bossaura/roleaura/priorityaura) have no confirmed Blizzard +-- "exclude" counterpart anywhere in this codebase or RaidFrames' own +-- DebuffManager (same isBossAura/isRoleAura/isPriorityAura-only usage +-- there) -- forcing those would reintroduce the duplicate-icon bug, so +-- Icon Effects for those three still require Show All Debuffs off + the +-- matching Base Filter on, same limitation as before this feature existed. +local function PAB_FxSafeToForce(class) + return not (class.cand == "isBossAura" or class.cand == "isRoleAura" or class.cand == "isPriorityAura") +end + +-- Filter vocabulary for the Icon Effects UI (debuffs only): the same +-- curated DEBUFF_FILTER_ORDER list ns.PAB_ClassItems(false) uses, but keyed +-- by the lowercase ENGINE group key (class.key, e.g. "bossaura") instead of +-- the CamelCase classFilters key (class.skey, e.g. "BossAura") -- fxList +-- blocks match against d.dmCat, which is stamped with class.key (see +-- ApplyGroupConfig's extraInit below). No catch-all "all" entry, matching +-- Raid Frames' own TILE_FILTER_ITEMS (which has none either). +function ns.PAB_FxClassItems() + local items = {} + for i = 1, #DEBUFF_FILTER_ORDER do + local class = ClassByKey(DEBUFF_FILTER_ORDER[i]) + if class then + local meta = CLASS_LABELS[class.skey] + items[#items + 1] = { + key = class.key, + label = meta and meta[1] or class.key, + tooltip = meta and meta[2] or nil, + } + end + end + return items +end + +-- Ported from Raid Frames' ApplyDmFx (EUI_RaidFrames_AuraContainers.lua): +-- Icon Glow (EllesmereUI.Glows overlay) + Border override (EllesmereUI.PP), +-- keyed off the button's stamped category (d.dmCat, set once at creation by +-- ApplyGroupConfig's extraInit, see below). Still a no-op for buffs +-- (style.fxList is never set on a buff style, so this never even gets +-- called there). For debuff buttons it now always pre-makes the glow/border +-- overlay frames (see their own doc comments below) even when no block +-- currently matches this button's category -- a small one-time cost per +-- button, same trade-off EllesmereUI_AuraKit.lua's dispelHolder already +-- makes, so a later Icon Effects change never needs a /reload to take. + +local function PAB_ApplyDmFx(button, d, style) + local cat = d.dmCat + local e = style.fxList and PAB_FxBlockFor(style.fxList, cat) or nil + + local Glows = EllesmereUI.Glows + local PP = EllesmereUI.PP + local gType = (e and e.glowType) or 0 + -- ALWAYS remap driver-ticked styles (Pixel/Action Button/Auto-Cast/ + -- Shape) to their FlipBook-safe equivalent -- unconditional, not gated + -- on AK.AurasRestricted() (2026-08-04, reverted after a field crash: + -- "Attempt to access forbidden object from code tainted by an AddOn" on + -- wrapper:IsVisible() inside EllesmereUI_Glows.lua's central driver, 4813x + -- in one session). AurasRestricted() only reflects whether AURA DATA is + -- currently secret (combat/instance-gated); it says nothing about + -- whether a Lua OnUpdate is allowed to touch a frame parented to a 12.1 + -- engine aura button, which is apparently forbidden UNCONDITIONALLY, + -- not just during combat. Only FlipBook styles (GCD/Modern/Classic) are + -- safe here -- they run on C-side AnimationGroups and never register + -- with the driver's wrapper:IsVisible() polling loop at all. + if gType > 0 and Glows and Glows.RestrictionSafeStyle then + gType = Glows.RestrictionSafeStyle(gType) + end + + -- Icon Glow overlay: created UNCONDITIONALLY here, regardless of whether + -- a block matches right now (2026-08-04, fixes "need /reload after + -- turning a glow on for a filter that was previously None"). This + -- function's first call happens inside the button's one legal + -- creation-window (extraInit, see AddGroupToContainer below); every + -- later call is a RestyleSoon pass, which is NOT that window -- + -- CreateFrame-parenting a NEW frame to the secure engine button then is + -- not guaranteed-legal, same reasoning EllesmereUI_AuraKit.lua's + -- dispelHolder comment gives for why IT is created unconditionally too. + -- Pre-making the frame here means a later glow-on/off toggle is only + -- ever Show/Hide + StartGlow/StopGlow, which is always legal. + local gov = d.pabFxGlow + if not gov then + gov = CreateFrame("Frame", nil, button) + gov:SetAllPoints(button) + -- Above both the base border (base+1) and the fx border override + -- (base+2), below the dispel ring (base+4) and text (base+5) -- + -- same level ladder Raid Frames' ApplyDmFx uses (border < fx + -- border < glow < dispel ring < text). Own level (base+3), not + -- shared with the fx border override's container (base+2) -- + -- see EllesmereUI_AuraKit.lua's dispelHolder comment. + local base = (d.borderHost and d.borderHost:GetFrameLevel()) + or (button:GetFrameLevel() + 1) + gov:SetFrameLevel(base + 3) + gov:EnableMouse(false) + gov:Hide() + d.pabFxGlow = gov + end + if gType > 0 and Glows and Glows.StartGlow then + gov:Show() + local cr, cg, cb = e.glowR or 1.0, e.glowG or 0.776, e.glowB or 0.376 + if e.glowClassColor then + local _, classFile = UnitClass("player") + local cc = classFile and RAID_CLASS_COLORS and RAID_CLASS_COLORS[classFile] + if cc then cr, cg, cb = cc.r, cc.g, cc.b end + end + local sz = style.width or 18 + if (not gov._euiGlowActive) or gov._fxStyle ~= gType or gov._fxW ~= sz + or gov._fxCR ~= cr or gov._fxCG ~= cg or gov._fxCB ~= cb then + Glows.StartGlow(gov, gType, sz, cr, cg, cb) + gov._fxStyle, gov._fxW = gType, sz + gov._fxCR, gov._fxCG, gov._fxCB = cr, cg, cb + end + else + if gov._euiGlowActive and Glows and Glows.StopGlow then Glows.StopGlow(gov) end + gov:Hide() + end + + -- Border override: same creation-window pre-make as the glow above. + local bSize = (e and e.borderSize) or 0 + local host = d.pabFxBdr + if not host and PP then + host = CreateFrame("Frame", nil, button) + host:SetAllPoints(button) + local base = (d.borderHost and d.borderHost:GetFrameLevel()) + or (button:GetFrameLevel() + 1) + host:SetFrameLevel(base + 1) + host:EnableMouse(false) + PP.CreateBorder(host, 0, 0, 0, 1, 1) + host:Hide() + d.pabFxBdr = host + end + if bSize > 0 and host and PP then + local bc = e.borderColor or { r = 0, g = 0, b = 0 } + PP.UpdateBorder(host, bSize, bc.r or 0, bc.g or 0, bc.b or 0, 1) + host:Show() + elseif host then + host:Hide() + end +end + +------------------------------------------------------------------------------- +-- Class-enabled check and mutual-exclusion chain builder +-- +-- Adapted from EUI_UnitFrames_AuraContainers.lua's ClassEnabled/BuildChain +-- (not shared as functions -- only Joel-approved sharing is the two class +-- tables above; the settings-lookup shape here is Player Aura Bars' own). +-- Same algorithm: token classes negate every earlier-enabled token class +-- before them (mutual exclusion, priority = declaration order); candidate +-- classes sit after the full token negation chain and are boolean engine +-- selectors, not addable to the token chain itself. +------------------------------------------------------------------------------- + +local function ClassEnabled(class, isBuff, cfg) + if class.buffOnly and not isBuff then return false end + if class.debuffOnly and isBuff then return false end + -- "Show All Debuffs": bypass every class toggle without touching the + -- saved classFilters table, so turning it back off restores exactly + -- what was configured before. Debuffs only -- buffs no longer read + -- classFilters at all (BM2/filters model, see engine-wiring section). + -- ~= false (not == true): defaults to ON like showAllBuffs, so nil + -- (unconfigured bar) behaves the same as an explicit true -- 2026-08-02 + -- symmetry fix, matches showAllBuffs' own "nil == on" convention (see + -- BuildAssignedBuffsFields' doc comment, which used to flag this as the + -- one deliberate asymmetry between the two). + if not isBuff and cfg.showAllDebuffs ~= false then return false end + -- playerUnitOnly classes (currently just "nonplayer") always apply -- + -- this module only ever targets the player unit. + return cfg.classFilters and cfg.classFilters[class.skey] == true +end + +-- includeCatchAll (default true, matches every pre-existing caller): the +-- default Debuffs bar and the buff-side {base}-only shortcut both want +-- "every remaining aura of this polarity" appended after the per-class +-- groups. Custom Debuff Bars with Show All Debuffs off do NOT want that -- +-- their whole point is to show ONLY the selected classes, but the catch-all +-- was being appended unconditionally, so a bar restricted to e.g. "Big +-- Defensive" also rendered every other debuff via the "all" group. Callers +-- now pass includeCatchAll = false whenever the UI's own "Base Filters +-- dropdown restricts what's shown" promise (see BuildAssignedDebuffsFields' +-- tooltip) needs to actually hold. +-- excludeDispelTypes propagation (2026-08-04): candidate classes (unlike +-- token classes) have no string token to negate forward with, so +-- "dispeltyped" (includeDispelTypes) was never excluded from later groups +-- -- harmless as long as it could only ever be active while Show All +-- Debuffs was off (which also disables the catch-all), but PAB_FxWantsCategory +-- below can now force it active WHILE the catch-all is still included, +-- which duplicated every matching debuff (once via "dispeltyped", once via +-- "all"). Fix: once the dispel-typed candidate class is enabled, every +-- chain link built AFTER it (including the catch-all) gets a matching +-- excludeDispelTypes candidateFilter -- same verified Blizzard mechanism +-- RaidFrames' DebuffManager already uses (EUI_RaidFrames_DebuffManager.lua +-- ~line 491: "cf.excludeDispelTypes = TYPED_DEBUFFS"). +local function BuildChain(base, classEnabledFn, includeCatchAll) + local chain, negations = {}, {} + local excludeDispelTypes + local tokenClasses = VisibleTokenClasses() + local candidateClasses = VisibleCandidateClasses() + if not (tokenClasses and candidateClasses) then return chain end + + local function ExtraCand() + return excludeDispelTypes and { excludeDispelTypes = excludeDispelTypes } or nil + end + + for i = 1, #tokenClasses do + local class = tokenClasses[i] + if classEnabledFn(class) then + local tokens = { base, class.token } + for n = 1, #negations do tokens[#tokens + 1] = negations[n] end + chain[#chain + 1] = { key = class.key, tokens = tokens, excludeCand = ExtraCand() } + negations[#negations + 1] = class.neg or ("!" .. class.token) + end + end + for i = 1, #candidateClasses do + local class = candidateClasses[i] + if classEnabledFn(class) then + local tokens = { base } + for n = 1, #negations do tokens[#tokens + 1] = negations[n] end + chain[#chain + 1] = { key = class.key, tokens = tokens, cand = class.cand, candValue = class.candValue, excludeCand = ExtraCand() } + if class.cand == "includeDispelTypes" then + excludeDispelTypes = class.candValue + end + end + end + + if includeCatchAll ~= false then + -- Catch-all group LAST: everything not claimed by an enabled class, + -- negating the full chain built above. With zero classes enabled + -- this is just { base }, i.e. every aura of that polarity. + local allTokens = { base } + for n = 1, #negations do allTokens[#allTokens + 1] = negations[n] end + chain[#chain + 1] = { key = "all", tokens = allTokens, excludeCand = ExtraCand() } + end + + return chain +end + +------------------------------------------------------------------------------- +-- Container spec construction +-- +-- STEP A style: bare initializer (icon only, no cooldown/duration/stack/ +-- border yet). AK.MakeInitializer with extra=nil still creates the standard +-- icon/cooldown/text regions (see AuraKit.lua's MakeInitializer) since +-- style.noRegions is not set -- this deliberately does NOT use noRegions, +-- so Step B only has to ADD styling, not restructure region creation. +------------------------------------------------------------------------------- + +local STYLE_BUFFS = "playerAuraBars_buffs" +local STYLE_DEBUFFS = "playerAuraBars_debuffs" +local STYLE_EXTDEF = "playerAuraBars_extDef" + +-- STEP B: real styling. Field names verified against AK's own +-- ApplyStyleToRegions (EllesmereUI_AuraKit.lua) -- not guessed. +-- +-- Settings schema (db.profile.playerAuraBars), Step B fields moved into +-- per-bar cfg tables during the Custom Bars increment: s.defaultBuffs / +-- s.defaultDebuffs (DefaultBuffsCfg/DefaultDebuffsCfg below), and every +-- custom bar object (see the CRUD section further down) carries the same +-- shape. No migration from the old flat s.iconSize/s.buffIconZoom/etc +-- fields, or from the old db.profile.playerAuras fields before that -- +-- see Joel's "keine Migration, aber Default Buffs/Debuffs anlegen" note. +-- BuildStyle/ComputeGrid/ClassEnabled all take (isBuff, cfg) and don't care +-- which of the above a given cfg table came from. +-- +-- Shared by every bar: +-- iconSize (button width == height) +-- durationShow, stackShow (independent show/hide, replaces the +-- old combined showText -- no migration, +-- same "leave the old field stale" +-- precedent as everywhere else here) +-- durationTextSize, durationPosition ("TOP"/"BOTTOM"/...), durationOffsetX/Y +-- durationColorR/G/B (optional; nil = white, AK default) +-- stackTextSize, stackPosition, stackOffsetX/Y +-- stackColorR/G/B (optional; nil = white, AK default) +-- Buff/debuff bars additionally: +-- iconZoom (default 0.07, matches AK's own fallback) +-- borderSize, borderR/G/B/A (base border color; per-dispel-type +-- override is Step C, not this) +-- padding (single scalar -> applied to all 4 sides) +-- rowSpacing (optional; row-to-row gap override, i.e. +-- lineSpacing/groupLineSpacing only -- +-- nil falls back to `padding`, same +-- value ComputeGrid used for both before +-- this field existed. elementSpacing/ +-- groupSpacing (icon-to-icon within a +-- row) always stay tied to `padding`.) +-- maxTotal (overall icon cap) +-- iconsPerRow (row width in icon columns) +-- maxRows (row cap; combined with iconsPerRow this +-- also bounds maxTotal -- see ComputeGrid()) +-- growDirection ("LEFT"/"RIGHT"; default LEFT, matches a +-- TOPRIGHT anchor growing inward like +-- Blizzard's own BuffFrame) +-- Buff bars (default AND custom -- unified onto one model 2026-08-01): +-- filters ([filterId]=true, references the +-- shared PAB Filters registry) +-- spells ({spellID,...}, direct/"Extra Spells") +-- showAllBuffs (default bar ONLY -- custom buff bars +-- never read this. Defaults to true: +-- without it, an unconfigured bar +-- (no Filters/Extra Spells) would show +-- nothing; true adds one additive +-- catch-all GROUP alongside the +-- spells group, matching both +-- Blizzard's own player BuffFrame and +-- this bar's pre-redesign default. UI +-- toggle: "Show All Buffs" in +-- BuildAssignedBuffsFields, mirrors +-- Show All Debuffs.) +-- (classFilters has NO effect on buff bars any more -- BuildChain is only +-- used via the always-catch-all showAllBuffs group, never per-class.) +-- Debuff bars (default AND custom): +-- classFilters ([classSkey]=true) +-- showAllDebuffs (bypasses classFilters entirely unless +-- explicitly false. Defaults to TRUE +-- (nil == on) as of 2026-08-02, mirroring +-- showAllBuffs' own default -- previously +-- defaulted to false/off, unlike +-- RaidFrames' DebuffManager "Show All" +-- which always defaulted to true) +-- dispelColorMagic/Curse/Disease/Poison/Bleed (optional Color-like {r,g,b}; +-- falls back to the same palette as +-- Raid Frames if unset) +-- +-- NOT carried over from the old module: durationFormat variants ("colon"/ +-- "seconds"). AK.GetDurationFormatter() returns ONE shared formatter instance +-- (the default rule-based style: bare seconds under 60, then Xm/Xh/Xd) -- it +-- does not expose a way to pick a different formatter per style. Supporting +-- the old colon/seconds variants would mean extending AuraKit itself (shared +-- file, used by Raid Frames too), not something to do silently inside this +-- module. Flagging this rather than dropping it without a word -- let me know +-- if that variant matters enough to be worth extending AK for. +-- Local copy of the dispel-token/fallback-color table from +-- EUI_RaidFrames_AuraContainers.lua's DISPEL_SLOTS. NOT a cross-addon +-- reference (ns is per-addon-private, confirmed with Joel 2026-07-29, +-- RaidFrames and UnitFrames are separate addons) -- duplicated here on +-- purpose, keeping the same tokens/fallback colors so dispel-type coloring +-- looks consistent across the whole suite. If the RaidFrames palette +-- changes, this needs to be updated by hand; there is no shared source. +local DISPEL_SLOTS = { + { token = "Magic", colorKey = "dispelColorMagic", fallback = { 0.349, 0.475, 1.0 } }, + { token = "Curse", colorKey = "dispelColorCurse", fallback = { 0.636, 0.0, 0.64 } }, + { token = "Disease", colorKey = "dispelColorDisease", fallback = { 0.671, 0.384, 0.098 } }, + { token = "Poison", colorKey = "dispelColorPoison", fallback = { 0.0, 0.706, 0.286 } }, + { token = "Bleed", colorKey = "dispelColorBleed", fallback = { 0.75, 0.15, 0.15 } }, +} + +-- Same shape as RaidFrames' ns.RFC_DispelBorderColorMap: a +-- customDispelColorMap (dispelName string -> Color) for AK's engine-driven +-- border, plus a fingerprint string so a palette edit re-registers the +-- border options (see AK's style.dispelColorFP usage). +local function BuildDispelColorMap(cfg) + local map, fp = {}, {} + for i = 1, #DISPEL_SLOTS do + local def = DISPEL_SLOTS[i] + local c = cfg[def.colorKey] + local r = (c and c.r) or def.fallback[1] + local g = (c and c.g) or def.fallback[2] + local b = (c and c.b) or def.fallback[3] + map[def.token] = CreateColor(r, g, b, 1) + fp[#fp + 1] = string.format("%.3f,%.3f,%.3f", r, g, b) + end + return map, table.concat(fp, ";") +end + +-- Standard WoW anchor-point mirror, used to place duration text OUTSIDE the +-- icon on a chosen side: text's OWN point is the opposite corner/edge of the +-- icon's anchor point, so e.g. picking "TOP" anchors the text's BOTTOM edge +-- to the icon's TOP edge (text sits above, growing upward) rather than the +-- other way around. +local OPPOSITE_POINT = { + TOP = "BOTTOM", BOTTOM = "TOP", LEFT = "RIGHT", RIGHT = "LEFT", + TOPLEFT = "BOTTOMRIGHT", TOPRIGHT = "BOTTOMLEFT", + BOTTOMLEFT = "TOPRIGHT", BOTTOMRIGHT = "TOPLEFT", + CENTER = "CENTER", +} + +-- Secondary text-styling pass, run by AK on top of its own native +-- position/size handling (verified field/hook: EUI_UnitFrames_AuraContainers +-- .lua's BuildStyle sets `applyExtra = ApplyUFText`, called as +-- (button, d, style); `d.duration`/`d.stack` are the FontStrings AK's own +-- initializer creates). Deliberately does NOT touch font/position/size -- +-- those are already handled correctly by AK's native durationPoint/ +-- durationX/Y/stackPoint/stackX/Y fields below (Step B, already live) -- +-- this hook ONLY adds what AK has no native field for: duration/stack text +-- color and independent stack-text show/hide (AK's own hideDurationText +-- covers duration hide already; there is no equivalent native field for +-- stacks, confirmed by its absence from every AK-native style field used +-- in this file so far). +local function PAB_ApplyExtraText(button, d, style) + if d.duration then + local c = style.durationColor + d.duration:SetTextColor(c and c.r or 1, c and c.g or 1, c and c.b or 1) + end + if d.stack then + d.stack:SetShown(style.showStacks ~= false) + local c = style.stackColor + d.stack:SetTextColor(c and c.r or 1, c and c.g or 1, c and c.b or 1) + end + + -- hideSwipe (BuildStyle, unconditionally true for every PAB style) only + -- runs through AK's own ApplyStyleToRegions, which fires at button + -- CREATION and on explicit Restyle passes -- NOT on ordinary aura + -- content churn. Blizzard's own engine calls something equivalent to + -- Cooldown:SetCooldown(...) on d.cooldown internally every time that + -- slot's aura data refreshes (new aura in the slot, duration change, + -- ...), and that native Blizzard Cooldown API implicitly re-Shows the + -- frame as a side effect -- confirmed in-game 2026-08-02: the swipe + -- disappeared right after a restyle but kept reappearing on ordinary + -- aura updates, i.e. SetShown(false) was being silently undone by + -- Blizzard's own code, not failing to apply in the first place. Same + -- "Blizzard keeps re-showing this" pattern already used in this file + -- for BuffFrame/DebuffFrame (HideBlizzardPlayerAuras) -- hooksecurefunc + -- runs AFTER Blizzard's call completes, not from inside it, so this + -- does not taint anything. Installed once per button (guarded, not + -- per-restyle) since the hook itself never needs to change -- PAB never + -- offers a UI toggle for hideSwipe, it is unconditional for every style + -- this module owns. + if d.cooldown and not d._pabSwipeHooked then + d._pabSwipeHooked = true + hooksecurefunc(d.cooldown, "Show", function() d.cooldown:Hide() end) + + -- Belt-and-suspenders (2026-08-02, after field reports that the + -- swipe still reappeared post-instance-change despite the Show + -- hook above): SetAlpha(0) is a visibility property Show()/Hide() + -- never touch, so it survives whatever internal path Blizzard's + -- engine used to make the frame visible again -- unlike the Show + -- hook, it doesn't depend on THAT specific method being the one + -- Blizzard's engine actually called on that path. Set once, same + -- guard as the hook above; a Shown-but-alpha-0 cooldown frame is + -- still invisible regardless of which Show-adjacent call re-armed + -- it. + d.cooldown:SetAlpha(0) + end + + -- Icon Effects Per-Filter (debuffs only): flag-gated so buff buttons, + -- which never carry style.fxList or fx overlay frames, pay zero cost. + if style.fxList or d.pabFxGlow or d.pabFxBdr then + PAB_ApplyDmFx(button, d, style) + end +end + +local function BuildStyle(isBuff, cfg) + local iconZoom = cfg.iconZoom + local borderSize = cfg.borderSize or 1 + local borderR = cfg.borderR or 0 + local borderG = cfg.borderG or 0 + local borderB = cfg.borderB or 0 + local borderA = cfg.borderA or 1 + + -- size 0 = no border (matches the Options page: the old separate "Hide + -- Border" toggle was removed since Border Size = 0 already achieves it). + local border + if borderSize > 0 then + border = { borderR, borderG, borderB, borderA, size = borderSize } + end + + -- Duration/Stack position: defaults may arrive as "Bottom"/"Top" (mixed + -- case) rather than the exact uppercase Blizzard anchor constants + -- SetPoint expects -- normalized here so a mismatched-case default or + -- Options write can't silently mis-anchor or error. + local durSide = string.upper(cfg.durationPosition or "BOTTOM") + local stackSide = string.upper(cfg.stackPosition or "TOP") + + -- Snapped to the physical pixel grid (same reasoning as MaxIconSizeFor/ + -- ApplyGroupConfig above) so the button's actual rendered size agrees + -- with the container's cross-axis extent math at any UIParent scale. + local iconSize = cfg.iconSize or 32 + do + local PPa = EllesmereUI and EllesmereUI.PP + if PPa and PPa.Scale then iconSize = PPa.Scale(iconSize) end + end + + local style = { + width = iconSize, + height = iconSize, + iconCrop = true, + iconZoom = iconZoom or 0.055, + + cooldownReverse = true, + -- Always hidden (2026-08-02, Joel's explicit request): the swipe is + -- the darkening radial overlay CooldownFrameTemplate draws over the + -- icon as remaining time shrinks -- distinct from the duration + -- NUMBER text below (hideDurationText), which stays independently + -- controllable. See EllesmereUI_AuraKit.lua's MakeInitializer: + -- hideSwipe just does d.cooldown:SetShown(false), no cfg field/UI + -- toggle needed since it's unconditional here. + hideSwipe = true, + + -- Right-click to cancel (2026-08-02): mirrors Blizzard's own player + -- BuffFrame / Edit Mode behavior and EUI_UnitFrames_AuraContainers + -- .lua's own `cancelButtons = (unit == "player" and isBuff) and + -- "RightButtonUp"` -- PAB is always the player unit, so only the + -- isBuff half of that condition applies here (debuffs were never + -- player-cancelable in Blizzard's own UI either). AK wires this + -- straight through to the engine's AuraButtonMixin: + -- SetCancelAuraButtons -- same secure click-to-cancel Blizzard uses, + -- not a hand-rolled macro/attribute setup. + cancelButtons = isBuff and "RightButtonUp" or nil, + + hideDurationText = cfg.durationShow == false, + durationFontSize = cfg.durationTextSize or 11, + durationPoint = OPPOSITE_POINT[durSide] or "TOP", + durationRelPoint = durSide, + durationX = cfg.durationOffsetX or 0, + durationY = cfg.durationOffsetY or 0, + durationColor = cfg.durationColorR and + { r = cfg.durationColorR, g = cfg.durationColorG, b = cfg.durationColorB } or nil, + + stackFontSize = cfg.stackTextSize or 11, + stackPoint = stackSide, + stackX = cfg.stackOffsetX or 0, + stackY = cfg.stackOffsetY or 0, + showStacks = cfg.stackShow ~= false, + stackColor = cfg.stackColorR and + { r = cfg.stackColorR, g = cfg.stackColorG, b = cfg.stackColorB } or nil, + + applyExtra = PAB_ApplyExtraText, + + border = border, + } + + -- STEP C: engine dispel-type border. Debuffs only -- buffs have no + -- dispel type. Per AK's own gate (ApplyStyleToRegions), this only + -- activates when `border` above is ALSO non-nil -- i.e. borderSize = 0 + -- disables dispel-type coloring too, not just the static ring. That is + -- the engine's behavior, not a choice made here. + -- borderSize drives BOTH the static ring width above AND this engine + -- dispel-color ring width -- the separate dispelBorderSize field/UI row + -- was merged away; if a distinct dispel-ring width is wanted again + -- later, split this back out into its own setting. + if not isBuff then + local dcMap, dcFP = BuildDispelColorMap(cfg) + style.dispelBorder = true + style.dispelBorderPx = borderSize + style.dispelColorMap = dcMap + style.dispelColorFP = dcFP + + -- Icon Effects Per-Filter: debuffs only (buffs never get style.fxList, + -- so PAB_ApplyDmFx's gate in PAB_ApplyExtraText stays a cheap no-op). + style.fxList = PAB_FxListView(cfg.fxList) + end + + return style +end + +-- Declares/updates every group in `chain` on an existing (already-created) +-- container. Groups are ADDITIVE and the container is NEVER torn down and +-- rebuilt: AK.ReleaseContainer()+RequestContainer() would work but permanently +-- leaks a 10-button engine batch per group per swap (frames are never freed +-- by WoW) -- confirmed as an accepted-then-fixed problem in the sibling +-- module's history (EUI_UnitFrames_AuraContainers.lua: "The old swap path +-- permanently leaked a 10-button batch per group per toggle"). This function +-- mirrors that module's ApplyGroupConfig instead: a class toggle (or grid/ +-- padding/growth change) just calls this again on the SAME container. +-- +-- A group's filter string is fixed at declaration (no group filter setter +-- exists on AuraContainer), so a class that was already declared under one +-- set of enabled classes keeps its original negation-chain tokens even if an +-- earlier-priority class gets enabled/disabled later -- the same known, +-- accepted limitation the sibling module carries (not something introduced +-- here). In practice this only matters if the user re-orders which classes +-- are active in a way that changes an ALREADY-DECLARED class's negation set; +-- toggling the SAME class on/off again is always correct since its own +-- filter never needs to change, only its maxFrameCount. +-- +-- Resolves a bar cfg's sortMethod/sortDirection into the native +-- AuraContainerSortMethod/AuraContainerSortDirection enum values (globals, +-- confirmed in-game 2026-08-03: AuraContainerSortMethod = {Default=0, +-- BigDefensive=1, UnitFrameDebuff=2, ImportantOnly=3, Expiration=4, +-- ExpirationOnly=5, Name=6, NameOnly=7, AuraInstanceIDOnly=8}, +-- AuraContainerSortDirection = {Normal=0, Reverse=1}). Default=0 and +-- Normal=0 are valid values, not "unset" -- callers must compare against +-- nil, never truthiness (same requirement the Nameplates module's own +-- sort wiring documents). +local function ResolveSortMethod(cfg) + local key = cfg.sortMethod or "Default" + return AuraContainerSortMethod and AuraContainerSortMethod[key] +end +local function ResolveSortDirection(cfg) + local key = (cfg.sortDirection == "Reverse") and "Reverse" or "Normal" + return AuraContainerSortDirection and AuraContainerSortDirection[key] +end + +-- "Has Duration" (Assigned Buffs filter, 2026-08-03) -- buffs-only, native +-- `candidateFilters.maxDuration` (verified against Blizzard's actual PTR +-- source, Gethe/wow-ui-source ptr branch, Blizzard_AuraContainerUtil.lua: +-- "Max duration filters implicitly always filter out permanent auras" -- +-- `auraData.duration > maxDuration or auraData.duration == 0` excludes the +-- aura). `math.huge` as the cap means the `>` half of that check never +-- trips, so this ONLY excludes permanent (duration=0) buffs, regardless of +-- how long a timed buff's duration actually is. +local function BuffCandidateExtras(cfg) + if cfg and cfg.hasDuration then + return { maxDuration = math.huge } + end + return nil +end + +-- Merges `extra`'s keys onto a copy of `base` (nil-safe both ways). Used to +-- combine a chain-link's own candidateFilters (e.g. debuff class token) with +-- BuffCandidateExtras' maxDuration -- both may be nil, either alone, or both +-- present at once. +local function MergeCandidateFilters(base, extra) + if not extra then return base end + if not base then return extra end + local out = {} + for k, v in pairs(base) do out[k] = v end + for k, v in pairs(extra) do out[k] = v end + return out +end + +-- declaredSet is a per-container registry of every group key ever declared +-- on that container: declared.debuffs for the default Debuffs bar/every +-- custom Debuff Bar (class-token chain), declared.buffs for the default +-- Buffs bar's single "Show All Buffs" catch-all group (see CreateBars' +-- doc comment for why buffs still need ONE group alongside their slots). +-- +-- cfg is the bar's own settings table (buffCfg/debuffCfg/a custom bar entry) +-- -- read-only here, only used to resolve sortMethod/sortDirection so every +-- chain group on this container gets the bar's configured sort. The direct +-- setter (SetAuraGroupSortMethod, unlike AddAuraGroup's sortMethod field) +-- requires both values non-nil, so it's re-applied every pass, same as +-- MaxFrameCount/Layout below. +-- +-- extraCand (optional): additional candidateFilters merged onto every +-- chain-link's own candidateFilters (see MergeCandidateFilters above) -- +-- used to thread BuffCandidateExtras' maxDuration onto the Buffs catch-all +-- chain without affecting Debuffs' class-token chains, which pass nil here. +-- Like sortMethod/sortDirection, re-applied live every pass via +-- SetAuraGroupCandidateFilters -- candidateFilters are NOT immutably fixed +-- at declaration once a live setter is used (only the group's FILTER STRING +-- is; see the doc comment above this function). +-- Icon Effects Per-Filter Size override: PAB's icon size is entirely +-- style-driven (ApplyStyleToRegions's button:SetSize(style.width, +-- style.height), EllesmereUI_AuraKit.lua -- a group's own elementWidth/ +-- Height in SetAuraGroupLayout feeds the FLOW MATH only, confirmed by that +-- file's own doc comment, and never resizes the button itself). A +-- per-category size therefore needs its OWN style key, not a group-layout +-- tweak (mirrors Raid Frames' DebuffManager EnsureBaseSizeStyle). Keyed by +-- size alone (not size+category): any two categories overridden to the same +-- size can share one variant, since the variant is just the bar's base +-- style with width/height swapped -- everything else (border, dispel +-- colors, fxList, applyExtra) rides along unchanged via the shallow copy. +-- Rebuilt unconditionally every ApplyGroupConfig pass (cheap: settings-apply +-- frequency, not per-frame) rather than fingerprint-cached, matching this +-- file's existing BuildStyle+RestyleSoon convention elsewhere. +local function EnsurePabSizedStyle(baseKey, size) + local base = AK.styles[baseKey] + if not base then return baseKey end + local variantKey = baseKey .. ":sz" .. tostring(size) + local v = {} + for k, val in pairs(base) do v[k] = val end + v.width = size + v.height = size + AK.styles[variantKey] = v + AK.RestyleSoon(variantKey) + return variantKey +end + +local function ApplyGroupConfig(container, chain, declaredSet, styleKey, effectiveMax, gap, rowGap, cfg, extraCand) + local sortMethod = ResolveSortMethod(cfg) + local sortDirection = ResolveSortDirection(cfg) + -- elementSpacing = gap between icons in the same row; lineSpacing = gap + -- between wrapped rows within a group; group*Spacing = gap to the NEXT + -- group on the same container. elementSpacing/groupSpacing stay tied to + -- `gap` (padding); lineSpacing/groupLineSpacing use `rowGap` (defaults to + -- `gap` when not passed, i.e. the pre-rowSpacing-field behavior) so row- + -- to-row distance can be overridden independently of icon-to-icon + -- spacing (see cfg.rowSpacing in the Settings Schema doc comment). + -- Container-level padding is a THIRD, unrelated concept: the OUTER edge + -- inset, fixed at 0 elsewhere and never affected by either of these. + rowGap = rowGap or gap + -- Snap to the physical pixel grid before handing these to the native + -- engine's cross-axis layout math (SetAuraGroupLayout below): raw + -- (unsnapped) values sometimes land the engine's OWN internal row + -- positioning a hair off a pixel boundary at a non-pixel-perfect + -- UIParent scale, which our own border (independently pixel-perfect via + -- GetEffectiveScale, confirmed via /eupabscale) then visibly disagrees + -- with by ~1px. Reproduced 2026-08-06: at UI scale 0.7111, rowSpacing=8 + -- and 17 (out of a full 1-20 sweep, everything else clean) showed the + -- border defect on a 3-row Buffs bar; nothing else about those two + -- values stood out, consistent with an engine-side rounding edge case + -- rather than an addon-side formula. Pre-snapping removes the room for + -- that mismatch regardless of the engine's exact internal rounding. + local PPa = EllesmereUI and EllesmereUI.PP + if PPa and PPa.Scale then + gap = PPa.Scale(gap) + rowGap = PPa.Scale(rowGap) + end + local layout = { + elementSpacing = gap, + lineSpacing = rowGap, + groupSpacing = gap, + groupLineSpacing = rowGap, + } + + local active = {} + for i = 1, #chain do + local link = chain[i] + -- Icon Effects Per-Filter Size override (debuffs only -- cfg.fxList + -- is nil for buffs, so szOv is always nil there and effKey == + -- link.key, identical to before this feature existed). + local szOv = PAB_FxSizeFor(cfg.fxList, link.key) + local effKey = szOv and (link.key .. "|sz") or link.key + local linkStyleKey = szOv and EnsurePabSizedStyle(styleKey, szOv) or styleKey + active[effKey] = true + local candidateFilters + if link.cand then + candidateFilters = { [link.cand] = link.candValue or true } + end + candidateFilters = MergeCandidateFilters(candidateFilters, link.excludeCand) + candidateFilters = MergeCandidateFilters(candidateFilters, extraCand) + if not declaredSet[effKey] then + local catKey = link.key + AK.AddGroupToContainer(container, { + key = effKey, + filter = link.tokens, + style = linkStyleKey, + maxFrameCount = 0, -- real count applied right below, matches the sibling module's declare-then-set order + candidateFilters = candidateFilters, + sortMethod = sortMethod, + sortDirection = sortDirection, + -- Icon Effects Per-Filter: stamps this button's category + -- (matched against fxList blocks by PAB_ApplyDmFx) and arms + -- the glow/border overlay inside the one legal creation + -- window, mirroring Raid Frames' DebuffManager tile + -- extraInit (style.applyExtra runs BEFORE extraInit at + -- creation, verified in EllesmereUI_AuraKit.lua's + -- MakeInitializer -- so the first applyExtra pass sees + -- d.dmCat == nil harmlessly, and this re-arms it right after). + extraInit = function(button, d, style) + d.dmCat = catKey + PAB_ApplyDmFx(button, d, style) + end, + }) + declaredSet[effKey] = true + end + container:SetAuraGroupMaxFrameCount(effKey, effectiveMax) + container:SetAuraGroupLayout(effKey, layout) + container:SetAuraGroupCandidateFilters(effKey, candidateFilters) + if sortMethod ~= nil and sortDirection ~= nil then + container:SetAuraGroupSortMethod(effKey, sortMethod, sortDirection) + end + end + + -- Zero out any previously-declared group that fell out of the active + -- chain (a class just got disabled). It stays declared -- just hidden -- + -- since it can't be un-declared. + for key in pairs(declaredSet) do + if not active[key] then + container:SetAuraGroupMaxFrameCount(key, 0) + end + end +end + +-- Grid sizing. AK's flow layout only exposes a row WIDTH (pixels) to wrap +-- on -- there is no native "max lines" cap (verified: Blizzard_AuraContainer- +-- FlowLayout.lua only has SetMaximumLineSize, nothing row-count based). A +-- row cap is therefore enforced indirectly by capping maxFrameCount to +-- rows*cols, and the anchor frame's real footprint is our own bounding-box +-- estimate from the same three numbers -- not something AK reports back. +-- +-- New settings (Step D addition, alongside maxTotal, the existing overall +-- cap): iconsPerRow (columns), maxRows. Effective cap = min(configured +-- maxTotal, maxRows * iconsPerRow) -- e.g. maxTotal=10 with a 5x5 grid +-- still shows at most 10, using 2 rows. +-- +-- iconsPerRow/maxRows/maxTotal fallbacks are isBuff-conditional (11x3=32 for +-- buffs, 8x2=16 for debuffs) -- only reached when a bar doesn't set these +-- fields itself, i.e. the two default bars (Joel's chosen starting values, +-- 2026-08-02). Every custom bar sets all three explicitly at creation +-- (PAB_AddCustomBuffBar/DebuffBar, 8x1=8 for both), so this fallback is +-- default-bar-only in practice. +-- +-- width/height (2026-08-02 fix, trial): N icons need (N-1) gaps, not N -- +-- the previous `cols * (iconSize + pad)` baked one extra trailing pad's +-- worth of edge margin into the box (visible as the box being a few px +-- bigger than the icons it actually contains). Surfaced by the External +-- Defensives migration (directly comparable against the old standalone +-- module's tighter `4*iconSize + 3*spacing` formula, which never had this +-- trailing pad). Affects every PAB bar's rendered box size, not just that +-- one -- Joel asked to see this applied before deciding whether to keep it. +-- Confirmed real bug, not preview-only (2026-08-03, Joel asked to check): +-- the live bar's parent frame is sized via this same function +-- (`parent:SetSize(grid.width, grid.height)`, e.g. line ~1500/1619/2586/ +-- 2727), computed purely from the bar's uniform cfg.iconSize -- an Icon +-- Effects Size override on one category renders THAT category's icons +-- bigger via a separate sized style variant (EnsurePabSizedStyle), but the +-- surrounding frame/box never grows to match, so the oversized icons simply +-- overflow the frame's bounds (also throwing off the CENTER-anchor +-- recompensation math a few lines below every grid.width/height use, which +-- assumes this size is accurate). Since the container is a FLOW layout +-- mixing possibly-different per-group icon sizes, there is no way to +-- compute an exact box footprint ahead of time (it's data-dependent -- which +-- specific auras are showing right now) -- the same "static worst-case +-- capacity reservation" spirit maxTotal/rows*cols already use here, so the +-- box is sized for the LARGEST icon that could ever appear (base iconSize, +-- or any active fx block's Size override, whichever is bigger) rather than +-- the base size alone. Same fix serves the preview box (RenderPreviewIcons/ +-- PAB_BuildPreviewBox both call this same function). +local function MaxIconSizeFor(isBuff, cfg) + local size = cfg.iconSize or 32 + if not isBuff and cfg.fxList then + local list = PAB_FxListView(cfg.fxList) + if list then + for i = 1, #list do + local sz = tonumber(list[i].size) + if sz and sz > size then size = sz end + end + end + end + -- Snap to the physical pixel grid, same reasoning as ApplyGroupConfig's + -- gap/rowGap snap above: a raw iconSize also feeds the container's own + -- cross-axis extent math (ComputeGrid below) at a non-pixel-perfect + -- UIParent scale. + local PPa = EllesmereUI and EllesmereUI.PP + if PPa and PPa.Scale then size = PPa.Scale(size) end + return size +end + +local function ComputeGrid(isBuff, cfg) + local iconSize = MaxIconSizeFor(isBuff, cfg) + local pad = cfg.padding or 5 + local rowGap = cfg.rowSpacing or 12 + local cols = math.max(1, cfg.iconsPerRow or (isBuff and 11 or 8)) + local rows = math.max(1, cfg.maxRows or (isBuff and 3 or 2)) + local configuredMax = cfg.maxTotal or (isBuff and 32 or 16) + local effectiveMax = math.min(configuredMax, rows * cols) + -- Actual rows needed for the effective cap, never more than the row limit + local usedRows = math.min(rows, math.max(1, math.ceil(effectiveMax / cols))) + -- `lineExtent` is always the AK rowWidth/line-size value (icons-per-line + -- axis: iconsPerRow icons of iconSize + gaps). `crossExtent` is the + -- other axis (how many lines are actually used). For horizontal growth a + -- "line" is a row, so lineExtent -> width; for vertical growth (Up/Down, + -- 2026-08-04) a "line" is a column, so lineExtent -> height instead -- + -- see CornerFor/BuildContainerSpec's doc comment for the matching + -- growthH/growthV swap. + local lineExtent = cols * iconSize + (cols - 1) * pad + local crossExtent = usedRows * iconSize + (usedRows - 1) * rowGap + local vertical = (cfg.growDirection == "UP" or cfg.growDirection == "DOWN") + local width = vertical and crossExtent or lineExtent + local height = vertical and lineExtent or crossExtent + return { + effectiveMax = effectiveMax, + rowWidth = lineExtent, + width = width, + height = height, + rowGap = rowGap, + } +end + +-- Lazily creates and returns the default bars' per-polarity cfg sub-tables. +-- Same shape as a custom bar object's shared+category fields (see the CRUD +-- section below) -- BuildStyle/ComputeGrid/ClassEnabled don't know or care +-- whether a cfg came from here or from a custom bar entry. +local function DefaultBuffsCfg(s) + s.defaultBuffs = s.defaultBuffs or {} + return s.defaultBuffs +end +local function DefaultDebuffsCfg(s) + s.defaultDebuffs = s.defaultDebuffs or {} + return s.defaultDebuffs +end +ns.PAB_DefaultBuffsCfg = DefaultBuffsCfg +ns.PAB_DefaultDebuffsCfg = DefaultDebuffsCfg + +-- One-time seed of the default Buffs/Debuffs bars' position/size/grid from +-- Blizzard's own EditMode Buff/Debuff frame setup, so a first-time PAB user +-- doesn't lose an already-customized Blizzard layout. Guarded by +-- s.pabEditModeSeeded (deliberately NOT a nil-check on s.buffsPos/ +-- s.defaultBuffs like MigrateExternalDefensives below -- Buffs/Debuffs bars +-- already exist for every current user, so a nil-check would wrongly treat +-- "never manually moved" as "brand new profile" and silently reposition/ +-- resize existing users' bars the first time they log in after this ships). +-- +-- Reads live BuffFrame/DebuffFrame state rather than C_EditMode.GetLayouts() +-- -- GetLayouts()'s `layouts` table is not reliably indexable by +-- `activeLayout` (confirmed in testing: the pairs-key holding the actually- +-- active layout's data did not match activeLayout's number, and there was +-- no other field to match against). Blizzard has already resolved and +-- applied whichever layout is active onto these two frames by the time +-- addons run, so reading their live AuraContainer fields sidesteps that +-- lookup problem entirely -- cross-checked in-game against a manual +-- C_EditMode.GetLayouts() dump of the same account and matched exactly. +-- +-- Field mapping (verified in-game, 2026-08-04): +-- AuraContainer.iconPadding -> padding (direct pixel value) +-- AuraContainer.iconScale (observed 0.5-2.0, -> iconSize = round(32 * scale) +-- i.e. Blizzard's 50%-200% slider) (32px baseline assumes +-- Blizzard's 100% matches +-- PAB's own 32px default -- +-- an approximation, not +-- verified against +-- Blizzard's actual native +-- icon pixel size; low risk, +-- user can readjust) +-- AuraContainer.iconStride -> iconsPerRow (NOT maxTotal -- +-- "stride" = icons per row before wrapping, confirmed by exact value +-- match against Blizzard's IconLimitBuffFrame/DebuffFrame EditMode +-- setting; no discovered Blizzard equivalent for a true total cap, so +-- maxTotal is left at PAB's own default, unmigrated) +-- AuraContainer.isHorizontal + .addIconsToRight/.addIconsToTop +-- -> growDirection + +-- iconWrapDirection (see SeedBarFromEditMode below). The vertical- +-- orientation branch is a reasoned inference from PAB's own cross-axis +-- convention (ToGrowthH/CornerFor above), NOT empirically tested -- +-- verification was only done against a horizontal Blizzard layout. +-- frame:GetLeft()/GetTop() minus UIParent's own -> buffsPos/debuffsPos, +-- stored as a TOPLEFT-relative-to-UIParent offset. Deliberately NOT +-- using GetPoint()'s raw relativeTo: PAB always anchors to UIParent +-- (ApplyBarPosition), but Blizzard's own anchor can chain to an +-- arbitrary frame (e.g. a Buffs frame anchored relativeTo="DebuffFrame", +-- not UIParent, is a real observed case, not hypothetical) -- absolute +-- screen coordinates sidestep that chain entirely. +local function SeedBarFromEditMode(frame, cfg, posKey, s, isBuff) + if not (frame and frame.AuraContainer) then return end + local ac = frame.AuraContainer + + local uiLeft, uiTop = UIParent:GetLeft(), UIParent:GetTop() + local left, top = frame:GetLeft(), frame:GetTop() + if uiLeft and uiTop and left and top then + s[posKey] = { point = "TOPLEFT", relPoint = "TOPLEFT", x = left - uiLeft, y = top - uiTop } + end + + if ac.iconPadding then cfg.padding = ac.iconPadding end + if ac.iconScale then cfg.iconSize = math.floor(32 * ac.iconScale + 0.5) end + if ac.iconStride then + cfg.iconsPerRow = ac.iconStride + -- Keep the full icon cap reachable even when the migrated row width + -- is narrower than PAB's own default (e.g. Blizzard stride=7 vs + -- PAB's fallback iconsPerRow=11): ComputeGrid effectively caps at + -- min(maxTotal, maxRows*iconsPerRow), and maxTotal itself is NOT + -- migrated (stays at PAB's own 32/16 default, see doc comment above + -- -- no discovered Blizzard equivalent), so maxRows must grow to + -- compensate or a narrower stride would silently show fewer icons + -- than PAB's un-migrated default did. cap matches ComputeGrid's own + -- isBuff-conditional maxTotal fallback (32/16) exactly. + local cap = isBuff and 32 or 16 + cfg.maxRows = math.max(1, math.ceil(cap / ac.iconStride)) + end + + if ac.isHorizontal ~= nil then + if ac.isHorizontal then + cfg.growDirection = ac.addIconsToRight and "RIGHT" or "LEFT" + else + cfg.growDirection = ac.addIconsToTop and "UP" or "DOWN" + cfg.iconWrapDirection = ac.addIconsToRight and "RIGHT" or "LEFT" + end + end +end + +-- Old "Styled Player Auras" module (db.profile.playerAuras, retired the same +-- commit PAB was introduced, 04bf744d) never controlled position/size/grid +-- itself -- its own header comment: "No reparenting, no repositioning -- +-- Blizzard controls layout via Edit Mode" -- so nothing here duplicates +-- SeedBarFromEditMode above. It DID apply its own border/font styling on +-- top of Blizzard's frames, as ONE shared flat cfg table (not split per +-- polarity like PAB's own cfg, unlike buffIconZoom/debuffIconZoom which +-- were already split -- not migrated, PAB already has its own per-bar +-- iconZoom independent of this). Gated on the old module's own `enabled` +-- flag -- only migrate if it was actually turned on. Field mapping +-- (verified against the extracted pre-removal source AND Joel's own live +-- in-game test, 2026-08-04): +-- borderSize, borderR/G/B/A -> same field name, applied to BOTH buffCfg +-- and debuffCfg (old module had one shared +-- value, not two) +-- showText -> durationShow, both bars (same nil/true = +-- shown semantics) +-- textSize -> BOTH durationTextSize AND stackTextSize, +-- both bars (old module's SkinAuraButton +-- used cfg.textSize to size both the +-- duration font string AND the stack-count +-- font string -- confirmed in source +-- [durFS:SetFont(...,cfg.textSize...) and +-- ApplyIconTextFont(countFS,...,cfg.textSize +-- ...)] and by Joel's own live test -- NOT +-- the duration-only/stack-only split that +-- applies to the separate old External +-- Defensives module) +-- noBorderDebuffs -> debuffCfg.borderSize = 0 (debuffs-only +-- override, applied after the shared +-- borderSize above) +-- NOT migrated -- no PAB cfg field exists for either yet (pre-existing +-- known gap, see project memory "PAB border/duration-format gaps"): +-- borderTexture/borderTextureOffset(Y)/borderTextureShiftX/Y/borderBehind, +-- durationFormat. +local function MigratePlayerAuraStyle(buffCfg, debuffCfg) + local old = ns.db and ns.db.profile and ns.db.profile.playerAuras + if not (old and old.enabled) then return end + + for _, cfg in ipairs({ buffCfg, debuffCfg }) do + if old.borderSize then cfg.borderSize = old.borderSize end + if old.borderR then cfg.borderR = old.borderR end + if old.borderG then cfg.borderG = old.borderG end + if old.borderB then cfg.borderB = old.borderB end + if old.borderA then cfg.borderA = old.borderA end + if old.showText ~= nil then cfg.durationShow = old.showText end + if old.textSize then + cfg.durationTextSize = old.textSize + cfg.stackTextSize = old.textSize + end + end + + if old.noBorderDebuffs then + debuffCfg.borderSize = 0 + end +end + +local function SeedDefaultBuffsDebuffsFromLegacySources(s) + if s.pabEditModeSeeded then return end + s.pabEditModeSeeded = true -- set first: never retry even if a read below errors/fails partway + local buffCfg, debuffCfg = DefaultBuffsCfg(s), DefaultDebuffsCfg(s) + SeedBarFromEditMode(BuffFrame, buffCfg, "buffsPos", s, true) + SeedBarFromEditMode(DebuffFrame, debuffCfg, "debuffsPos", s, false) + MigratePlayerAuraStyle(buffCfg, debuffCfg) +end + +-- One-time migration from the retired standalone EllesmereUIUnitFrames_ +-- ExternalDefensives.lua module (db.profile.externalDefensives) into PAB's +-- own defaultExternalDefensives cfg, run lazily the first time +-- DefaultExternalDefensivesCfg is accessed (mirrors DefaultBuffsCfg/ +-- DefaultDebuffsCfg's `s.defaultX = s.defaultX or {}` pattern, just with a +-- real seed body instead of an empty table). Field-name/semantics mapping: +-- enabled/iconSize/borderSize/R/G/B/A: direct 1:1 +-- growDirection: old module used lowercase "left"/"right", PAB uses +-- uppercase "LEFT"/"RIGHT" +-- showText -> durationShow: same semantics both sides (nil/true = shown) +-- textSize -> stackTextSize: old module's `textSize` styled the stack/ +-- application-count text (btn._count), NOT a duration text -- the old +-- module's duration numbers came from Blizzard's native Cooldown +-- countdown text instead (SetCountdownFont/SetCountdownFormatter on the +-- swipe widget itself), which PAB does not use (AK forces native +-- countdown numbers off unconditionally and renders duration through +-- its own d.duration binding instead, see EllesmereUI_AuraKit.lua's +-- ApplyStyleToRegions doc comment) -- so there is no equivalent source +-- field to migrate FROM for PAB's durationTextSize/durationPosition/etc, +-- they just start at PAB's normal fallbacks (except durationPosition, +-- seeded to "CENTER" below per Joel's explicit request, closer to how +-- the old module's centered native countdown text looked). +-- NOT migrated (PAB's BuildStyle does not expose these fields at all, even +-- though AK's engine border call already accepts them -- flagged 2026-08-02 +-- as a follow-up: Joel wants border texture/offset/shift/behind support +-- added across ALL of PAB later, not just for this bar): +-- iconZoom, borderTexture, borderTextureOffset(Y), borderTextureShiftX/Y, +-- borderBehind, durationFormat (colon/seconds compact variants -- already +-- a known, pre-existing PAB-wide gap, see the Settings Schema doc comment +-- near BuildStyle). +local function MigrateExternalDefensives(s) + local old = ns.db and ns.db.profile and ns.db.profile.externalDefensives + local cfg = { + enabled = false, + iconsPerRow = 4, + maxRows = 1, + maxTotal = 4, + durationPosition = "CENTER", + } + if old then + if old.enabled ~= nil then cfg.enabled = old.enabled end + if old.iconSize then cfg.iconSize = old.iconSize end + if old.growDirection then cfg.growDirection = string.upper(old.growDirection) end + if old.showText ~= nil then cfg.durationShow = old.showText end + if old.textSize then cfg.stackTextSize = old.textSize end + if old.borderSize then cfg.borderSize = old.borderSize end + if old.borderR then cfg.borderR = old.borderR end + if old.borderG then cfg.borderG = old.borderG end + if old.borderB then cfg.borderB = old.borderB end + if old.borderA then cfg.borderA = old.borderA end + if old.unlockPos and old.unlockPos.point and not s.extDefPos then + s.extDefPos = { + point = old.unlockPos.point, + relPoint = old.unlockPos.relPoint or old.unlockPos.point, + x = old.unlockPos.x, y = old.unlockPos.y, + } + end + end + return cfg +end + +local function DefaultExternalDefensivesCfg(s) + s.defaultExternalDefensives = s.defaultExternalDefensives or MigrateExternalDefensives(s) + return s.defaultExternalDefensives +end +ns.PAB_DefaultExternalDefensivesCfg = DefaultExternalDefensivesCfg + +local buffsContainer, debuffsContainer, extDefContainer +local buffsParent, debuffsParent, extDefParent +-- Per-container, per-polarity registry of every group key ever declared +-- (see ApplyGroupConfig above) -- reset only when a container is (re-) +-- created, never cleared on a live settings change. +local declared = { buffs = {}, debuffs = {} } -- buffs: only the "Show All Buffs" catch-all group key ("all"); debuffs: every class-token chain group key +local lastSize = { buffs = nil, debuffs = nil, extdef = nil } -- {w=,h=}, tracks our own last-applied grid size for CENTER-anchor compensation (see ApplyLiveConfig) +local buffsSlotSig -- signature of the default Buffs bar's last-applied resolved spell list (ns.PAB_ResolveSpells), mirrors customBuffSig[barId] for the per-bar slots model +local RegisterPABUnlock -- forward-declared; defined after CreateBars, called from it +local ReloadAllCustomBars -- forward-declared; defined after CreateBars (custom bars section), called from it +local PAB_MaybeRefreshPreview -- forward-declared; assigned in the options-page preview section below, called from every live-apply function so an open bar-detail preview box stays in sync with slider drags without needing to know it exists + +-- Grow direction. AK.ApplyContainerLayout only applies growth when BOTH +-- growthH and growthV are set (verified: `if layout.growthH and +-- layout.growthV then ... end`) -- growthV is always Down regardless of the +-- user's L/R choice (rows still wrap downward). Values are +-- AnchorUtil.FlowDirection members, NOT strings -- verified against +-- Blizzard_SharedXMLBase/AnchorUtil.lua: Left=-1, Right=1, Up=1, Down=-1. Our +-- own settings still store "LEFT"/"RIGHT" strings (matches EUI_UnlockMode. +-- lua's dropdown `val` convention). +-- +-- CRITICAL, found 2026-07-30: AnchorUtil.ApplyFlowLayout positions every +-- element via `layout:GetAnchorPoint()` (default "TOPLEFT", a THIRD, +-- independent field -- layout.anchorPoint -- we never set) -- NOT via our +-- outer spec.point. Elements are placed relative to that internal anchor +-- with offsets increasing in the growthH direction. Leaving anchorPoint +-- stuck at its default while growthH changed is exactly what put the first +-- icon on the wrong side / let icons drift outside the mover box. Fix: +-- derive ONE corner from the direction and use it for BOTH the outer frame +-- anchor (against buffsParent/debuffsParent) and the internal flow +-- anchorPoint, so the "fixed" corner and the flow's start corner are always +-- the same physical point: +-- growDirection RIGHT -> fixed/start corner TOPLEFT (icons extend right) +-- growDirection LEFT -> fixed/start corner TOPRIGHT (icons extend left) +-- This is derived directly from AnchorUtil's source, not copied from a +-- working Blizzard example -- no other Blizzard UI uses this new flow layout +-- system yet to cross-check against. Confirmed in-game 2026-07-30 (Joel). +-- +-- Vertical growth (Up/Down, 2026-08-04 addition, Joel): when growDirection is +-- UP/DOWN, the flow's PRIMARY axis becomes vertical (icons fill up/down first) +-- and growthH instead carries the CROSS axis -- which side additional columns +-- wrap to, from the new cfg.iconWrapDirection field ("LEFT"/"RIGHT", default +-- LEFT). Mirrors EUI_RaidFrames_AuraContainers.lua's AnchorDebuffContainer +-- (grow==UP/DOWN branch: gH = wrap, gV = grow) -- same AK primitives, same +-- axis-swap idea, just PAB has no separate wrap dropdown (see the cog-only UI +-- decision in the plan). Horizontal growth (Left/Right) is untouched: growthH +-- stays the primary axis, growthV stays hardcoded Down (rows always wrap +-- downward, as before this feature existed). +local function ToGrowthH(dirStr, wrapStr) + local FlowDir = AnchorUtil and AnchorUtil.FlowDirection + if not FlowDir then return nil end + if dirStr == "UP" or dirStr == "DOWN" then + return wrapStr == "RIGHT" and FlowDir.Right or FlowDir.Left + end + return dirStr == "RIGHT" and FlowDir.Right or FlowDir.Left +end +local function ToGrowthV(dirStr) + local FlowDir = AnchorUtil and AnchorUtil.FlowDirection + if not FlowDir then return nil end + if dirStr == "UP" then return FlowDir.Up end + if dirStr == "DOWN" then return FlowDir.Down end + return FlowDir.Down -- horizontal growth: rows always wrap downward +end +-- Corner = the flow's fixed start point = (opposite of growthV side) + +-- (opposite of growthH side) -- same rule for every direction, horizontal or +-- vertical (verified against the existing LEFT/RIGHT cases: growthV is +-- always Down -> TOP component; growthH Right/Left -> LEFT/RIGHT component). +local function CornerFor(dirStr, wrapStr) + if dirStr == "UP" or dirStr == "DOWN" then + local vSide = (dirStr == "UP") and "BOTTOM" or "TOP" + local hSide = (wrapStr == "RIGHT") and "LEFT" or "RIGHT" + return vSide .. hSide + end + return dirStr == "RIGHT" and "TOPLEFT" or "TOPRIGHT" +end + +-- Shared by every container -- default bars (buffs/debuffs) AND custom bars +-- alike: builds the AK.RequestContainer spec's point+layout from a bar-local +-- cfg's growDirection and a precomputed grid (see ComputeGrid). One +-- implementation so default and custom bars can never drift in how they +-- interpret growDirection/rowWidth. Returns the corner too since callers +-- also need it for the container's own SetPoint against its parent frame, +-- and `vertical` since AK.SetContainerAxis is a separate call from +-- AK.ApplyContainerLayout (axis isn't part of the layout table AK consumes). +local function BuildContainerSpec(parent, cfg, grid) + local dir = cfg.growDirection or "LEFT" + local wrap = cfg.iconWrapDirection or "LEFT" + local vertical = (dir == "UP" or dir == "DOWN") + local corner = CornerFor(dir, wrap) + return corner, { + point = { corner, parent, corner, 0, 0 }, + layout = { + anchorPoint = corner, + padding = { 0, 0, 0, 0 }, + rowWidth = grid.rowWidth, + growthH = ToGrowthH(dir, wrap), + growthV = ToGrowthV(dir), + }, + }, vertical +end + +-- Default anchor when no saved position exists yet. Independent per bar +-- (Joel: bars must be individually movable) -- debuffs no longer chained to +-- buffsParent's BOTTOMRIGHT as in Step A, just a separate default offset so +-- the two don't overlap before either has been dragged. +local DEFAULT_POS = { + buffs = { point = "TOPRIGHT", relPoint = "TOPRIGHT", x = -300, y = -200 }, + debuffs = { point = "TOPRIGHT", relPoint = "TOPRIGHT", x = -300, y = -260 }, + extdef = { point = "CENTER", relPoint = "CENTER", x = 0, y = -220 }, -- matches the old standalone module's own default +} + +local function BarPositionKey(isBuff) + return isBuff and "buffsPos" or "debuffsPos" +end + +-- Snaps a saved (x, y) to the physical pixel grid before it's handed to +-- SetPoint, matching EllesmereUIUnitFrames.lua's ApplyFramePosition (the +-- rest of the addon's established pattern for this exact problem: an +-- unsnapped coordinate is only ever wrong at a non-pixel-perfect UIParent +-- scale, since 1 coord unit == 1 physical pixel at PP.PixelBestSize() and +-- the gap is invisible there). CENTER/CENTER anchors need SnapCenterForDim +-- (its +0.5 odd-dimension offset keeps both edges on whole pixels; plain +-- SnapForES would round the center itself and push edges onto half +-- pixels), every other anchor point (TOPRIGHT etc.) uses SnapForES. +local function SnapBarPos(frame, point, relPoint, x, y) + local PPa = EllesmereUI and EllesmereUI.PP + if not (PPa and x and y) then return x, y end + local es = frame:GetEffectiveScale() + local isCenterAnchor = (point == "CENTER" or point == nil) + and (relPoint == "CENTER" or relPoint == nil) + if isCenterAnchor and PPa.SnapCenterForDim then + local fw = frame:GetWidth() or 0 + local fh = frame:GetHeight() or 0 + return PPa.SnapCenterForDim(x, fw, es), PPa.SnapCenterForDim(y, fh, es) + elseif PPa.SnapForES then + return PPa.SnapForES(x, es), PPa.SnapForES(y, es) + end + return x, y +end + +-- Applies the saved position (if any) or the default to the given parent +-- frame. Shared between initial creation and the unlock-mode applyPos +-- callback so the two never drift into different SetPoint logic. +local function ApplyBarPosition(parent, isBuff) + local s = PAB() + local pos = s and s[BarPositionKey(isBuff)] + local def = isBuff and DEFAULT_POS.buffs or DEFAULT_POS.debuffs + parent:ClearAllPoints() + if pos and pos.point then + local x, y = SnapBarPos(parent, pos.point, pos.relPoint or pos.point, pos.x, pos.y) + parent:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, x, y) + else + local x, y = SnapBarPos(parent, def.point, def.relPoint, def.x, def.y) + parent:SetPoint(def.point, UIParent, def.relPoint, x, y) + end +end + +-- Same as ApplyBarPosition, kept separate rather than folded into its +-- isBuff-boolean signature: External Defensives is a THIRD, independent +-- position slot (s.extDefPos), not a third value of a two-state toggle. +local function ApplyExtDefPosition(parent) + local s = PAB() + local pos = s and s.extDefPos + local def = DEFAULT_POS.extdef + parent:ClearAllPoints() + if pos and pos.point then + local x, y = SnapBarPos(parent, pos.point, pos.relPoint or pos.point, pos.x, pos.y) + parent:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, x, y) + else + local x, y = SnapBarPos(parent, def.point, def.relPoint, def.x, def.y) + parent:SetPoint(def.point, UIParent, def.relPoint, x, y) + end +end + +-- Blizzard's own player BuffFrame/DebuffFrame are now fully superseded by +-- this module -- hide them so auras aren't shown twice. Same pattern +-- already used elsewhere in this addon for "Blizzard keeps re-showing this" +-- cases: Hide() once, then hooksecurefunc(Show) to immediately re-hide any +-- time Blizzard's own code calls Show() again (e.g. on an aura update). +-- hooksecurefunc runs AFTER Blizzard's secure call completes, not from +-- inside it, so this does not taint anything. Neither frame is a protected/ +-- secure frame itself -- plain Hide() is safe outside of any special +-- lockdown concern. +local blizzardAurasHidden = false +local function HideBlizzardPlayerAuras() + if blizzardAurasHidden then return end + blizzardAurasHidden = true + if BuffFrame then + BuffFrame:Hide() + hooksecurefunc(BuffFrame, "Show", function() BuffFrame:Hide() end) + end + if DebuffFrame then + DebuffFrame:Hide() + hooksecurefunc(DebuffFrame, "Show", function() DebuffFrame:Hide() end) + end +end + +local function CreateBars() + AK = AK or (EllesmereUI and EllesmereUI.AuraKit) + if not AK then return end -- 12.1 gated at file top; defensive only + + local s = PAB() + if not s then return end -- ns.db not ready yet; TryCreateBars() below retries + + -- Must run before DefaultBuffsCfg/DefaultDebuffsCfg are first called + -- below, so a seeded value is what those functions' first lazy-create + -- actually populates -- see SeedDefaultBuffsDebuffsFromLegacySources's + -- doc comment above for why this is a one-time-ever seed, not a + -- migration that could reapply on an existing profile. + SeedDefaultBuffsDebuffsFromLegacySources(s) + + -- Must run before buffCfg/custom-bar spell resolution below: the 10 + -- curated BM2 presets (Defensives, Offensive CDs, ...) were previously + -- only imported when the options page opened (EUI_PlayerAuraBars_ + -- ManagerPages.lua's PABMP_BuildPage), so a bar referencing a preset + -- filter that hadn't been imported yet this session resolved an + -- incomplete spell set at login, cached that as its signature, and + -- never re-resolved until something else (e.g. opening the Filter + -- Editor) forced a signature change. Importing here too closes that + -- gap for both the default Buffs bar and every custom buff bar. + if ns.PAB_ImportBM2Filters then ns.PAB_ImportBM2Filters() end + + HideBlizzardPlayerAuras() + + local buffCfg, debuffCfg = DefaultBuffsCfg(s), DefaultDebuffsCfg(s) + local extDefCfg = DefaultExternalDefensivesCfg(s) + + AK.styles[STYLE_BUFFS] = BuildStyle(true, buffCfg) + AK.styles[STYLE_DEBUFFS] = BuildStyle(false, debuffCfg) + AK.styles[STYLE_EXTDEF] = BuildStyle(true, extDefCfg) -- isBuff=true: External Defensives are HELPFUL auras, inherits the same swipe-hide/right-click-cancel treatment every other buff bar gets + + local buffGrid = ComputeGrid(true, buffCfg) + local debuffGrid = ComputeGrid(false, debuffCfg) + local extDefGrid = ComputeGrid(true, extDefCfg) + + buffsParent = buffsParent or CreateFrame("Frame", "EllesmereUIPlayerAuraBars_Buffs", UIParent) + buffsParent:SetSize(buffGrid.width, buffGrid.height) + ApplyBarPosition(buffsParent, true) + lastSize.buffs = { w = buffGrid.width, h = buffGrid.height } + + debuffsParent = debuffsParent or CreateFrame("Frame", "EllesmereUIPlayerAuraBars_Debuffs", UIParent) + debuffsParent:SetSize(debuffGrid.width, debuffGrid.height) + ApplyBarPosition(debuffsParent, false) + lastSize.debuffs = { w = debuffGrid.width, h = debuffGrid.height } + + extDefParent = extDefParent or CreateFrame("Frame", "EllesmereUIPlayerAuraBars_ExternalDefensives", UIParent) + extDefParent:SetSize(extDefGrid.width, extDefGrid.height) + ApplyExtDefPosition(extDefParent) + extDefParent:SetShown(extDefCfg.enabled ~= false) + lastSize.extdef = { w = extDefGrid.width, h = extDefGrid.height } + + local debuffChain = BuildChain("HARMFUL", function(class) return ClassEnabled(class, false, debuffCfg) or (PAB_FxSafeToForce(class) and PAB_FxWantsCategory(debuffCfg.fxList, class.key)) end, debuffCfg.showAllDebuffs ~= false) + + -- Single scalar padding (matches the old module's paddingBuffs/ + -- paddingDebuffs) -- feeds ONLY ApplyGroupConfig's per-group + -- elementSpacing/lineSpacing/groupSpacing/groupLineSpacing below (the + -- gap between icons). Must NOT also feed the container's own outer edge + -- inset (spec.layout.padding / AK.SetContainerPadding) -- that's a + -- DIFFERENT concept (margin from the container frame edge to the first + -- icon) and was wrongly tied to this same value before, which pushed + -- the whole grid away from its fixed corner as padding grew instead of + -- only widening gaps between icons. Outer edge inset is fixed at 0 + -- below instead. + local buffPad = buffCfg.padding or 5 + local debuffPad = debuffCfg.padding or 5 + + local _, buffSpec, buffVertical = BuildContainerSpec(buffsParent, buffCfg, buffGrid) + local _, debuffSpec, debuffVertical = BuildContainerSpec(debuffsParent, debuffCfg, debuffGrid) + + -- Groups are declared additively right after creation (not via + -- spec.groups) so the exact same ApplyGroupConfig path handles both + -- initial creation and every later live settings change -- see + -- ApplyGroupConfig's doc comment above. + -- + -- Buffs: the Filters/Extra-Spells selection and the "Show All Buffs" + -- catch-all GROUP coexist additively on the same container -- both are + -- GROUPS now (2026-08-02 fix, see below), independent declarations, + -- nothing in AK's model makes them mutually exclusive. + -- The catch-all uses the SAME zero-classes-enabled chain trick as + -- Debuffs' "all" group (BuildChain's own doc comment: "with zero + -- classes enabled this is just { base }, i.e. every aura of that + -- polarity") -- there is no per-class filtering UI for buffs, so this + -- is unconditionally all-or-nothing, gated only by showAllBuffs. + -- Defaults ON: without it, an unconfigured bar (no filters/extra + -- spells) would show nothing; ON matches both Blizzard's own player + -- BuffFrame (FrameXML BuffFrame.lua filters HELPFUL with no category + -- restriction) and this bar's pre-redesign default behavior (empty + -- classFilters = catch-all). + -- + -- 2026-08-02 FIX: the Filters/Extra-Spells selection was originally + -- built as one AK.AddAuraSlot per resolved spellID. Field-verified (via + -- temporary instrumentation: extraInit fired, buttons reported + -- shown=true/correct size, but GetPoint(1) returned nil and nothing + -- rendered on screen -- even an UNRESTRICTED slot with no + -- candidateFilters at all never appeared) that AK's flow layout only + -- positions GROUP content (AddAuraGroup), not slot content + -- (AddAuraSlot) -- slots are apparently meant to be self-anchored via + -- extraInit (confirmed by both other AddAuraSlot consumers in this + -- codebase, EUI_RaidFrames_AuraContainers.lua's chain slots and + -- EUI_ResourceBars_EbonMight121.lua, which both manually SetPoint their + -- slot button in extraInit rather than relying on the container flow). + -- PAB wants a flowing multi-icon grid of a DYNAMIC spell set, which + -- slots don't support without reimplementing flow placement by hand. + -- Fix: use ONE GROUP whose candidateFilters.includeSpellIDs is the + -- resolved spell-ID set (map shape {[id]=true}, verified against every + -- other includeSpellIDs consumer in the codebase -- BmIncludeMap, + -- BmSimpleCand, EbonMight121's own candidateFilters -- all use a map, + -- never an array). A group's candidateFilters is fixed at declaration + -- (see ApplyGroupConfig's own doc comment), so a spell-list change + -- still requires releasing and recreating the container -- same + -- sig-diffing this file already used for the slot version. + local buffAllChain = BuildChain("HELPFUL", function() return false end) + local buffSpells = ns.PAB_ResolveSpells(buffCfg) + buffsSlotSig = table.concat(buffSpells, ",") + AK.RequestContainer(buffsParent, "player", buffSpec, function(container) + buffsContainer = container + AK.SetContainerAxis(container, buffVertical) + declared.buffs = {} + if buffCfg.showAllBuffs ~= false then + ApplyGroupConfig(container, buffAllChain, declared.buffs, STYLE_BUFFS, buffGrid.effectiveMax, buffPad, buffGrid.rowGap, buffCfg, BuffCandidateExtras(buffCfg)) + end + if #buffSpells > 0 then + local includeMap = {} + for i = 1, #buffSpells do includeMap[buffSpells[i]] = true end + AK.AddGroupToContainer(container, { + key = "spells", + filter = { "HELPFUL" }, + style = STYLE_BUFFS, + maxFrameCount = buffGrid.effectiveMax, + candidateFilters = MergeCandidateFilters({ includeSpellIDs = includeMap }, BuffCandidateExtras(buffCfg)), + sortMethod = ResolveSortMethod(buffCfg), + sortDirection = ResolveSortDirection(buffCfg), + }) + container:SetAuraGroupLayout("spells", { + elementSpacing = buffPad, lineSpacing = buffGrid.rowGap, + groupSpacing = buffPad, groupLineSpacing = buffGrid.rowGap, + }) + declared.buffs.spells = true + end + end) + AK.RequestContainer(debuffsParent, "player", debuffSpec, function(container) + debuffsContainer = container + AK.SetContainerAxis(container, debuffVertical) + declared.debuffs = {} + ApplyGroupConfig(container, debuffChain, declared.debuffs, STYLE_DEBUFFS, debuffGrid.effectiveMax, debuffPad, debuffGrid.rowGap, debuffCfg) + end) + + -- External Defensives: fixed engine classification, not a user-selected + -- spell/class set -- ONE static group declared once and never touched + -- again (no ApplyGroupConfig chain machinery, no spell-signature + -- diffing/rebuild like Buffs/Debuffs above -- there is nothing to + -- diff, the filter can never change). filter={"HELPFUL", + -- "EXTERNAL_DEFENSIVE"} is AK.Filter-joined into the exact same + -- "HELPFUL|EXTERNAL_DEFENSIVE" string the old standalone module used + -- directly against C_UnitAuras.IsAuraFilteredOutByInstanceID. + local extDefPad = extDefCfg.padding or 5 + local _, extDefSpec, extDefVertical = BuildContainerSpec(extDefParent, extDefCfg, extDefGrid) + AK.RequestContainer(extDefParent, "player", extDefSpec, function(container) + extDefContainer = container + AK.SetContainerAxis(container, extDefVertical) + AK.AddGroupToContainer(container, { + key = "extdef", + filter = { "HELPFUL", "EXTERNAL_DEFENSIVE" }, + style = STYLE_EXTDEF, + maxFrameCount = extDefGrid.effectiveMax, + sortMethod = ResolveSortMethod(extDefCfg), + sortDirection = ResolveSortDirection(extDefCfg), + }) + container:SetAuraGroupLayout("extdef", { + elementSpacing = extDefPad, lineSpacing = extDefGrid.rowGap, + groupSpacing = extDefPad, groupLineSpacing = extDefGrid.rowGap, + }) + end) + + RegisterPABUnlock() + ReloadAllCustomBars() +end + +-- Unlock-mode registration, patterned directly on +-- EllesmereUIDamageMeters.lua's ns.RegisterDMUnlock / MakeSATimerUnlockElement +-- (the only two real usage examples available -- EUI.MakeUnlockElement's own +-- source was not provided, so this mirrors observed field usage rather than +-- a verified schema). Both bars: noResize (AuraKit sizes the container +-- itself based on active aura count, nothing here to drag-resize) and +-- noAnchorTarget (same reasoning as the combat timer: a dynamically-resizing +-- frame is a bad anchor target for other elements -- confirmed with Joel +-- 2026-07-29). +function RegisterPABUnlock() + if not (EllesmereUI and EllesmereUI.RegisterUnlockElements and EllesmereUI.MakeUnlockElement) then return end + local MK = EllesmereUI.MakeUnlockElement + + local function MakeBarElement(key, label, order, isBuff, getParent) + return MK({ + key = key, + label = label, + group = "Player Aura Bars", + order = order, + noResize = true, + noAnchorTarget = true, + getFrame = function() return getParent() end, + -- Deliberately NOT container:GetWidth()/GetHeight(): AuraContainer + -- geometry is a Secret Value while execution is tainted (field + -- confirmed 2026-07-30 -- EUI_UnlockMode.lua:6250 threw comparing + -- a secret baseW). noResize is set anyway, so an exact live size + -- isn't needed here, just a public number for the mover's label. + getSize = function() + local s = PAB() + if not s then return 32, 32 end + local grid = ComputeGrid(isBuff, isBuff and DefaultBuffsCfg(s) or DefaultDebuffsCfg(s)) + return grid.width, grid.height + end, + savePos = function(_, point, relPoint, x, y) + local s = PAB() + if not s then return end + s[BarPositionKey(isBuff)] = { point = point, relPoint = relPoint or point, x = x, y = y } + end, + loadPos = function() + local s = PAB() + local pos = s and s[BarPositionKey(isBuff)] + if not pos then return nil end + return { point = pos.point, relPoint = pos.relPoint, x = pos.x, y = pos.y } + end, + clearPos = function() + local s = PAB() + if s then s[BarPositionKey(isBuff)] = nil end + end, + applyPos = function() + local parent = getParent() + if parent then ApplyBarPosition(parent, isBuff) end + end, + }) + end + + local elements = { + MakeBarElement("PAB_Buffs", "Buffs", 700, true, function() return buffsParent end), + MakeBarElement("PAB_Debuffs", "Debuffs", 701, false, function() return debuffsParent end), + -- Bespoke third entry, not MakeBarElement: External Defensives is a + -- THIRD independent position slot (s.extDefPos via + -- ApplyExtDefPosition), not a third value of MakeBarElement's + -- isBuff-boolean-driven BarPositionKey/ApplyBarPosition. isBuff=true + -- only for getSize's ComputeGrid call (it IS buff-shaped content), + -- everything position-related is its own accessor. + MK({ + key = "PAB_ExternalDefensives", + label = "External Defensives", + group = "Player Aura Bars", + order = 702, + noResize = true, + noAnchorTarget = true, + getFrame = function() return extDefParent end, + isHidden = function() + local s = PAB() + local cfg = s and DefaultExternalDefensivesCfg(s) + return not (cfg and cfg.enabled ~= false) + end, + getSize = function() + local s = PAB() + if not s then return 32, 32 end + local grid = ComputeGrid(true, DefaultExternalDefensivesCfg(s)) + return grid.width, grid.height + end, + savePos = function(_, point, relPoint, x, y) + local s = PAB() + if not s then return end + s.extDefPos = { point = point, relPoint = relPoint or point, x = x, y = y } + end, + loadPos = function() + local s = PAB() + local pos = s and s.extDefPos + if not pos then return nil end + return { point = pos.point, relPoint = pos.relPoint, x = pos.x, y = pos.y } + end, + clearPos = function() + local s = PAB() + if s then s.extDefPos = nil end + end, + applyPos = function() + if extDefParent then ApplyExtDefPosition(extDefParent) end + end, + }), + } + EllesmereUI:RegisterUnlockElements(elements, "EllesmereUIUnitFrames") +end + +-- Public hook for the Options UI: rebuild both style tables from current +-- settings and re-decorate every existing button. Purely a re-skin -- does +-- NOT touch groups, filters, container layout, or parent frame size. Call +-- for style-only fields (colors, fonts, border, dispel colors, cooldown/ +-- stack text, icon zoom). For iconSize specifically, also call +-- ApplyLiveConfig for both polarities, since icon size affects grid geometry +-- too, not just per-button style. +local function RestyleBars() + local s = PAB() + if not (AK and s) then return end + AK.styles[STYLE_BUFFS] = BuildStyle(true, DefaultBuffsCfg(s)) + AK.styles[STYLE_DEBUFFS] = BuildStyle(false, DefaultDebuffsCfg(s)) + AK.styles[STYLE_EXTDEF] = BuildStyle(true, DefaultExternalDefensivesCfg(s)) + AK.RestyleSoon(STYLE_BUFFS) + AK.RestyleSoon(STYLE_DEBUFFS) + AK.RestyleSoon(STYLE_EXTDEF) +end +ns.PAB_Restyle = RestyleBars + +-- Public hook for the Options UI: live counterpart to RestyleBars for +-- everything spec-level (class toggles, grid: iconsPerRow/maxRows/padding/ +-- maxBuffs-or-Debuffs, grow direction). Applies to ONE polarity's container; +-- callers touching a shared field (iconSize) call this for both. No-op +-- before the container exists yet (TryCreateBars will call CreateBars(), +-- which ends up here anyway once ns.db is ready). +local function ApplyLiveConfig(isBuff) + local s = PAB() + if not (AK and s) then return end + local container = isBuff and buffsContainer or debuffsContainer + local parent = isBuff and buffsParent or debuffsParent + if not container or not parent then return end + + local cfg = isBuff and DefaultBuffsCfg(s) or DefaultDebuffsCfg(s) + local grid = ComputeGrid(isBuff, cfg) + local sizeKey = isBuff and "buffs" or "debuffs" + local prev = lastSize[sizeKey] + + -- Compensate for CENTER-anchored saved positions: SetSize on a + -- CENTER/CENTER point grows/shrinks symmetrically in all directions, so + -- any height/width change would visibly shift the icons. Shift the + -- saved position by half the delta so the CENTER point stays put. Uses + -- OUR OWN last-applied size (lastSize), not parent:GetWidth/GetHeight -- + -- querying the live frame was jitter-prone across rapid successive + -- calls (e.g. dragging a slider), since there's no guarantee the + -- frame's rendered size already reflects the previous call before this + -- one reads it. Skipped entirely when the size hasn't actually changed. + local posKey = BarPositionKey(isBuff) + local pos = s[posKey] + if pos and pos.point == "CENTER" and prev and (prev.w ~= grid.width or prev.h ~= grid.height) then + pos.x = pos.x + (prev.w - grid.width) / 2 + pos.y = pos.y + (prev.h - grid.height) / 2 + -- Snap against the NEW grid.width/height (what parent:SetSize below + -- is about to apply), not parent:GetWidth/GetHeight -- those still + -- read the OLD size here, since the resize call hasn't run yet. + local sx, sy = pos.x, pos.y + local PPa = EllesmereUI and EllesmereUI.PP + if PPa and PPa.SnapCenterForDim then + local es = parent:GetEffectiveScale() + sx = PPa.SnapCenterForDim(pos.x, grid.width, es) + sy = PPa.SnapCenterForDim(pos.y, grid.height, es) + end + parent:ClearAllPoints() + parent:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, sx, sy) + end + lastSize[sizeKey] = { w = grid.width, h = grid.height } + + parent:SetSize(grid.width, grid.height) + + local corner, liveSpec, vertical = BuildContainerSpec(parent, cfg, grid) + local pad = cfg.padding or 5 + + -- Outer frame anchor is a plain SetPoint, not an AK-managed field -- + -- live-settable directly, same as any other frame anchor. + container:ClearAllPoints() + container:SetPoint(corner, parent, corner, 0, 0) + AK.SetContainerAnchor(container, corner) + AK.SetContainerAxis(container, vertical) + if liveSpec.layout.growthH then + AK.SetContainerGrowth(container, liveSpec.layout.growthH, liveSpec.layout.growthV) + end + AK.SetContainerPadding(container, 0, 0, 0, 0) + AK.SetContainerRowWidth(container, grid.rowWidth) + + if isBuff then + local spells = ns.PAB_ResolveSpells(cfg) + local sig = table.concat(spells, ",") + local allChain = (cfg.showAllBuffs ~= false) and BuildChain("HELPFUL", function() return false end) or {} + if sig ~= buffsSlotSig then + -- Safe to fully release+rebuild: the default Buffs container + -- holds only the catch-all group + the spells group, nothing + -- else shares it (same reasoning as PAB_ReloadCustomBuffBar's + -- doc comment). The new container's anchor/growth/rowWidth + -- come from `spec` below -- the live SetContainerAnchor/etc + -- calls above already ran against the OLD container and are + -- harmless overhead here. A group's candidateFilters is fixed + -- at declaration (see ApplyGroupConfig's doc comment), so a + -- spell-list change requires this release+rebuild -- same as + -- the old per-spell-slot version did. + AK.ReleaseContainer(container) + local _, spec, specVertical = BuildContainerSpec(parent, cfg, grid) + AK.RequestContainer(parent, "player", spec, function(newContainer) + buffsContainer = newContainer + AK.SetContainerAxis(newContainer, specVertical) + declared.buffs = {} + if cfg.showAllBuffs ~= false then + ApplyGroupConfig(newContainer, allChain, declared.buffs, STYLE_BUFFS, grid.effectiveMax, pad, grid.rowGap, cfg, BuffCandidateExtras(cfg)) + end + if #spells > 0 then + local includeMap = {} + for i = 1, #spells do includeMap[spells[i]] = true end + AK.AddGroupToContainer(newContainer, { + key = "spells", + filter = { "HELPFUL" }, + style = STYLE_BUFFS, + maxFrameCount = grid.effectiveMax, + candidateFilters = MergeCandidateFilters({ includeSpellIDs = includeMap }, BuffCandidateExtras(cfg)), + sortMethod = ResolveSortMethod(cfg), + sortDirection = ResolveSortDirection(cfg), + }) + newContainer:SetAuraGroupLayout("spells", { + elementSpacing = pad, lineSpacing = grid.rowGap, + groupSpacing = pad, groupLineSpacing = grid.rowGap, + }) + declared.buffs.spells = true + end + end) + buffsSlotSig = sig + else + -- Spell list unchanged -- only Show All Buffs and/or grid + -- (icon size, padding, ...) may have changed. ApplyGroupConfig + -- is idempotent and self-zeroes the catch-all group when + -- `allChain` is empty, so this single call covers both on + -- and off without a separate branch. The spells group (if + -- declared) isn't part of that chain-based path, so its + -- maxFrameCount/layout/sort are refreshed here directly. + ApplyGroupConfig(container, allChain, declared.buffs, STYLE_BUFFS, grid.effectiveMax, pad, grid.rowGap, cfg, BuffCandidateExtras(cfg)) + if declared.buffs.spells then + container:SetAuraGroupMaxFrameCount("spells", grid.effectiveMax) + container:SetAuraGroupLayout("spells", { + elementSpacing = pad, lineSpacing = grid.rowGap, + groupSpacing = pad, groupLineSpacing = grid.rowGap, + }) + local liveIncludeMap = {} + for i = 1, #spells do liveIncludeMap[spells[i]] = true end + container:SetAuraGroupCandidateFilters("spells", + MergeCandidateFilters({ includeSpellIDs = liveIncludeMap }, BuffCandidateExtras(cfg))) + local sortMethod, sortDirection = ResolveSortMethod(cfg), ResolveSortDirection(cfg) + if sortMethod ~= nil and sortDirection ~= nil then + container:SetAuraGroupSortMethod("spells", sortMethod, sortDirection) + end + end + end + else + local chain = BuildChain("HARMFUL", function(class) return ClassEnabled(class, false, cfg) or (PAB_FxSafeToForce(class) and PAB_FxWantsCategory(cfg.fxList, class.key)) end, cfg.showAllDebuffs ~= false) + ApplyGroupConfig(container, chain, declared.debuffs, STYLE_DEBUFFS, grid.effectiveMax, pad, grid.rowGap, cfg) + end + + if PAB_MaybeRefreshPreview then PAB_MaybeRefreshPreview(isBuff and "buff" or "debuff", "default") end +end +ns.PAB_ApplyLiveConfig = ApplyLiveConfig + +-- External Defensives' counterpart to ApplyLiveConfig above -- much +-- shorter since there is no spell/class selection to diff or rebuild: the +-- single "extdef" group's filter is permanent, only style/grid/anchor ever +-- change. Also handles the enabled toggle (ApplyLiveConfig has no +-- equivalent -- the two default bars have no enable/disable of their own). +local function ApplyExtDefLiveConfig() + local s = PAB() + if not (AK and s) then return end + local container, parent = extDefContainer, extDefParent + if not container or not parent then return end + + local cfg = DefaultExternalDefensivesCfg(s) + local grid = ComputeGrid(true, cfg) + local prev = lastSize.extdef + + -- Same CENTER-anchor size-change compensation as ApplyLiveConfig. + local pos = s.extDefPos + if pos and pos.point == "CENTER" and prev and (prev.w ~= grid.width or prev.h ~= grid.height) then + pos.x = pos.x + (prev.w - grid.width) / 2 + pos.y = pos.y + (prev.h - grid.height) / 2 + -- Snap against the NEW grid.width/height (what parent:SetSize below + -- is about to apply), not parent:GetWidth/GetHeight -- those still + -- read the OLD size here, since the resize call hasn't run yet. + local sx, sy = pos.x, pos.y + local PPa = EllesmereUI and EllesmereUI.PP + if PPa and PPa.SnapCenterForDim then + local es = parent:GetEffectiveScale() + sx = PPa.SnapCenterForDim(pos.x, grid.width, es) + sy = PPa.SnapCenterForDim(pos.y, grid.height, es) + end + parent:ClearAllPoints() + parent:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, sx, sy) + end + lastSize.extdef = { w = grid.width, h = grid.height } + + parent:SetSize(grid.width, grid.height) + parent:SetShown(cfg.enabled ~= false) + + local corner, liveSpec, vertical = BuildContainerSpec(parent, cfg, grid) + local pad = cfg.padding or 5 + + container:ClearAllPoints() + container:SetPoint(corner, parent, corner, 0, 0) + AK.SetContainerAnchor(container, corner) + AK.SetContainerAxis(container, vertical) + if liveSpec.layout.growthH then + AK.SetContainerGrowth(container, liveSpec.layout.growthH, liveSpec.layout.growthV) + end + AK.SetContainerPadding(container, 0, 0, 0, 0) + AK.SetContainerRowWidth(container, grid.rowWidth) + + container:SetAuraGroupMaxFrameCount("extdef", grid.effectiveMax) + container:SetAuraGroupLayout("extdef", { + elementSpacing = pad, lineSpacing = grid.rowGap, + groupSpacing = pad, groupLineSpacing = grid.rowGap, + }) + do + local sortMethod, sortDirection = ResolveSortMethod(cfg), ResolveSortDirection(cfg) + if sortMethod ~= nil and sortDirection ~= nil then + container:SetAuraGroupSortMethod("extdef", sortMethod, sortDirection) + end + end + + if PAB_MaybeRefreshPreview then PAB_MaybeRefreshPreview("buff", "extdef") end +end +ns.PAB_ApplyExtDefLiveConfig = ApplyExtDefLiveConfig + +-- Bridge for EllesmereUF:GetGrowDirectionForBar/SetGrowDirectionForBar (see +-- snippet to add in EllesmereUIUnitFrames.lua). Kept here, not inlined +-- there, so the settings-field names stay defined in one place. +-- +-- Also handles custom-bar keys ("PAB_CustomBuff_" / "PAB_CustomDebuff_ +-- ", the unlock-mode keys RegisterPABCustomUnlock registers below) -- +-- EUI_UnlockMode.lua's Grow dropdown dispatches to this bridge for ANY +-- barKey prefixed "PAB_", not just the two default bars, so custom bars +-- need their own branch here or the dropdown silently no-ops on them +-- (2026-08-02 fix: originally only recognized the two literal default keys). +-- ns.PAB_ReloadCustomBuffBar/DebuffBar are called (not a direct +-- BuildContainerSpec/SetContainerGrowth call) since the spell/class +-- signature is unchanged and they already re-apply corner + growth on that +-- cheap path -- see those functions' own doc comments. +function ns.PAB_GetGrowDirection(barKey) + local s = PAB() + if not s then return "LEFT" end + if barKey == "PAB_Buffs" then return DefaultBuffsCfg(s).growDirection or "LEFT" end + if barKey == "PAB_Debuffs" then return DefaultDebuffsCfg(s).growDirection or "LEFT" end + if barKey == "PAB_ExternalDefensives" then return DefaultExternalDefensivesCfg(s).growDirection or "LEFT" end + local buffId = barKey:match("^PAB_CustomBuff_(%d+)$") + if buffId then + local bar = ns.PAB_GetCustomBuffBar(tonumber(buffId)) + return bar and (bar.growDirection or "LEFT") or "LEFT" + end + local debuffId = barKey:match("^PAB_CustomDebuff_(%d+)$") + if debuffId then + local bar = ns.PAB_GetCustomDebuffBar(tonumber(debuffId)) + return bar and (bar.growDirection or "LEFT") or "LEFT" + end + return "LEFT" +end + +function ns.PAB_SetGrowDirection(barKey, dir) + local s = PAB() + if not s then return end + if barKey == "PAB_Buffs" then + DefaultBuffsCfg(s).growDirection = dir + ApplyLiveConfig(true) + return + elseif barKey == "PAB_Debuffs" then + DefaultDebuffsCfg(s).growDirection = dir + ApplyLiveConfig(false) + return + elseif barKey == "PAB_ExternalDefensives" then + DefaultExternalDefensivesCfg(s).growDirection = dir + ApplyExtDefLiveConfig() + return + end + local buffId = barKey:match("^PAB_CustomBuff_(%d+)$") + if buffId then + local bar = ns.PAB_GetCustomBuffBar(tonumber(buffId)) + if bar then + bar.growDirection = dir + ns.PAB_ReloadCustomBuffBar(bar.id) + end + return + end + local debuffId = barKey:match("^PAB_CustomDebuff_(%d+)$") + if debuffId then + local bar = ns.PAB_GetCustomDebuffBar(tonumber(debuffId)) + if bar then + bar.growDirection = dir + ns.PAB_ReloadCustomDebuffBar(bar.id) + end + return + end +end + +------------------------------------------------------------------------------- +-- Custom bars (free bar creator) +-- +-- Buffs: SpellID-based (BM2 model) -- customBuffBars entries carry +-- filters={[filterId]=true}, spells={id,...}, ownOnlySpells={[id]=bool}. +-- Same shape and same PAB_ResolveSpells() union the default Buffs bar +-- uses as of 2026-08-01 (see CreateBars/ApplyLiveConfig above) -- default +-- Buffs and custom Buff Bars are now ONE model, not two. Resolution/ +-- rendering (one candidateFilters-restricted GROUP into the bar's own +-- dedicated container, signature-gated rebuild -- see PAB_ReloadCustomBuffBar's +-- own doc comment for why this is a group, not per-spell slots) lives here +-- for custom bars; this section is the data layer for the CRUD, engine +-- wiring is further down. +-- +-- Debuffs: category-based (DM model, same as RaidFrames) -- customDebuffBars +-- entries carry classFilters={[classKey]=true} and render through the +-- EXISTING BuildChain/ApplyGroupConfig path used by the two default groups, +-- no new engine machinery needed. +-- +-- ID scheme mirrors ns.BM2_AddFilter (EUI_RaidFrames_BuffManager2.lua): +-- a single monotonically increasing counter, never reused, so deleted +-- bars' engine-side declarations (which are ADD-ONLY on the container, +-- see ApplyGroupConfig's doc comment) never collide with a later bar. +------------------------------------------------------------------------------- + +-- Ensures db.profile.playerAuraBars itself exists before writing to it -- +-- PAB() alone is read-only and may return nil (Bug 3 from the Step E +-- history: reading via `PAB() or {}` silently wrote into a throwaway table +-- that never persisted). Only CRUD (write) functions call this. +local function PABEnsure() + local db = ns.db + if not (db and db.profile) then return nil end + db.profile.playerAuraBars = db.profile.playerAuraBars or {} + return db.profile.playerAuraBars +end + +local function NextBarId(s) + s.nextBarId = (s.nextBarId or 1) + local id = s.nextBarId + s.nextBarId = id + 1 + return id +end + +------------------------------------------------------------------------------- +-- Buff Filters (BM2-style named spell sets) +-- +-- Global registry (db.profile.playerAuraBars.pabFilters), same shape as +-- RaidFrames' ns.BM2_Filters storage (EUI_RaidFrames_BuffManager2.lua: +-- b.filters = { nextId = 1, list = {} }) -- id/name/nextId counter pattern +-- mirrored 1:1. User-created filters are fully renameable/deletable; the +-- 10 curated BM2 presets (Defensives, Raid CDs, Externals, etc.) imported +-- via ns.PAB_ImportBM2Filters carry `f.preset = true` -- same protected- +-- preset flag as BM2_Filters, not renameable/deletable (see the Filter +-- Editor's sidebar/detail-header guards). Spell-level checkboxes within a +-- preset filter stay fully editable either way -- only the filter's own +-- name/existence is protected. +-- +-- Referenced by id from any buff-side cfg's `filters` table +-- ([filterId]=true) -- the default Buffs bar and every custom buff bar +-- share ONE filter registry, same as BM2 indicators sharing one filter +-- list. Own-only tracking (BM2's ownFilters/ownExtras) is intentionally +-- NOT implemented here -- out of scope for this pass, not requested; +-- bar.ownOnlySpells stays reserved-but-unused, same status as before. +------------------------------------------------------------------------------- + +local function FilterStore(s) + s.pabFilters = s.pabFilters or { nextId = 1, list = {} } + return s.pabFilters +end + +function ns.PAB_Filters() + local s = PAB() + local store = s and s.pabFilters + return store and store.list or nil +end + +function ns.PAB_GetFilter(id) + local list = ns.PAB_Filters() + if not list then return nil end + for i = 1, #list do + if list[i].id == id then return list[i] end + end +end + +function ns.PAB_AddFilter(name) + local s = PABEnsure() + if not s then return nil end + local store = FilterStore(s) + local f = { id = store.nextId, name = name or "New Filter", spells = {} } + store.nextId = store.nextId + 1 + store.list[#store.list + 1] = f + return f +end + +function ns.PAB_RenameFilter(id, name) + local f = ns.PAB_GetFilter(id) + if f and name and name ~= "" then f.name = name end +end + +-- Also strips the filter's assignment from every buff-side cfg that could +-- reference it (default Buffs bar + every custom buff bar) -- mirrors +-- BM2_DeleteFilter stripping ind.filters[id] off every indicator. +function ns.PAB_DeleteFilter(id) + local s = PAB() + if not (s and s.pabFilters) then return end + local list = s.pabFilters.list + for i = #list, 1, -1 do + if list[i].id == id then table.remove(list, i) end + end + if s.defaultBuffs and s.defaultBuffs.filters then s.defaultBuffs.filters[id] = nil end + local customBuffBars = s.customBuffBars + if customBuffBars then + for i = 1, #customBuffBars do + local bar = customBuffBars[i] + if bar.filters then bar.filters[id] = nil end + end + end +end + +-- Checkbox state for one spell within one filter. state=nil removes the +-- spell entirely (matches BM2_SetSpellState's custom-spell-removal path -- +-- every PAB filter spell is "custom", there is no curated/preset spell to +-- fall back to). +function ns.PAB_SetSpellState(filterId, spellID, state) + local f = ns.PAB_GetFilter(filterId) + if not f then return end + if state == nil then + f.spells[spellID] = nil + else + f.spells[spellID] = state and true or false + end +end + +function ns.PAB_AddSpellToFilter(filterId, spellID) + local f = ns.PAB_GetFilter(filterId) + if not (f and spellID and spellID > 0) then return false end + if f.spells[spellID] ~= nil then return false end -- already present + f.spells[spellID] = true + return true +end + +-- Union of a buff-side cfg's direct spells (cfg.spells) + the enabled +-- spells of every filter it references (cfg.filters). Mirrors +-- BM2_ResolveSpellsOwn's Add()/set-union logic, minus own-only tracking +-- (see doc comment above). Sorted so the caller's signature diffing stays +-- deterministic (same reasoning as CustomBuffSpellSignature). +function ns.PAB_ResolveSpells(cfg) + local set = {} + local spells = cfg.spells + if spells then + for i = 1, #spells do set[spells[i]] = true end + end + local filters = cfg.filters + if filters then + for filterId in pairs(filters) do + local f = ns.PAB_GetFilter(filterId) + if f then + for id, on in pairs(f.spells) do + if on then set[id] = true end + end + end + end + end + local out = {} + for id in pairs(set) do out[#out + 1] = id end + table.sort(out) + return out +end + +function ns.PAB_CustomBuffBars() + local s = PAB() + return s and s.customBuffBars or nil +end + +------------------------------------------------------------------------------- +-- Buff Manager filter import (Joel, 2026-08-01): all 10 curated preset +-- filters from RaidFrames' Buff Manager 2 (Defensives, Raid CDs, +-- Externals, Core/Lesser Healing Buffs, Support, Offensive CDs, Movement, +-- Utility, Consumables) -- ported as a starting point for PAB Filters. +-- +-- Spell IDs extracted programmatically from EUI_RaidFrames_BuffManager2 +-- .lua's PRESET_FILTERS + DEFAULT_FILTER_SPELLS tables (primary ids + +-- their `alts` flattened into one flat list each -- PAB's Filters have no +-- primary/alt grouping concept, every id is just its own checkbox row) -- +-- NOT retyped by hand, to rule out transcription errors. `disabled` +-- entries are imported too (matching BM2's own list, which keeps them +-- visible-but-unchecked) but start unchecked here as well. +------------------------------------------------------------------------------- + +-- Display-only hint: which class a curated (imported) spell belongs to, +-- so the Filter Editor can group/color rows the same way BM2's does. +-- Purely cosmetic -- PAB_ResolveSpells/PAB_SetSpellState never consult +-- this table, a filter's spells are always just a flat {id=bool} set. +-- Extracted from the same PRESET_FILTERS/DEFAULT_FILTER_SPELLS source as +-- BM2_FILTER_SEED below (254 unique entries, "ALL"-class spells omitted +-- since those don't get a class header in BM2 either). +local SPELL_CLASS_HINTS = { + [47585] = "PRIEST", + [404381] = "EVOKER", + [427912] = "DEMONHUNTER", + [258920] = "DEMONHUNTER", + [48792] = "DEATHKNIGHT", + [184662] = "PALADIN", + [108416] = "WARLOCK", + [114216] = "PRIEST", + [114214] = "PRIEST", + [193065] = "PRIEST", + [1266616] = "DEMONHUNTER", + [394933] = "DEMONHUNTER", + [212800] = "DEMONHUNTER", + [192081] = "DRUID", + [374349] = "EVOKER", + [472708] = "HUNTER", + [184364] = "WARRIOR", + [498] = "PALADIN", + [403876] = "PALADIN", + [22842] = "DRUID", + [235450] = "MAGE", + [31224] = "ROGUE", + [147833] = "WARRIOR", + [11426] = "MAGE", + [45438] = "MAGE", + [414658] = "MAGE", + [49039] = "DEATHKNIGHT", + [642] = "PALADIN", + [264735] = "HUNTER", + [61336] = "DRUID", + [186265] = "HUNTER", + [5277] = "ROGUE", + [385391] = "WARRIOR", + [393903] = "DRUID", + [45242] = "PRIEST", + [426401] = "PRIEST", + [118038] = "WARRIOR", + [104773] = "WARLOCK", + [363916] = "EVOKER", + [1966] = "ROGUE", + [108271] = "SHAMAN", + [190456] = "WARRIOR", + [1277297] = "WARRIOR", + [22812] = "DRUID", + [122783] = "MONK", + [48707] = "DEATHKNIGHT", + [444741] = "DEATHKNIGHT", + [442715] = "DEMONHUNTER", + [342246] = "MAGE", + [586] = "PRIEST", + [19236] = "PRIEST", + [235313] = "MAGE", + [115203] = "MONK", + [120954] = "MONK", + [145629] = "DEATHKNIGHT", + [51052] = "DEATHKNIGHT", + [209426] = "DEMONHUNTER", + [196718] = "DEMONHUNTER", + [374227] = "EVOKER", + [359816] = "EVOKER", + [362361] = "EVOKER", + [81782] = "PRIEST", + [62618] = "PRIEST", + [740] = "DRUID", + [157982] = "DRUID", + [1264623] = "DRUID", + [31821] = "PALADIN", + [317929] = "PALADIN", + [363534] = "EVOKER", + [64843] = "PRIEST", + [64844] = "PRIEST", + [97463] = "WARRIOR", + [97462] = "WARRIOR", + [325174] = "SHAMAN", + [98008] = "SHAMAN", + [102342] = "DRUID", + [116849] = "MONK", + [33206] = "PRIEST", + [6940] = "PALADIN", + [357170] = "EVOKER", + [387804] = "PALADIN", + [53480] = "HUNTER", + [204018] = "PALADIN", + [47788] = "PRIEST", + [1022] = "PALADIN", + [1309794] = "PALADIN", + [156910] = "PALADIN", + [376788] = "EVOKER", + [409895] = "EVOKER", + [474754] = "DRUID", + [474750] = "DRUID", + [155777] = "DRUID", + [48438] = "DRUID", + [419344] = "DRUID", + [450769] = "MONK", + [450521] = "MONK", + [450711] = "MONK", + [450526] = "MONK", + [450531] = "MONK", + [33763] = "DRUID", + [419207] = "DRUID", + [1227806] = "DRUID", + [1291636] = "EVOKER", + [409678] = "EVOKER", + [1244893] = "PALADIN", + [1245369] = "PALADIN", + [1278914] = "DRUID", + [8936] = "DRUID", + [419287] = "DRUID", + [53563] = "PALADIN", + [355941] = "EVOKER", + [355936] = "EVOKER", + [382614] = "EVOKER", + [432502] = "PALADIN", + [363502] = "EVOKER", + [156322] = "PALADIN", + [461432] = "PALADIN", + [207400] = "SHAMAN", + [450805] = "MONK", + [1253593] = "PRIEST", + [1300009] = "PRIEST", + [774] = "DRUID", + [419204] = "DRUID", + [444490] = "SHAMAN", + [383648] = "SHAMAN", + [974] = "SHAMAN", + [194384] = "PRIEST", + [467281] = "MONK", + [427296] = "MONK", + [453846] = "PRIEST", + [453850] = "PRIEST", + [439530] = "DRUID", + [77489] = "PRIEST", + [367364] = "EVOKER", + [139] = "PRIEST", + [17] = "PRIEST", + [1246768] = "PRIEST", + [1254306] = "PRIEST", + [1300008] = "PRIEST", + [41635] = "PRIEST", + [469703] = "PALADIN", + [61295] = "SHAMAN", + [431381] = "PALADIN", + [431522] = "PALADIN", + [200025] = "PALADIN", + [115175] = "MONK", + [1260617] = "MONK", + [198533] = "MONK", + [119611] = "MONK", + [388513] = "MONK", + [124682] = "MONK", + [364343] = "EVOKER", + [1292922] = "MONK", + [373862] = "EVOKER", + [366155] = "EVOKER", + [445740] = "EVOKER", + [373267] = "EVOKER", + [360827] = "EVOKER", + [410263] = "EVOKER", + [395152] = "EVOKER", + [395296] = "EVOKER", + [413984] = "EVOKER", + [369459] = "EVOKER", + [410089] = "EVOKER", + [106951] = "DRUID", + [191427] = "DEMONHUNTER", + [187827] = "DEMONHUNTER", + [321067] = "DEMONHUNTER", + [321068] = "DEMONHUNTER", + [186254] = "HUNTER", + [1235388] = "HUNTER", + [1285912] = "HUNTER", + [19574] = "HUNTER", + [190319] = "MAGE", + [50334] = "DRUID", + [1249658] = "DEATHKNIGHT", + [152279] = "DEATHKNIGHT", + [471306] = "DEMONHUNTER", + [1217605] = "DEMONHUNTER", + [1225789] = "DEMONHUNTER", + [473671] = "DEMONHUNTER", + [1217607] = "DEMONHUNTER", + [42650] = "DEATHKNIGHT", + [10060] = "PRIEST", + [365350] = "MAGE", + [107574] = "WARRIOR", + [194223] = "DRUID", + [375087] = "EVOKER", + [114050] = "SHAMAN", + [114051] = "SHAMAN", + [114052] = "SHAMAN", + [288613] = "HUNTER", + [403631] = "EVOKER", + [1249625] = "MONK", + [79206] = "SHAMAN", + [192082] = "SHAMAN", + [444754] = "MAGE", + [443569] = "MONK", + [48265] = "DEATHKNIGHT", + [252216] = "DRUID", + [118922] = "HUNTER", + [212552] = "DEATHKNIGHT", + [58875] = "SHAMAN", + [90328] = "SHAMAN", + [119085] = "MONK", + [111400] = "WARLOCK", + [276111] = "PALADIN", + [221886] = "PALADIN", + [221883] = "PALADIN", + [276112] = "PALADIN", + [254474] = "PALADIN", + [254472] = "PALADIN", + [254471] = "PALADIN", + [221885] = "PALADIN", + [254473] = "PALADIN", + [363608] = "PALADIN", + [294133] = "PALADIN", + [221887] = "PALADIN", + [1272854] = "PALADIN", + [453804] = "PALADIN", + [1253874] = "PALADIN", + [1253723] = "PALADIN", + [1253881] = "PALADIN", + [101545] = "MONK", + [186257] = "HUNTER", + [186258] = "HUNTER", + [202164] = "WARRIOR", + [121557] = "PRIEST", + [2983] = "ROGUE", + [1850] = "DRUID", + [61684] = "DRUID", + [106898] = "DRUID", + [77761] = "DRUID", + [77764] = "DRUID", + [3714] = "DEATHKNIGHT", + [406732] = "EVOKER", + [406789] = "EVOKER", + [390386] = "EVOKER", + [466904] = "HUNTER", + [115834] = "ROGUE", + [114018] = "ROGUE", + [408233] = "EVOKER", + [2825] = "SHAMAN", + [116841] = "MONK", + [29166] = "DRUID", + [1044] = "PALADIN", + [299256] = "PALADIN", + [80353] = "MAGE", + [264667] = "HUNTER", + [357650] = "HUNTER", + [32182] = "SHAMAN", + [1224810] = "HUNTER", + [54216] = "HUNTER", + [62305] = "HUNTER", +} +ns.PAB_SPELL_CLASS_HINTS = SPELL_CLASS_HINTS + +local BM2_FILTER_SEED = { + { name = "Defensives", + enabled = {498, 586, 642, 1966, 5277, 19236, 22812, 22842, 31224, 45438, 47585, 48707, 48792, 61336, 104773, 108271, 108416, 115203, 118038, 120954, 122783, 147833, 184364, 186265, 190456, 193065, 212800, 264735, 342246, 363916, 374349, 385391, 403876, 404381, 414658, 444741, 1277297}, + disabled = {11426, 45242, 49039, 114214, 114216, 184662, 192081, 235313, 235450, 258920, 393903, 394933, 426401, 427912, 442715, 472708, 1266616} }, + { name = "Raid CDs", + enabled = {31821, 51052, 62618, 81782, 97462, 97463, 98008, 145629, 196718, 209426, 317929, 325174, 374227}, + disabled = {740, 64843, 64844, 157982, 359816, 362361, 363534, 1264623} }, + { name = "Externals", + enabled = {1022, 6940, 33206, 47788, 53480, 102342, 116849, 204018, 357170, 387804, 1309794}, + disabled = {} }, + { name = "Core Healing Buffs", + enabled = {974, 33763, 53563, 119611, 156910, 194384, 200025, 364343, 373267, 383648, 419207, 474750, 474754, 1227806, 1244893, 1245369}, + disabled = {17, 139, 774, 8936, 41635, 48438, 61295, 77489, 115175, 124682, 155777, 156322, 198533, 207400, 355936, 355941, 363502, 366155, 367364, 373862, 376788, 382614, 388513, 409678, 409895, 419204, 419287, 419344, 427296, 431381, 431522, 432502, 439530, 444490, 445740, 450521, 450526, 450531, 450711, 450769, 450805, 453846, 453850, 461432, 467281, 469703, 1246768, 1253593, 1254306, 1260617, 1278914, 1291636, 1292922, 1300008, 1300009} }, + { name = "Lesser Healing Buffs", + enabled = {17, 139, 774, 8936, 41635, 48438, 61295, 77489, 115175, 124682, 155777, 156322, 198533, 355936, 355941, 366155, 367364, 373267, 376788, 382614, 409895, 419204, 419287, 419344, 431381, 431522, 432502, 444490, 450521, 450526, 450531, 450711, 450769, 461432, 469703, 1246768, 1253593, 1254306, 1260617, 1278914, 1292922, 1300008, 1300009}, + disabled = {974, 33763, 53563, 119611, 156910, 194384, 200025, 207400, 363502, 364343, 373862, 383648, 388513, 409678, 419207, 427296, 439530, 445740, 450805, 453846, 453850, 467281, 474750, 474754, 1227806, 1244893, 1245369, 1291636} }, + { name = "Support", + enabled = {360827, 395152, 395296, 410089}, + disabled = {369459, 410263, 413984} }, + { name = "Offensive CDs", + enabled = {10060, 19574, 42650, 50334, 106951, 107574, 114050, 114051, 114052, 152279, 186254, 187827, 190319, 191427, 194223, 288613, 321067, 321068, 365350, 375087, 403631, 471306, 473671, 1217605, 1217607, 1225789, 1235388, 1249625, 1249658, 1285912}, + disabled = {} }, + { name = "Movement", + enabled = {1850, 2983, 48265, 58875, 61684, 77761, 77764, 79206, 90328, 106898, 111400, 118922, 119085, 121557, 186257, 186258, 192082, 202164, 212552, 221883, 221885, 221886, 221887, 252216, 254471, 254472, 254473, 254474, 276111, 276112, 294133, 363608, 443569, 444754, 453804, 1253723, 1253874, 1253881, 1272854}, + disabled = {101545} }, + { name = "Utility", + enabled = {1044, 3714, 29166, 54216, 62305, 114018, 115834, 116841, 299256, 406732, 406789, 1224810}, + disabled = {2825, 32182, 80353, 264667, 357650, 390386, 408233, 466904} }, + { name = "Consumables", + enabled = {1236616, 1236994, 1236998, 1239479}, + disabled = {} }, +} + +-- Idempotent: skips any seed entry whose exact name already exists as a +-- filter (so re-clicking Import doesn't create duplicates). Returns the +-- number of filters actually created. +-- Equivalent of ns.BM2_AllPresetSpells() -- every spell ID across all 10 +-- curated presets (enabled AND disabled entries both count, same as BM2's +-- own `for id in pairs(spells)`, which doesn't check the disabled flag +-- either). One honest divergence from a byte-identical port: BM2_FILTER_ +-- SEED already has `alts` flattened into its enabled/disabled lists (see +-- that table's own doc comment), so this returns a superset of real +-- spell IDs BM2_AllPresetSpells would -- more complete for PAB's purposes +-- (alts ARE valid trackable buff spell IDs), not a bug. +function ns.PAB_AllPresetSpells() + local set = {} + for i = 1, #BM2_FILTER_SEED do + local seed = BM2_FILTER_SEED[i] + for j = 1, #seed.enabled do set[seed.enabled[j]] = true end + for j = 1, #seed.disabled do set[seed.disabled[j]] = true end + end + local out = {} + for id in pairs(set) do out[#out + 1] = id end + table.sort(out) + return out +end + +function ns.PAB_ImportBM2Filters() + local byName = {} + local list = ns.PAB_Filters() or {} + for i = 1, #list do byName[list[i].name] = list[i] end + + local created = 0 + for i = 1, #BM2_FILTER_SEED do + local seed = BM2_FILTER_SEED[i] + local f = byName[seed.name] + if not f then + f = ns.PAB_AddFilter(seed.name) + if f then + for j = 1, #seed.enabled do f.spells[seed.enabled[j]] = true end + for j = 1, #seed.disabled do f.spells[seed.disabled[j]] = false end + created = created + 1 + end + end + -- Retroactively flags filters imported before f.preset existed + -- (idempotent-by-name previously meant they were silently skipped + -- forever and never got the protection flag) -- runs every call, + -- not just on fresh creation. + if f then f.preset = true end + end + return created +end + +function ns.PAB_CustomDebuffBars() + local s = PAB() + return s and s.customDebuffBars or nil +end + +function ns.PAB_GetCustomBuffBar(id) + local list = ns.PAB_CustomBuffBars() + if not list then return nil end + for i = 1, #list do + if list[i].id == id then return list[i] end + end +end + +function ns.PAB_GetCustomDebuffBar(id) + local list = ns.PAB_CustomDebuffBars() + if not list then return nil end + for i = 1, #list do + if list[i].id == id then return list[i] end + end +end + +-- Bar objects (both kinds) also carry the same shared+category cfg fields +-- as DefaultBuffsCfg/DefaultDebuffsCfg (iconSize, durationShow/stackShow, +-- durationPosition/TextSize/OffsetX/Y/ColorR/G/B, stackPosition/TextSize/ +-- OffsetX/Y/ColorR/G/B; buff/debuff bars additionally borderSize/R/G/B/A, +-- iconZoom, padding, iconsPerRow, maxRows, maxTotal; debuff bars additionally +-- dispelColorMagic/Curse/Disease/Poison/Bleed) -- NOT pre-populated here, +-- same as DefaultBuffsCfg/DefaultDebuffsCfg +-- starting as {}. BuildStyle/ComputeGrid apply the same `or ` +-- fallbacks regardless of whether the field is simply unset or the table +-- was just created, so a fresh bar renders with sane defaults immediately +-- and the Options UI only ever needs to write the fields the user touches. +function ns.PAB_AddCustomBuffBar(name) + local s = PABEnsure() + if not s then return nil end + s.customBuffBars = s.customBuffBars or {} + local bar = { + id = NextBarId(s), + name = name or "New Buff Bar", + enabled = true, + filters = {}, -- [filterId] = true (BM2-style assigned filters) + spells = {}, -- {spellID, ...} direct/custom spells + ownOnlySpells = {}, -- [spellID] = bool + growDirection = "LEFT", + -- Starting grid (2026-08-02, Joel's chosen values): a compact + -- single row, distinct from the default bars' own ComputeGrid + -- fallback (11x3 for buffs) -- a freshly-added custom bar is meant + -- to start small, not inherit the default bar's larger grid. + iconsPerRow = 8, + maxRows = 1, + maxTotal = 8, + } + s.customBuffBars[#s.customBuffBars + 1] = bar + return bar +end + +function ns.PAB_AddCustomDebuffBar(name) + local s = PABEnsure() + if not s then return nil end + s.customDebuffBars = s.customDebuffBars or {} + local bar = { + id = NextBarId(s), + name = name or "New Debuff Bar", + enabled = true, + classFilters = {}, -- [classKey] = true, same vocabulary as BuildChain + growDirection = "LEFT", + -- Same starting-grid reasoning as PAB_AddCustomBuffBar above. + iconsPerRow = 8, + maxRows = 1, + maxTotal = 8, + } + s.customDebuffBars[#s.customDebuffBars + 1] = bar + return bar +end + +-- Deletion only strips the DB entry; it deliberately does NOT try to remove +-- the bar's engine-side group/slots (containers are add-only, see +-- ApplyGroupConfig doc comment). The engine-wiring layer must instead detect +-- the missing DB entry and set maxFrameCount = 0 / hide the bar's frames, +-- mirroring how disabled default groups are handled today. +function ns.PAB_DeleteCustomBuffBar(id) + local s = PABEnsure() + if not (s and s.customBuffBars) then return end + for i = #s.customBuffBars, 1, -1 do + if s.customBuffBars[i].id == id then table.remove(s.customBuffBars, i) end + end +end + +function ns.PAB_DeleteCustomDebuffBar(id) + local s = PABEnsure() + if not (s and s.customDebuffBars) then return end + for i = #s.customDebuffBars, 1, -1 do + if s.customDebuffBars[i].id == id then table.remove(s.customDebuffBars, i) end + end +end + +------------------------------------------------------------------------------- +-- Custom bars -- engine wiring +-- +-- Debuffs: one dedicated container per bar (own parent frame, own +-- AK.RequestContainer), groups declared through the SAME BuildChain/ +-- ApplyGroupConfig path the two default bars use -- just fed the bar +-- itself as cfg (bar objects share DefaultBuffsCfg/DefaultDebuffsCfg's +-- field shape, see the CRUD section above). Nothing new engine-side: this +-- is "one more container using an already-proven path." +-- +-- Buffs: SpellID-based (per Joel: no class-token checkboxes for custom +-- buff bars -- selection is via filters/direct spells only). Each bar's +-- container holds ONE GROUP for that bar's resolved spell set +-- (candidateFilters.includeSpellIDs, map shape {[id]=true} -- verified +-- against EUI_RaidFrames_AuraContainers.lua's BmIncludeMap/BmSimpleCand +-- and EUI_ResourceBars_EbonMight121.lua). 2026-08-02: this was originally +-- one AK.AddAuraSlot per spellID, but field-verified instrumentation +-- showed AK's flow layout only positions GROUP content -- slots never got +-- a real anchor point (GetPoint(1) nil) and never rendered, even +-- unrestricted ones. See CreateBars' matching doc comment for the full +-- writeup. Still flows through the SAME ComputeGrid/BuildContainerSpec +-- grid (padding/iconsPerRow/maxRows/maxTotal/growDirection) as every +-- other bar. Unlike RaidFrames' Buff Manager -- where custom-spell content +-- shares a container with structurally-stable chain/simple groups, so +-- only a dedicated sub-container gets released on a spell-list change +-- (see BmSignature / the "Release the SLOTS container only" comment) -- a +-- custom buff bar's container here holds nothing else. Releasing and +-- rebuilding the WHOLE bar container on a spell-list change is therefore +-- safe: there is no other shared group on it to lose frames. Deliberate +-- simplification of the RaidFrames pattern for PAB's dedicated-per-bar- +-- container design, not a partial copy of it. +-- +-- 2026-08-01: bar.filters is now resolved (see ns.PAB_ResolveSpells and +-- the Filter Editor, EUI_PlayerAuraBars_ManagerPages.lua) -- the signature +-- below folds resolved filter spells in alongside bar.spells, same as the +-- default Buffs bar. bar.ownOnlySpells remains UNCONSULTED -- own-only +-- tracking (BM2's ownFilters/ownExtras) was explicitly out of scope for +-- this pass, not a partial miss; add it the same way BM2 does if wanted. +------------------------------------------------------------------------------- + +local customBuffParents, customBuffContainers, customBuffSig, customBuffDeclared = {}, {}, {}, {} +local customDebuffParents, customDebuffContainers, customDebuffDeclared = {}, {}, {} + +-- Tracks which unlock-mode keys are currently registered for custom bars, +-- so RegisterPABCustomUnlock can retire keys for bars deleted since the +-- previous call. See that function's doc comment. +local pabRegisteredCustomBuffKeys, pabRegisteredCustomDebuffKeys + +local function CustomBuffStyleKey(barId) return "playerAuraBars_customBuff_" .. barId end +local function CustomDebuffStyleKey(barId) return "playerAuraBars_customDebuff_" .. barId end + +-- Default anchor for a bar with no saved position yet -- the SAME fixed +-- spot (screen center, slight upward offset) for every bar, not staggered +-- by barId (2026-08-02, Joel's explicit request): since this is only ever +-- read as a fallback for a bar that has no bar.pos, a bar that's been +-- dragged elsewhere keeps its own saved position regardless, while any +-- bar still untouched -- 1st, 2nd, 3rd, ... -- always starts from this +-- same default until the user moves it. +local function DefaultCustomPos(barId) + return { point = "CENTER", relPoint = "CENTER", x = 0, y = 80 } +end + +-- Applies bar.pos (or the default) to a custom bar's parent frame. Mirrors +-- ApplyBarPosition for the two default bars -- kept separate since custom +-- bars key off bar.pos on the bar object, not a fixed s[BarPositionKey] +-- slot, but the SetPoint logic itself is identical. +local function ApplyCustomBarPosition(parent, bar, barId) + local pos = bar.pos or DefaultCustomPos(barId) + parent:ClearAllPoints() + local x, y = SnapBarPos(parent, pos.point, pos.relPoint or pos.point, pos.x, pos.y) + parent:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, x, y) +end + +local function CustomBuffSpellSignature(spells) + return table.concat(spells, ",") +end + +-- Unlock-mode registration for custom bars, patterned on RegisterPABUnlock +-- (the two default bars) for the per-element schema, and on +-- EllesmereUICdmBuffBars.lua's ns.RegisterTBBUnlockElements for the "dynamic +-- list" shape: rebuilds the FULL element list (every currently-persisted +-- custom buff + debuff bar) on every call and re-registers it, rather than +-- trying to diff adds/removes incrementally. Cheap at PAB's expected bar +-- counts, and it means a freshly-added bar just appears next call with no +-- separate registration path. +-- +-- Unlike TBB (index-keyed, so a deleted mid-list bar reshuffles every +-- higher key and its links), PAB custom bars carry a permanent NextBarId +-- that's never reused or renumbered, so the "never unregister, just hide" +-- caution from TBB's doc comment doesn't apply here: a genuinely deleted +-- bar's key is retired for good, so calling UnregisterUnlockElement for it +-- is correct, not lossy. Still noResize/noAnchorTarget for the same reason +-- as the default bars: AuraKit sizes the container itself, and a +-- dynamically-resizing frame is a bad anchor target for other elements. +local function RegisterPABCustomUnlock() + if not (EllesmereUI and EllesmereUI.RegisterUnlockElements and EllesmereUI.MakeUnlockElement) then return end + local MK = EllesmereUI.MakeUnlockElement + + local prevBuffKeys, prevDebuffKeys = pabRegisteredCustomBuffKeys, pabRegisteredCustomDebuffKeys + pabRegisteredCustomBuffKeys, pabRegisteredCustomDebuffKeys = {}, {} + + local function MakeCustomBarElement(barId, bar, order, isBuff, parents) + local key = (isBuff and "PAB_CustomBuff_" or "PAB_CustomDebuff_") .. barId + return key, MK({ + key = key, + label = "PAB: " .. (bar.name or (isBuff and "Buff Bar" or "Debuff Bar")), + group = "Player Aura Bars", + order = order, + noResize = true, + noAnchorTarget = true, + isHidden = function() + local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) + return not b or b.enabled == false + end, + getFrame = function() return parents[barId] end, + getSize = function() + local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) + if not b then return 32, 32 end + local grid = ComputeGrid(isBuff, b) + return grid.width, grid.height + end, + savePos = function(_, point, relPoint, x, y) + local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) + if not b then return end + b.pos = { point = point, relPoint = relPoint or point, x = x, y = y } + end, + loadPos = function() + local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) + return b and b.pos or nil + end, + clearPos = function() + local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) + if b then b.pos = nil end + end, + applyPos = function() + local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) + local parent = parents[barId] + if b and parent then ApplyCustomBarPosition(parent, b, barId) end + end, + }) + end + + local elements = {} + local buffList = ns.PAB_CustomBuffBars() + if buffList then + for i = 1, #buffList do + local bar = buffList[i] + local key, el = MakeCustomBarElement(bar.id, bar, 702, true, customBuffParents) + elements[#elements + 1] = el + pabRegisteredCustomBuffKeys[key] = true + end + end + local debuffList = ns.PAB_CustomDebuffBars() + if debuffList then + for i = 1, #debuffList do + local bar = debuffList[i] + local key, el = MakeCustomBarElement(bar.id, bar, 703, false, customDebuffParents) + elements[#elements + 1] = el + pabRegisteredCustomDebuffKeys[key] = true + end + end + + if #elements > 0 then + EllesmereUI:RegisterUnlockElements(elements, "EllesmereUIUnitFrames") + end + + -- Retire keys for bars deleted since the last call -- safe here (unlike + -- TBB) because PAB custom-bar ids are permanent, see doc comment above. + if prevBuffKeys then + for key in pairs(prevBuffKeys) do + if not pabRegisteredCustomBuffKeys[key] then EllesmereUI:UnregisterUnlockElement(key) end + end + end + if prevDebuffKeys then + for key in pairs(prevDebuffKeys) do + if not pabRegisteredCustomDebuffKeys[key] then EllesmereUI:UnregisterUnlockElement(key) end + end + end +end +ns.PAB_RegisterCustomUnlock = RegisterPABCustomUnlock + +-- Public hook for the Options UI: (re)builds one custom buff bar's engine +-- state to match its current DB entry. Safe to call after ANY change to +-- that bar (spell add/remove, any cfg field, enable toggle, delete) -- it +-- diffs the spell signature itself and only pays for a container rebuild +-- when the spell list actually changed; everything else (style, grid, +-- anchor) is cheap to just re-apply every time, same as the default bars' +-- RestyleBars/ApplyLiveConfig split does across two calls -- one combined +-- call here keeps the Options UI's call sites simple. +-- +-- Wrapped below so unlock-mode registration stays in sync on every exit +-- path (deleted, disabled, spell-list-unchanged, and full rebuild) without +-- duplicating the RegisterPABCustomUnlock() call at each of this function's +-- several early returns. +local function ReloadCustomBuffBarImpl(barId) + AK = AK or (EllesmereUI and EllesmereUI.AuraKit) + if not AK then return end + + local bar = ns.PAB_GetCustomBuffBar(barId) + if not bar then + -- Deleted: release the container (frees its slot-button tracking, + -- see AK.ReleaseContainer's doc comment -- the engine frames + -- themselves are never destroyed, same as everywhere else in AK) + -- and hide the now-orphaned parent frame. + if customBuffContainers[barId] then AK.ReleaseContainer(customBuffContainers[barId]) end + if customBuffParents[barId] then customBuffParents[barId]:Hide() end + customBuffContainers[barId], customBuffParents[barId], customBuffSig[barId], customBuffDeclared[barId] = nil, nil, nil, nil + return + end + + local styleKey = CustomBuffStyleKey(barId) + AK.styles[styleKey] = BuildStyle(true, bar) + -- Bug fix (2026-08-02): missing counterpart to RestyleBars' own + -- AK.RestyleSoon(STYLE_BUFFS)/(STYLE_DEBUFFS) for the two default bars. + -- Without this, writing AK.styles[styleKey] alone only affects buttons + -- created AFTER this point (MakeInitializer runs once per button, see + -- its own doc comment) -- any style-only edit (icon zoom, swipe, + -- stack/duration position, ...) on a custom bar whose container/buttons + -- already exist (the "spell list unchanged" cheap path further below) + -- silently kept rendering the OLD style until the container was + -- released and rebuilt for an unrelated reason. RestyleSoon re-runs + -- ApplyStyleToRegions against every already-live button under that key. + AK.RestyleSoon(styleKey) + + local parent = customBuffParents[barId] + if not parent then + parent = CreateFrame("Frame", "EllesmereUIPlayerAuraBars_CustomBuff" .. barId, UIParent) + customBuffParents[barId] = parent + end + ApplyCustomBarPosition(parent, bar, barId) + parent:SetShown(bar.enabled ~= false) + if bar.enabled == false then return end + + local grid = ComputeGrid(true, bar) + parent:SetSize(grid.width, grid.height) + + local spells = ns.PAB_ResolveSpells(bar) + local sig = CustomBuffSpellSignature(spells) + -- Show All Buffs: functional for custom buff bars too (2026-08-02 fix -- + -- bar.showAllBuffs previously wasn't read anywhere in this function, so + -- the UI toggle had no engine effect). Mirrors the default Buffs bar's + -- catch-all group exactly (BuildChain zero-classes trick, "all" key), + -- via the same ApplyGroupConfig path used for custom debuff bars' + -- category chain -- ApplyGroupConfig is generic over any {key,tokens} + -- chain, not debuff-specific. + local allChain = (bar.showAllBuffs ~= false) and BuildChain("HELPFUL", function() return false end) or {} + + if customBuffContainers[barId] then + -- Style/grid-only change (icon size, padding, grow direction, ...): + -- the container already exists and the spell list hasn't changed, + -- so just re-apply the live anchor/growth/rowWidth, same fields + -- ApplyLiveConfig live-updates for the default bars. + local corner, liveSpec, vertical = BuildContainerSpec(parent, bar, grid) + local container = customBuffContainers[barId] + container:ClearAllPoints() + container:SetPoint(corner, parent, corner, 0, 0) + AK.SetContainerAnchor(container, corner) + AK.SetContainerAxis(container, vertical) + if liveSpec.layout.growthH then + AK.SetContainerGrowth(container, liveSpec.layout.growthH, liveSpec.layout.growthV) + end + AK.SetContainerPadding(container, 0, 0, 0, 0) + AK.SetContainerRowWidth(container, grid.rowWidth) + + if customBuffSig[barId] == sig then + -- Spell list unchanged -- still refresh the spells group's + -- maxFrameCount/layout in case grid (icon size, padding, ...) + -- changed without the spell list changing, and re-apply the + -- catch-all chain (ApplyGroupConfig is idempotent and + -- self-zeroes it when Show All Buffs is off). + if #spells > 0 then + local livePad = bar.padding or 5 + container:SetAuraGroupMaxFrameCount("spells", grid.effectiveMax) + container:SetAuraGroupLayout("spells", { + elementSpacing = livePad, lineSpacing = grid.rowGap, + groupSpacing = livePad, groupLineSpacing = grid.rowGap, + }) + local liveIncludeMap = {} + for i = 1, #spells do liveIncludeMap[spells[i]] = true end + container:SetAuraGroupCandidateFilters("spells", + MergeCandidateFilters({ includeSpellIDs = liveIncludeMap }, BuffCandidateExtras(bar))) + local sortMethod, sortDirection = ResolveSortMethod(bar), ResolveSortDirection(bar) + if sortMethod ~= nil and sortDirection ~= nil then + container:SetAuraGroupSortMethod("spells", sortMethod, sortDirection) + end + end + customBuffDeclared[barId] = customBuffDeclared[barId] or {} + ApplyGroupConfig(container, allChain, customBuffDeclared[barId], styleKey, grid.effectiveMax, bar.padding or 5, grid.rowGap, bar, BuffCandidateExtras(bar)) + return -- nothing structural to rebuild + end + AK.ReleaseContainer(container) -- safe: dedicated container, see doc comment above + customBuffContainers[barId] = nil + end + + local _, spec, specVertical = BuildContainerSpec(parent, bar, grid) + local pad = bar.padding or 5 + AK.RequestContainer(parent, "player", spec, function(container) + customBuffContainers[barId] = container + AK.SetContainerAxis(container, specVertical) + customBuffSig[barId] = sig + customBuffDeclared[barId] = {} + ApplyGroupConfig(container, allChain, customBuffDeclared[barId], styleKey, grid.effectiveMax, pad, grid.rowGap, bar, BuffCandidateExtras(bar)) + if #spells > 0 then + local includeMap = {} + for i = 1, #spells do includeMap[spells[i]] = true end + AK.AddGroupToContainer(container, { + key = "spells", + filter = { "HELPFUL" }, + style = styleKey, + maxFrameCount = grid.effectiveMax, + candidateFilters = MergeCandidateFilters({ includeSpellIDs = includeMap }, BuffCandidateExtras(bar)), + sortMethod = ResolveSortMethod(bar), + sortDirection = ResolveSortDirection(bar), + }) + container:SetAuraGroupLayout("spells", { + elementSpacing = pad, lineSpacing = grid.rowGap, + groupSpacing = pad, groupLineSpacing = grid.rowGap, + }) + end + end) +end + +function ns.PAB_ReloadCustomBuffBar(barId) + ReloadCustomBuffBarImpl(barId) + RegisterPABCustomUnlock() + if PAB_MaybeRefreshPreview then PAB_MaybeRefreshPreview("buff", barId) end +end + +-- Public hook for the Options UI: (re)builds one custom debuff bar's engine +-- state to match its current DB entry. Groups are additive and never +-- released (same reasoning as ApplyGroupConfig's doc comment for the two +-- default bars) -- a class toggle, grid change, or style edit just re-runs +-- this on the same container. +-- +-- Wrapped below for the same reason as PAB_ReloadCustomBuffBar: keeps +-- unlock-mode registration in sync on every exit path. +local function ReloadCustomDebuffBarImpl(barId) + AK = AK or (EllesmereUI and EllesmereUI.AuraKit) + if not AK then return end + + local bar = ns.PAB_GetCustomDebuffBar(barId) + if not bar then + if customDebuffContainers[barId] then + -- Groups can't be un-declared (see ApplyGroupConfig doc + -- comment) -- zero every group's frame count instead so a + -- deleted bar's icons disappear even though the container + -- itself is never released. + for key in pairs(customDebuffDeclared[barId] or {}) do + customDebuffContainers[barId]:SetAuraGroupMaxFrameCount(key, 0) + end + end + if customDebuffParents[barId] then customDebuffParents[barId]:Hide() end + -- Bar IDs are never reused, so this container/declared-set entry + -- will never be looked up again -- drop our own tracking-table + -- references (the container itself stays alive engine-side, only + -- our addon-side bookkeeping is cleared) to avoid unbounded growth + -- of these tables across long sessions of create/delete cycles. + customDebuffParents[barId], customDebuffContainers[barId], customDebuffDeclared[barId] = nil, nil, nil + return + end + + local styleKey = CustomDebuffStyleKey(barId) + AK.styles[styleKey] = BuildStyle(false, bar) + -- Same bug fix as ReloadCustomBuffBarImpl above. + AK.RestyleSoon(styleKey) + + local parent = customDebuffParents[barId] + if not parent then + parent = CreateFrame("Frame", "EllesmereUIPlayerAuraBars_CustomDebuff" .. barId, UIParent) + customDebuffParents[barId] = parent + end + ApplyCustomBarPosition(parent, bar, barId) + parent:SetShown(bar.enabled ~= false) + if bar.enabled == false then return end + + local grid = ComputeGrid(false, bar) + parent:SetSize(grid.width, grid.height) + + local chain = BuildChain("HARMFUL", function(class) return ClassEnabled(class, false, bar) or (PAB_FxSafeToForce(class) and PAB_FxWantsCategory(bar.fxList, class.key)) end, bar.showAllDebuffs ~= false) + local corner, spec, vertical = BuildContainerSpec(parent, bar, grid) + local pad = bar.padding or 5 + + if not customDebuffContainers[barId] then + AK.RequestContainer(parent, "player", spec, function(container) + customDebuffContainers[barId] = container + AK.SetContainerAxis(container, vertical) + customDebuffDeclared[barId] = {} + ApplyGroupConfig(container, chain, customDebuffDeclared[barId], styleKey, grid.effectiveMax, pad, grid.rowGap, bar) + end) + else + local container = customDebuffContainers[barId] + container:ClearAllPoints() + container:SetPoint(corner, parent, corner, 0, 0) + AK.SetContainerAnchor(container, corner) + AK.SetContainerAxis(container, vertical) + if spec.layout.growthH then + AK.SetContainerGrowth(container, spec.layout.growthH, spec.layout.growthV) + end + AK.SetContainerPadding(container, 0, 0, 0, 0) + AK.SetContainerRowWidth(container, grid.rowWidth) + ApplyGroupConfig(container, chain, customDebuffDeclared[barId], styleKey, grid.effectiveMax, pad, grid.rowGap, bar) + end +end + +function ns.PAB_ReloadCustomDebuffBar(barId) + ReloadCustomDebuffBarImpl(barId) + RegisterPABCustomUnlock() + if PAB_MaybeRefreshPreview then PAB_MaybeRefreshPreview("debuff", barId) end +end + +-- Rebuilds every persisted custom bar's engine state. Called once from +-- TryCreateBars alongside the two default bars, and safe to call again any +-- time (e.g. profile switch) -- both reload functions above are idempotent +-- no-ops when nothing actually changed. +local function ReloadAllCustomBarsImpl() + local buffList = ns.PAB_CustomBuffBars() + if buffList then + for i = 1, #buffList do ns.PAB_ReloadCustomBuffBar(buffList[i].id) end + end + local debuffList = ns.PAB_CustomDebuffBars() + if debuffList then + for i = 1, #debuffList do ns.PAB_ReloadCustomDebuffBar(debuffList[i].id) end + end +end +ReloadAllCustomBars = ReloadAllCustomBarsImpl +ns.PAB_ReloadAllCustomBars = ReloadAllCustomBarsImpl + +------------------------------------------------------------------------------- +-- Options-page preview box (2026-08-02, Joel's explicit direction: embedded +-- inside the bar's own detail page, like Raid Frames' Buff Manager preview +-- -- NOT an on-screen overlay at the bar's real position like Raid Frames' +-- own raid-frame preview or Boss Frames' fake-aura preview). Shows FAKE +-- buffs/debuffs at the bar's REAL configured icon size/grid (iconSize, +-- iconsPerRow, maxRows, maxTotal -- via the same ComputeGrid used by the +-- live bar), styled with the bar's real BuildStyle/dispel-color output, so +-- icon size/count/row-wrap/growth direction/spacing/border/duration+stack +-- formatting all preview live as the user edits a bar's settings -- no +-- real aura data involved, no touching of the real bar/container at all. +-- Debuffs cycle through fake spellIDs carrying real dispel tokens (Magic/ +-- Curse/Poison/Disease/Bleed) so BuildDispelColorMap's border coloring +-- previews too. +-- +-- Icons are hand-built Frame/Texture/FontString regions, not AK buttons -- +-- same reasoning as EllesmereUIUnitFrames.lua's Boss Frame +-- AttachFakeDebuffs/AttachFakeBuffs: AK's AuraContainer has no supported +-- way to receive synthetic aura data. +------------------------------------------------------------------------------- + +-- Class-appropriate fake buff pool (2026-08-02, Joel: preview should draw +-- from buffs the player's own class actually has, not a fixed generic +-- list). Best-effort real, well-known spellIDs per class -- purely cosmetic +-- (icon texture only, see PreviewSpellIcon's fallback), a wrong/renamed ID +-- here just shows the generic question-mark icon, nothing else depends on +-- these being exactly right. Keyed by the class FILE token (UnitClass's +-- second return, e.g. "PRIEST"/"DEATHKNIGHT") -- verified WoW convention, +-- not an addon-specific vocabulary. +local CLASS_PREVIEW_BUFFS = { + WARRIOR = { 6673, 97462, 871, 12975, 1719, 107574, 184364, 118038, 46924, 3411 }, + PALADIN = { 465, 6940, 1044, 1022, 31850, 86659, 642, 498, 31884, 105809 }, + HUNTER = { 186257, 288613, 19574, 186265, 109304, 5384, 34477, 264735, 193530, 90355 }, + ROGUE = { 13750, 1784, 5277, 31224, 1966, 2983, 13877, 121471, 185311, 1856 }, + PRIEST = { 21562, 17, 139, 33206, 47788, 586, 47585, 41635, 6346, 64843 }, + DEATHKNIGHT = { 48792, 48707, 55233, 49039, 51052, 42650, 47568, 194844, 194679, 81256 }, + SHAMAN = { 2825, 108271, 79206, 98008, 108281, 8178, 30823, 51490, 16188, 974 }, + MAGE = { 1459, 11426, 190319, 45438, 55342, 12042, 108978, 66, 80353, 12051 }, + WARLOCK = { 104773, 108416, 111400, 6789, 20707, 89808, 108503, 755, 6229, 5697 }, + MONK = { 115203, 122470, 116849, 122783, 115176, 116841, 124682, 116680, 101643, 322507 }, + DRUID = { 1126, 774, 22812, 61336, 102342, 106898, 29166, 33891, 192081, 108238 }, + DEMONHUNTER = { 191427, 198589, 196555, 203720, 196718, 258920, 217832, 195072, 191786, 188501 }, + EVOKER = { 364342, 374348, 355936, 357170, 363916, 358267, 370960, 360995, 359816, 370537 }, +} + +-- Fallback used when the player's class token isn't recognized (defensive +-- only -- UnitClass always returns one of the tokens above on a live +-- character) or CLASS_PREVIEW_BUFFS is somehow missing an entry. +local PREVIEW_BUFF_SPELLS = { 21562, 1459, 1126, 6673 } -- Fort, Arcane Intellect, Mark of the Wild, Battle Shout + +-- Cross-class/consumable buffs for the "All Buffs" preview fill (2026-08-03, +-- Joel: "random aus allen möglichen Buffs... + Zusatzbuffs" -- All Buffs has +-- no finite spell list, real raid buffs come from every class plus food/ +-- flask/world-buff-style consumables, not just the player's own class). +-- Same 4 spell IDs EUI_RaidFrames_BuffManager2.lua's curated "consumables" +-- preset already uses (class="ALL" entries, maintainer-verified data, +-- 2026-07-21) -- duplicated here rather than cross-addon-referenced, since +-- RaidFrames' ns table isn't shared with this addon and isn't guaranteed +-- to even be loaded. +local EXTRA_WORLD_PREVIEW_BUFFS = { 1236998, 1236616, 1239479, 1236994 } + +-- External Defensives bar preview (2026-08-02, Joel: should reflect actual +-- external-defensive-flavored spells, not the player's own class buffs -- +-- these come from OTHER players' classes, so this is a fixed cross-class +-- pool rather than a UnitClass lookup like CLASS_PREVIEW_BUFFS). +local EXTDEF_PREVIEW_SPELLS = { + 33206, -- Pain Suppression + 47788, -- Guardian Spirit + 102342, -- Ironbark + 1022, -- Blessing of Protection + 6940, -- Blessing of Sacrifice + 116849, -- Life Cocoon + 196718, -- Darkness + 145629, -- Anti-Magic Zone + 98008, -- Spirit Link Totem + 97462, -- Rallying Cry +} + +-- Shuffles a fresh copy of `source` (Fisher-Yates), never mutating the +-- source table itself. +local function ShuffleCopy(source) + local out = {} + for i = 1, #source do out[i] = source[i] end + for i = #out, 2, -1 do + local j = math.random(i) + out[i], out[j] = out[j], out[i] + end + return out +end + +-- Builds a freshly shuffled copy of the "All Buffs" preview pool. Called +-- once per ns.PAB_BuildPreviewBox (NOT on every live-apply refresh -- the +-- resulting order is stashed on activePreview and reused by every +-- subsequent RenderPreviewIcons call for that box, so icons don't shuffle +-- their spell identity out from under the user on every slider tick, only +-- their style/position/count). +-- +-- 2026-08-03 (Joel): was just the player's OWN class's 10 buffs, cycling +-- via modulo once a bar had more icon slots than that -- looked repetitive +-- for anything above ~10 icons and didn't represent what All Buffs +-- actually shows (every OTHER unit's buffs too, not just the player's +-- class). Now combines every class's curated CLASS_PREVIEW_BUFFS list +-- (13 classes x 10 = 130 entries) plus EXTRA_WORLD_PREVIEW_BUFFS' +-- consumables (134 total), shuffled together with no class priority -- +-- Joel: show as many of the combined real class-buff pool as fit, only +-- falling back to something else ("notfalls") if the pool itself runs out, +-- which in practice never happens (134 is far larger than any configured +-- grid, maxTotal defaults to 32). A brief own-class-first variant was +-- tried and reverted the same session -- flat/uniform across all classes +-- is what's wanted. +local function BuildBuffPreviewPool() + local combined = {} + for _, spells in pairs(CLASS_PREVIEW_BUFFS) do + for i = 1, #spells do combined[#combined + 1] = spells[i] end + end + for i = 1, #EXTRA_WORLD_PREVIEW_BUFFS do combined[#combined + 1] = EXTRA_WORLD_PREVIEW_BUFFS[i] end + if #combined == 0 then combined = PREVIEW_BUFF_SPELLS end + return ShuffleCopy(combined) +end + +-- Expanded 2026-08-03 (Joel-supplied list, mostly recent Mythic+/dungeon +-- trash debuffs) from the original 6 entries -- unlike CLASS_PREVIEW_BUFFS, +-- there was no existing verified debuff catalog anywhere in this repo to +-- draw from, so this list came directly from Joel rather than being +-- independently sourced. +local PREVIEW_DEBUFF_SPELLS = { + { id = 122, dispel = "Magic" }, -- Frost Nova + { id = 702, dispel = "Curse" }, -- Curse of Weakness + { id = 2823, dispel = "Poison" }, -- Deadly Poison + { id = 55095, dispel = "Disease" }, -- Frost Fever + { id = 772, dispel = "Bleed" }, -- Rend + { id = 6788, dispel = nil }, -- Weakened Soul -- NOT dispellable, previews the plain base border color (no dispel-type override) + -- Magic + { id = 434083, dispel = "Magic" }, -- Lightning Bolt Volley + { id = 426735, dispel = "Magic" }, -- Void Rift + { id = 428161, dispel = "Magic" }, -- Frost Shock + { id = 409465, dispel = "Magic" }, -- Astral Bomb + { id = 397911, dispel = "Magic" }, -- Mystic Vapors + { id = 385963, dispel = "Magic" }, -- Burnout + { id = 387564, dispel = "Magic" }, -- Arcane Eruption + { id = 372749, dispel = "Magic" }, -- Ice Cutter + { id = 369365, dispel = "Magic" }, -- Curse of Stone (Magic) + { id = 388777, dispel = "Magic" }, -- Arcane Vulnerability + -- Curse + { id = 381692, dispel = "Curse" }, -- Decaying Strength + { id = 377488, dispel = "Curse" }, -- Cursed Blood + { id = 384978, dispel = "Curse" }, -- Hextrick Totem + { id = 328664, dispel = "Curse" }, -- Curse of Desolation + { id = 322817, dispel = "Curse" }, -- Lingering Curse + { id = 340288, dispel = "Curse" }, -- Curse of Obliteration + { id = 426308, dispel = "Curse" }, -- Void Curse + { id = 433443, dispel = "Curse" }, -- Shadow Curse + { id = 373509, dispel = "Curse" }, -- Withering Curse + { id = 375602, dispel = "Curse" }, -- Curse of Decay + -- Disease + { id = 373391, dispel = "Disease" }, -- Choking Rotcloud + { id = 374389, dispel = "Disease" }, -- Rotting Sickness + { id = 409492, dispel = "Disease" }, -- Diseased Bite + { id = 322486, dispel = "Disease" }, -- Plague Rot + { id = 321821, dispel = "Disease" }, -- Viral Contagion + { id = 330868, dispel = "Disease" }, -- Festering Rot + { id = 325552, dispel = "Disease" }, -- Necrotic Rot + { id = 345245, dispel = "Disease" }, -- Putrid Bile + { id = 426660, dispel = "Disease" }, -- Diseased Claws + { id = 209858, dispel = "Disease" }, -- Necrotic Rot (different id, same name) + -- Poison + { id = 322358, dispel = "Poison" }, -- Venomous Spit + { id = 324859, dispel = "Poison" }, -- Toxic Pool + { id = 373614, dispel = "Poison" }, -- Decaying Venom + { id = 385039, dispel = "Poison" }, -- Venom Strike + { id = 376149, dispel = "Poison" }, -- Poisoned Spear + { id = 384620, dispel = "Poison" }, -- Noxious Stench + { id = 326092, dispel = "Poison" }, -- Poison Bolt + { id = 257483, dispel = "Poison" }, -- Pile of Bones (Poison) + { id = 381664, dispel = "Poison" }, -- Toxic Trap + { id = 428019, dispel = "Poison" }, -- Poisoned Fang + -- Bleed + { id = 196497, dispel = "Bleed" }, -- Ravenous Leap + { id = 257775, dispel = "Bleed" }, -- Gushing Wound + { id = 381379, dispel = "Bleed" }, -- Jagged Bite + { id = 373735, dispel = "Bleed" }, -- Bloody Bite + { id = 391191, dispel = "Bleed" }, -- Savage Peck + { id = 372718, dispel = "Bleed" }, -- Rending Slash + { id = 385356, dispel = "Bleed" }, -- Tear Flesh + { id = 328181, dispel = "Bleed" }, -- Jagged Quarrel + { id = 381514, dispel = "Bleed" }, -- Serrated Strike + { id = 424414, dispel = "Bleed" }, -- Brutal Rend + -- No dispel type + { id = 240559, dispel = nil }, -- Grievous Wound + { id = 226512, dispel = nil }, -- Sanguine Ichor + { id = 257908, dispel = nil }, -- Oozing Leftovers + { id = 268008, dispel = nil }, -- Snake Charm + { id = 274358, dispel = nil }, -- Rending Maul + { id = 320788, dispel = nil }, -- Frozen Binds + { id = 323043, dispel = nil }, -- Blood Barrier + { id = 373429, dispel = nil }, -- Gash Frenzy + { id = 424889, dispel = nil }, -- Brutal Strike +} +local PREVIEW_DURATIONS = { 8, 15, 23, 41, 5, 30, 12, 60, 3, 18 } +local PREVIEW_STACKS = { nil, 3, nil, nil, 2, nil, nil, 5, nil, 1 } -- a few icons show a fake stack count, rest hidden + +-- Shuffled once per box build, same reasoning as BuildBuffPreviewPool's own +-- doc comment (icons shouldn't swap identity on every slider tick). +local function BuildDebuffPreviewPool() + return ShuffleCopy(PREVIEW_DEBUFF_SPELLS) +end + +-- Memoized fake-icon texture lookup, same technique as EUI_RaidFrames_ +-- BuffManager.lua's GetSpellIcon: C_Spell.GetSpellInfo's iconID, falling +-- back to the generic question-mark icon. +local previewIconCache = {} +local function PreviewSpellIcon(spellID) + local cached = previewIconCache[spellID] + if cached then return cached end + local info = C_Spell and C_Spell.GetSpellInfo and C_Spell.GetSpellInfo(spellID) + local icon = (info and info.iconID) or 134400 + previewIconCache[spellID] = icon + return icon +end + +-- Same memoization for the preview's "Name" sort simulation below. +local previewNameCache = {} +local function PreviewSpellName(spellID) + local cached = previewNameCache[spellID] + if cached then return cached end + local info = C_Spell and C_Spell.GetSpellInfo and C_Spell.GetSpellInfo(spellID) + local name = (info and info.name) or "" + previewNameCache[spellID] = name + return name +end + +-- Best-effort preview simulation of the 4 curated sort methods (Default/ +-- Expiration/Name/ImportantOnly, see EUI_PlayerAuraBars_ManagerPages.lua's +-- SORT_METHOD_VALUES). Verified 2026-08-03 against Blizzard's actual PTR +-- source (Gethe/wow-ui-source, ptr branch, AuraUtil.lua's +-- ExpirationAuraCompare/NameAuraCompare/ImportantOnlyAuraCompare): Expiration +-- = ascending expirationTime, Name = alphabetical spell name, +-- ImportantOnly = `C_Spell.IsSpellImportant(spellId)` first -- a native +-- per-spell flag, NOT dispel-type-based, and NOT debuff-specific (applies +-- equally to buffs; corrects an earlier wrong assumption that treated it as +-- "dispellable debuffs first" and no-opped it for buffs). The real engine's +-- comparators also weight player-cast/priority/canApplyAura ahead of the +-- named criterion and always tie-break on auraInstanceID -- not reproduced +-- here, since the preview's fake entries have no equivalent concepts; this +-- remains a simplified approximation of relative ORDER, not a byte-exact +-- match. `sortDirection == "Reverse"` flips every comparison -- including under +-- Default, since we don't know whether the real engine's Default ordering +-- itself respects direction; best-effort here is to at least reverse the +-- pool's own order rather than silently ignore the direction toggle (bug +-- fixed 2026-08-03: Default previously ignored `reverse` entirely, so +-- flipping only Sort Direction with Sort Method left at Default produced +-- no visible preview change). Never mutates `list` -- returns a fresh +-- array so callers can index it exactly like the original. +local function SortPreviewList(list, isBuff, cfg) + local method = cfg.sortMethod or "Default" + local reverse = cfg.sortDirection == "Reverse" + if method == "Default" then + if not reverse then return list end + local out = {} + local n = #list + for i = 1, n do out[i] = list[n - i + 1] end + return out + end + + local tagged = {} + for i = 1, #list do tagged[i] = { entry = list[i], idx = i } end + + if method == "Expiration" then + table.sort(tagged, function(a, b) + local da = PREVIEW_DURATIONS[((a.idx - 1) % #PREVIEW_DURATIONS) + 1] + local db = PREVIEW_DURATIONS[((b.idx - 1) % #PREVIEW_DURATIONS) + 1] + if da ~= db then + if reverse then return da > db end + return da < db + end + return a.idx < b.idx + end) + elseif method == "Name" then + table.sort(tagged, function(a, b) + local sa = isBuff and a.entry or a.entry.id + local sb = isBuff and b.entry or b.entry.id + local na, nb = PreviewSpellName(sa), PreviewSpellName(sb) + if na ~= nb then + if reverse then return na > nb end + return na < nb + end + return a.idx < b.idx + end) + elseif method == "ImportantOnly" then + table.sort(tagged, function(a, b) + local sa = isBuff and a.entry or a.entry.id + local sb = isBuff and b.entry or b.entry.id + local ia = (C_Spell and C_Spell.IsSpellImportant and C_Spell.IsSpellImportant(sa)) and 0 or 1 + local ib = (C_Spell and C_Spell.IsSpellImportant and C_Spell.IsSpellImportant(sb)) and 0 or 1 + if ia ~= ib then + if reverse then return ia > ib end + return ia < ib + end + return a.idx < b.idx + end) + end + + local out = {} + for i = 1, #tagged do out[i] = tagged[i].entry end + return out +end + +-- Identifies which bar-detail pane currently owns the visible preview box +-- (kind: "buff"/"debuff", id: "default"/"extdef"/a custom bar id), plus +-- that box's icon pool and the fontPath it was built with -- so a live- +-- apply hook can re-render in place without the detail pane rebuilding. +-- Reset to a fresh box every ns.PAB_BuildPreviewBox call, since the owning +-- detail pane itself is always torn down/rebuilt on structural changes +-- (switching bars, add/rename/delete) -- same lifecycle as every other +-- widget BuildCoreFields/BuildDisplayFields places on that pane. +local activePreview + +-- Layer order matches the live bar exactly (AK's own ApplyStyleToRegions): +-- icon texture (btn, ARTWORK) below border (child frame, level+1) below +-- duration/stack text (textHost, child frame, level+2, ABOVE the border). +local function CreatePreviewIcon(box) + local btn = CreateFrame("Frame", nil, box) + btn.icon = btn:CreateTexture(nil, "ARTWORK") + btn.icon:SetAllPoints() + -- "Nothing configured" placeholder (2026-08-03, Joel): a red X centered + -- over the icon's flat grey fill, shown instead of a fake spell icon + -- when the bar's real config would show zero buffs (Show All Buffs off, + -- no Filters/Extra Spells resolved) -- see the noneConfigured check in + -- RenderPreviewIcons. Reuses the existing close/X media icon rather + -- than adding a new asset. + btn.placeholder = btn:CreateTexture(nil, "OVERLAY") + btn.placeholder:SetTexture("Interface\\AddOns\\EllesmereUI\\media\\icons\\eui-close.png") + btn.placeholder:SetVertexColor(1, 0.2, 0.2, 1) + btn.placeholder:Hide() + btn.border = CreateFrame("Frame", nil, btn) + btn.textHost = CreateFrame("Frame", nil, btn) + btn.duration = btn.textHost:CreateFontString(nil, "OVERLAY") + btn.stack = btn.textHost:CreateFontString(nil, "OVERLAY") + return btn +end + +-- Resolves a bar's live cfg + polarity from its (kind,id) identity -- same +-- shape every other engine-side lookup in this file uses (DefaultBuffsCfg/ +-- DefaultExternalDefensivesCfg/PAB_GetCustomBuffBar/GetCustomDebuffBar). +local function ResolvePreviewCfg(kind, id) + local s = PAB() + if not s then return nil end + if id == "default" then + local isBuff = kind == "buff" + return (isBuff and DefaultBuffsCfg(s) or DefaultDebuffsCfg(s)), isBuff + elseif id == "extdef" then + return DefaultExternalDefensivesCfg(s), true + elseif kind == "buff" then + return ns.PAB_GetCustomBuffBar(id), true + else + return ns.PAB_GetCustomDebuffBar(id), false + end +end + +-- Options-panel "pixel perfect" compensation (2026-08-02 fix): the whole +-- options window (EllesmereUI._mainFrame) runs at effective scale +-- baseScale*userScale (EllesmereUI.GetPopupScale()), while the real PAB +-- bars are parented directly to UIParent with no extra scale of their own +-- -- so identical iconSize/padding/border/text-size NUMBERS render visibly +-- SMALLER inside the options panel than on the real bar whenever that +-- panel scale is below 1 (the common case, "pixel perfect" scaling is +-- usually <1). Rather than SetScale the preview box itself (which would +-- desync it from the sy layout accounting used to place widgets below it), +-- every size-affecting cfg field is pre-multiplied by 1/GetPopupScale() +-- before being fed into BuildStyle/ComputeGrid, so the ENTIRE preview +-- (icon size, padding, border thickness, duration/stack font size and +-- offsets, box footprint) inflates by exactly the panel's own scale factor +-- and ends up the same TRUE on-screen size as the live bar -- both the +-- panel-scale slider and WoW's own UI Scale setting cancel out +-- algebraically (UIParent's effective scale multiplies both the live bar's +-- and the panel's on-screen size equally, so only the panel's OWN extra +-- SetScale factor matters). +local function PreviewScaleFactor() + local s = (EllesmereUI.GetPopupScale and EllesmereUI.GetPopupScale()) or 1 + if not s or s <= 0 then return 1 end + return 1 / s +end + +-- Duplicates BuildStyle's own `or ` fallbacks for every field +-- being scaled (32/5/1/11/0/11/0) since a size can't be scaled without +-- first resolving what "unset" means -- keep these in sync if BuildStyle's +-- defaults ever change. Non-size fields (growDirection, iconsPerRow/ +-- maxRows/maxTotal, dispel colors, ...) pass through unchanged. +local function ApplyPreviewScale(cfg, comp) + if comp == 1 then return cfg end + local out = {} + for k, v in pairs(cfg) do out[k] = v end + out.iconSize = (cfg.iconSize or 32) * comp + out.padding = (cfg.padding or 5) * comp + out.rowSpacing = cfg.rowSpacing and (cfg.rowSpacing * comp) or nil + out.borderSize = (cfg.borderSize or 1) * comp + out.durationTextSize = (cfg.durationTextSize or 11) * comp + out.durationOffsetX = (cfg.durationOffsetX or 0) * comp + out.durationOffsetY = (cfg.durationOffsetY or 0) * comp + out.stackTextSize = (cfg.stackTextSize or 11) * comp + out.stackOffsetX = (cfg.stackOffsetX or 0) * comp + out.stackOffsetY = (cfg.stackOffsetY or 0) * comp + -- Icon Effects Per-Filter Size overrides need the same panel-scale + -- compensation as iconSize above -- a per-block shallow copy (never + -- mutating the real saved fxList/its filters/borderColor sub-tables) + -- with just `.size` rescaled, so the preview's box footprint (see + -- ComputeGrid's MaxIconSizeFor) and the actual fake-icon SetSize call + -- (RenderPreviewIcons/ApplyPreviewFx) both read the correct on-screen + -- pixel value. + if cfg.fxList then + local scaledFx = {} + for i = 1, #cfg.fxList do + local e = cfg.fxList[i] + local se = {} + for k, v in pairs(e) do se[k] = v end + if se.size then se.size = se.size * comp end + scaledFx[i] = se + end + out.fxList = scaledFx + end + return out +end + +-- Preview area height budget (2026-08-04, Joel: Up/Down growth with several +-- icons-per-column made the preview box very tall very fast, pushing the +-- rest of the options page down). Applied as an EXTRA proportional shrink on +-- top of the panel-zoom compensation above -- combined into one factor so +-- iconSize/padding/rowSpacing/etc are only scaled once, not twice. Only ever +-- shrinks (extra <= 1); horizontal bars and short vertical ones are +-- unaffected since their unscaled grid.height is normally well under this. +local MAX_PREVIEW_CONTENT_HEIGHT = 330 + +local function ScaledPreviewCfg(cfg, isBuff) + local comp = PreviewScaleFactor() + local extra = 1 + if isBuff ~= nil then + local probe = ApplyPreviewScale(cfg, comp) + local grid = ComputeGrid(isBuff, probe) + if grid.height > MAX_PREVIEW_CONTENT_HEIGHT then + extra = MAX_PREVIEW_CONTENT_HEIGHT / grid.height + end + end + return ApplyPreviewScale(cfg, comp * extra) +end + +-- Renders (or re-renders in place) the fake icon grid using the bar's +-- CURRENT cfg -- safe to call on every live slider tick, only touches +-- plain addon-owned Frame/Texture/FontString regions, never the real bar. +-- Row/column math mirrors ComputeGrid/BuildContainerSpec's own corner- +-- anchored flow layout so the preview wraps exactly like the live bar. +local function HasAnyTrue(map) + if not map then return false end + for _, v in pairs(map) do if v then return true end end + return false +end + +-- True when the FILLER portion (whatever's left after real resolved spells, +-- see BuildPreviewSlots) should show fake example icons rather than an +-- empty placeholder. Buffs: ONLY All Buffs justifies fake filler -- it has +-- no finite spell list, so "more buffs than we can show" is a fair +-- approximation. Real Filters/Extra Spells DON'T (2026-08-03 fix, Joel: +-- "nur Icons zeigen, die laut Filter ausgewählt wurden") -- they resolve to +-- a concrete, finite spell set (ns.PAB_ResolveSpells, used directly in +-- BuildPreviewSlots below as real icons), so anything beyond that count is +-- genuinely empty capacity, not more content pretending to exist. Debuffs +-- have no per-spell resolution for class filters (they're AURA FILTER +-- STRING/category tokens, not a concrete spell list), so both Show All +-- Debuffs and any Base Filter class still justify fake filler there -- +-- mirrors the exact condition ApplyLiveConfig/ReloadCustomDebuffBarImpl use +-- via BuildChain's includeCatchAll (2026-08-03: re-verified against the +-- current code -- debuffs CAN show truly nothing, contrary to an earlier, +-- stale session note). +local function HasFillerSource(isBuff, cfg) + if isBuff then + return cfg.showAllBuffs ~= false + end + return cfg.showAllDebuffs ~= false or HasAnyTrue(cfg.classFilters) +end + +-- Builds one descriptor per icon slot (length `count`). Buffs: the bar's +-- REAL resolved spells (ns.PAB_ResolveSpells -- Filters' enabled spells + +-- Extra Spells, deliberately NOT including All Buffs, which has no finite +-- list) occupy the LEADING slots as {kind="extra", spellID=}, each with its +-- own real icon rather than the generic fake pool (2026-08-03, Joel: both +-- "Extra Spells always visible" and "only Filter-selected spells shown, +-- padded with placeholders" folded into the same mechanism -- a Filter +-- selection and an Extra Spell are equally "real content" from the +-- preview's point of view). Whatever's left is {kind="fake", entry=} when +-- HasFillerSource is true (All Buffs on), or {kind="placeholder"} +-- otherwise -- e.g. 4 resolved spells with room for 8 icons and All Buffs +-- off renders 4 real icons + 4 placeholders, never 4 fake ones. Debuffs +-- have no per-spell resolution, so every slot is fake-or-placeholder there. +-- ns.PAB_ResolveSpells dedupes by SPELL ID, but a filter's `alts` (rank/ +-- alternate spell IDs for the same visual buff, see PAB_AllPresetSpells' +-- doc comment) resolve to DIFFERENT spell IDs sharing the SAME icon -- +-- e.g. Mark of the Wild's rank alts would otherwise render as the same +-- icon twice in a row. 2026-08-03 fix (Joel: "manche Icons werden noch +-- doppelt gezeigt"): dedupe the preview's own real-spell list by ICON +-- TEXTURE, keeping the first (lowest spell ID, since ResolveSpells already +-- sorts numerically) occurrence per distinct icon. Preview-only -- the +-- real bar never has this problem, since it shows actual active aura +-- instances on the player, not an enumeration of every possible spell ID. +local function DedupeByIcon(ids) + local seenIcons, out = {}, {} + for i = 1, #ids do + local icon = PreviewSpellIcon(ids[i]) + if not seenIcons[icon] then + seenIcons[icon] = true + out[#out + 1] = ids[i] + end + end + return out +end + +-- ns.PAB_ResolveSpells unions every selected Filter's + Extra Spells' ids +-- into ONE set, sorted purely numerically by spell id. With more resolved +-- spells than available icon slots, BuildPreviewSlots truncates to the +-- first `count` -- which, sorted by raw id, means whichever selected +-- Filter happens to contain the lowest-numbered spells wins the visible +-- slots outright, and every OTHER selected Filter (plus Extra Spells) +-- never appears at all. 2026-08-03 fix (Joel: "wenn mehrere Filter gewählt +-- sind, sollte die Preview aus einer Mischung... bestehen"): interleave +-- round-robin ACROSS sources (each selected Filter is its own source, Extra +-- Spells is one more) instead of a flat numeric sort, so truncation always +-- samples a bit of everything rather than exhausting one source first. +-- Deterministic (no math.random) on purpose -- unlike the fake buff/debuff +-- pools, this must NOT reshuffle on every live-apply refresh, and a stable +-- interleave achieves that without needing to stash shuffle state on +-- activePreview the way BuildBuffPreviewPool does. +local function BuildMixedRealSpells(cfg) + local sources = {} + if cfg.filters then + local allFilters = ns.PAB_Filters and ns.PAB_Filters() + if allFilters then + for i = 1, #allFilters do + local f = allFilters[i] + if cfg.filters[f.id] then + local ids = {} + for id, on in pairs(f.spells) do + if on then ids[#ids + 1] = id end + end + if #ids > 0 then + table.sort(ids) + sources[#sources + 1] = ids + end + end + end + end + end + if cfg.spells and #cfg.spells > 0 then + local extra = {} + for i = 1, #cfg.spells do extra[i] = cfg.spells[i] end + sources[#sources + 1] = extra + end + + local out = {} + local idx = 1 + while true do + local addedAny = false + for s = 1, #sources do + local id = sources[s][idx] + if id then + out[#out + 1] = id + addedAny = true + end + end + if not addedAny then break end + idx = idx + 1 + end + return out +end + +local function BuildPreviewSlots(isBuff, cfg, list, listLen, count) + local hasFiller = HasFillerSource(isBuff, cfg) + -- Sort Method/Direction must apply to the REAL extra icons too, not + -- just the fake filler pool (2026-08-03 fix, Joel: "Sort Method und + -- Sort Direction funktioniert aber nicht wenn ich nicht Show All Buffs + -- aktiv habe") -- with All Buffs off, content is mostly/only these + -- real slots, so skipping them left the sort controls looking dead. + -- SortPreviewList already handles plain buff spellID arrays (the shape + -- extraIDs is in), same as the fake buff pool. + -- + -- SELECT before SORT (2026-08-03 fix, Joel: changing Sort Method/ + -- Direction shouldn't "remix" which icons show, only their order): + -- with more resolved+deduped spells than icon slots, sorting the FULL + -- list first and truncating afterward meant a different sort put a + -- different subset into the surviving first `count` -- i.e. changing + -- sort could swap which spells appear, not just their order. Truncate + -- to `count` on the stable, sort-independent mixed order FIRST, then + -- sort only that fixed selection for display order. + local mixed = isBuff and DedupeByIcon(BuildMixedRealSpells(cfg)) or nil + local extraIDs + if mixed then + local numSelected = math.min(#mixed, count) + local selected = {} + for i = 1, numSelected do selected[i] = mixed[i] end + extraIDs = SortPreviewList(selected, isBuff, cfg) + end + local numExtra = extraIDs and #extraIDs or 0 + local numFiller = count - numExtra + local slots = {} + for i = 1, numExtra do + slots[i] = { kind = "extra", spellID = extraIDs[i] } + end + if numFiller > 0 then + if hasFiller then + -- Same select-before-sort fix as the real extra icons above + -- (2026-08-03, Joel: applies to All Buffs' fake filler too): + -- select the fixed filler slice from `list` (stable, shuffled + -- once per box build, NOT sorted) first, then sort only that + -- selection -- so Sort Method/Direction reorders the SAME fake + -- icons already showing instead of pulling different ones in + -- from elsewhere in the pool. + local fillerSelected = {} + for i = 1, numFiller do + fillerSelected[i] = list[((i - 1) % listLen) + 1] + end + fillerSelected = SortPreviewList(fillerSelected, isBuff, cfg) + for i = 1, numFiller do + slots[numExtra + i] = { kind = "fake", entry = fillerSelected[i] } + end + else + for i = 1, numFiller do + slots[numExtra + i] = { kind = "placeholder" } + end + end + end + return slots +end + +-- Icon Effects Per-Filter preview: applies a matched fx block's Glow/Border/ +-- Size directly to a fake preview icon. Unlike the live PAB_ApplyDmFx, these +-- are plain addon-owned frames (CreatePreviewIcon), never secure engine +-- buttons -- no creation-window/taint restriction applies, so glow/border +-- hosts are lazily created wherever this first runs, and Glows.StartGlow is +-- called directly (no RestrictionSafeStyle gate -- that gate exists only +-- for the real, combat-lockdown-able aura buttons). `e` is nil when no +-- active fx block matches this icon's assigned preview category (or for +-- buffs/placeholder slots, which never carry one) -- clears any fx left +-- over from a previous render of this reused icon frame. +local function ApplyPreviewFx(btn, e) + local Glows = EllesmereUI.Glows + local gType = (e and e.glowType) or 0 + local gov = btn.fxGlow + if gType > 0 and Glows and Glows.StartGlow then + if not gov then + gov = CreateFrame("Frame", nil, btn) + gov:SetAllPoints(btn) + gov:SetFrameLevel(btn.border:GetFrameLevel() + 2) + gov:EnableMouse(false) + btn.fxGlow = gov + end + gov:Show() + local cr, cg, cb = e.glowR or 1.0, e.glowG or 0.776, e.glowB or 0.376 + if e.glowClassColor then + local _, classFile = UnitClass("player") + local cc = classFile and RAID_CLASS_COLORS and RAID_CLASS_COLORS[classFile] + if cc then cr, cg, cb = cc.r, cc.g, cc.b end + end + local sz = btn:GetWidth() or 18 + if (not gov._euiGlowActive) or gov._fxStyle ~= gType or gov._fxW ~= sz + or gov._fxCR ~= cr or gov._fxCG ~= cg or gov._fxCB ~= cb then + Glows.StartGlow(gov, gType, sz, cr, cg, cb) + gov._fxStyle, gov._fxW = gType, sz + gov._fxCR, gov._fxCG, gov._fxCB = cr, cg, cb + end + elseif gov then + if gov._euiGlowActive and Glows and Glows.StopGlow then Glows.StopGlow(gov) end + gov:Hide() + end + + local PP = EllesmereUI.PP + local bSize = (e and e.borderSize) or 0 + if bSize > 0 and PP then + local host = btn.fxBorder + if not host then + host = CreateFrame("Frame", nil, btn) + host:SetAllPoints(btn) + host:SetFrameLevel(btn.border:GetFrameLevel() + 1) + host:EnableMouse(false) + PP.CreateBorder(host, 0, 0, 0, 1, 1) + btn.fxBorder = host + end + local bc = e.borderColor or { r = 0, g = 0, b = 0 } + PP.UpdateBorder(host, bSize, bc.r or 0, bc.g or 0, bc.b or 0, 1) + host:Show() + elseif btn.fxBorder then + btn.fxBorder:Hide() + end + + -- Size override is NOT applied here (2026-08-03, moved out): it now + -- feeds directly into RenderPreviewIcons' own per-icon flow-packing + -- layout (slotSize/colOffset/rowYOffset), which needs to know each + -- icon's real footprint BEFORE positioning any of them -- applying it + -- here, after the anchor is already placed, would either be redundant + -- with that or (if this ran first) invisible to the layout math. +end + +-- `pool` (2026-08-03 renamed from buffPool -- now shuffled once per box +-- build for BOTH polarities, see BuildBuffPreviewPool/BuildDebuffPreviewPool) +-- is the box's own stable, pre-shuffled fake-icon pool. +local function RenderPreviewIcons(box, icons, isBuff, cfg, fontPath, pool) + cfg = ScaledPreviewCfg(cfg, isBuff) + local style = BuildStyle(isBuff, cfg) + local dcMap = (not isBuff) and BuildDispelColorMap(cfg) or nil + local grid = ComputeGrid(isBuff, cfg) + + -- The box itself (and therefore the header darken band/divider below + -- it, see ns.PAB_BuildPreviewBox) is FIXED at its build-time size + -- (2026-08-02, Joel: divider/box should stay put, only the CONTENT + -- should change on a live settings edit). Icons are instead centered + -- as a BLOCK inside the box's current (unchanging) width/height, using + -- box:GetCenter()-relative offsets rather than anchoring to one of the + -- box's own corners -- growDirection still decides which edge of that + -- centered block fills first (matches the live bar's own fill order), + -- it just no longer moves the box/divider around while doing it. + local growDir = cfg.growDirection or "LEFT" + local wrapDir = cfg.iconWrapDirection or "LEFT" + local vertical = (growDir == "UP" or growDir == "DOWN") + local corner = CornerFor(growDir, wrapDir) + local pad = cfg.padding or 5 + local rowGap = cfg.rowSpacing or 12 + local iconSize = cfg.iconSize or 32 + local cols = math.max(1, cfg.iconsPerRow or (isBuff and 11 or 8)) + local count = grid.effectiveMax + -- NOT sorted here anymore (2026-08-03 fix, same "select before sort" + -- reasoning as the real extra icons below): `list` is much larger than + -- `count` (the fake pools are 134/66 entries), so sorting the WHOLE + -- pool before BuildPreviewSlots selects its filler slice would let a + -- sort change pull a DIFFERENT subset of fake icons into view, not + -- just reorder the ones already showing. BuildPreviewSlots now selects + -- the fixed filler slice from this stable, shuffled-once-per-box-build + -- order FIRST, then sorts only that selection. + local list = (pool and #pool > 0 and pool) or (isBuff and PREVIEW_BUFF_SPELLS or PREVIEW_DEBUFF_SPELLS) + local listLen = #list + local slots = BuildPreviewSlots(isBuff, cfg, list, listLen, count) + + -- Icon Effects Per-Filter preview (debuffs only): deliberately NOT tied + -- to the bar's own active Base Filters/Show All Debuffs state (2026-08- + -- 03 fix, Joel: "you won't normally select the same category in both + -- places" -- requiring a matching Base Filter meant the preview usually + -- showed nothing, since the two dropdowns serve different purposes and + -- aren't meant to be set identically). Instead, every ACTIVE fx block + -- claims 1-2 fake icon slots outright, regardless of which categories + -- are actually enabled for display -- a simple "here's what this + -- configured effect looks like" demonstration, not a claim that these + -- specific icons represent that category on a real bar (which the fake + -- pool has no Blizzard boss/role/priority/... flags to support anyway). + local fxBySlot + if not isBuff then + local fxListView = PAB_FxListView(cfg.fxList) + if fxListView and #fxListView > 0 then + local fakeIdx = {} + for i = 1, #slots do + if slots[i].kind == "fake" then fakeIdx[#fakeIdx + 1] = i end + end + local nFake = #fakeIdx + if nFake > 0 then + fxBySlot = {} + for bi = 1, #fxListView do + local perBlock = math.min(2, nFake) + for k = 1, perBlock do + local pos = ((bi - 1) * 2 + (k - 1)) % nFake + 1 + fxBySlot[fakeIdx[pos]] = fxListView[bi] + end + end + end + end + end + + local rows = math.max(1, math.ceil(count / cols)) + + -- Real per-icon flow packing (2026-08-03 fix, Joel: the earlier uniform + -- `cellSize` reservation stopped the oversized-icon overlap but left too + -- much gap around every OTHER, normal-sized icon -- every cell paid the + -- worst-case size even when only one icon in the whole grid needed it). + -- Each slot's OWN actual render size (its fx Size override, or the + -- bar's base iconSize) now drives its own footprint directly, so + -- spacing between normal icons stays tight and only an oversized icon's + -- immediate neighbors get pushed out -- true flow-layout behavior + -- rather than a uniform cell grid. + local slotSize = {} + for i = 1, count do + local e = fxBySlot and fxBySlot[i] + local sz = e and tonumber(e.size) + slotSize[i] = (sz and sz > 0) and sz or iconSize + end + + local rowWidth, rowHeight, colOffset, rowYOffset = {}, {}, {}, {} + do + local runningX, runningY = {}, 0 + for r = 0, rows - 1 do runningX[r] = 0 end + for i = 1, count do + local r = math.floor((i - 1) / cols) + colOffset[i] = runningX[r] + runningX[r] = runningX[r] + slotSize[i] + pad + rowHeight[r] = math.max(rowHeight[r] or 0, slotSize[i]) + end + for r = 0, rows - 1 do + rowWidth[r] = math.max(0, runningX[r] - pad) -- drop the trailing gap + end + for r = 0, rows - 1 do + rowYOffset[r] = runningY + runningY = runningY + (rowHeight[r] or 0) + rowGap + end + end + -- blockW/blockH are generic axis extents: "within-line" (rowWidth, the + -- primary/fill axis) and "across-lines" (the wrap axis) -- screen X/Y + -- only for horizontal growth. Vertical growth (Up/Down, 2026-08-04) swaps + -- which one maps to X vs Y in the placement loop below. + local blockW = 0 + for r = 0, rows - 1 do blockW = math.max(blockW, rowWidth[r] or 0) end + local blockH = math.max(0, (rowYOffset[rows - 1] or 0) + (rowHeight[rows - 1] or 0)) + local halfPrimary, halfCross = blockW / 2, blockH / 2 + local growUp = (growDir == "UP") + local wrapRight = (wrapDir == "RIGHT") + + for i = 1, math.max(count, #icons) do + if i <= count then + local btn = icons[i] + if not btn then + btn = CreatePreviewIcon(box) + icons[i] = btn + end + + local row = math.floor((i - 1) / cols) + local withinLineStep = colOffset[i] + local acrossLinesStep = rowYOffset[row] + -- btn's own anchor point is `corner` (matching growDirection/ + -- iconWrapDirection), placed at an offset from the box's CENTER + -- -- see the block-centering comment above `local rows = ...`. + -- Vertical growth (Up/Down) swaps which step drives X vs Y: the + -- within-line step (icons stacking inside one column) becomes Y, + -- the across-lines step (columns wrapping sideways) becomes X -- + -- mirrors the corner/growthH/growthV swap in CornerFor/ + -- BuildContainerSpec used by the real (non-preview) bars. + local btnX, btnY + if vertical then + btnY = growUp and (-halfPrimary + withinLineStep) or (halfPrimary - withinLineStep) + btnX = wrapRight and (-halfCross + acrossLinesStep) or (halfCross - acrossLinesStep) + else + btnX = (corner == "TOPRIGHT") and (halfPrimary - withinLineStep) or (-halfPrimary + withinLineStep) + btnY = halfCross - acrossLinesStep + end + btn:ClearAllPoints() + btn:SetPoint(corner, box, "CENTER", btnX, btnY) + btn:SetSize(slotSize[i], slotSize[i]) + + local slot = slots[i] + local dispel + + if slot.kind == "placeholder" then + -- Flat grey box + centered red X (see CreatePreviewIcon's + -- doc comment) instead of a fake spell icon -- nothing + -- would actually render on the real bar here, so the + -- preview shouldn't imply otherwise with example buffs. + -- Border still draws (2026-08-03, Joel) -- only the icon + -- texture and duration/stack text are placeholder-specific. + btn.icon:SetTexture(nil) + btn.icon:SetColorTexture(0.16, 0.16, 0.16, 1) + btn.placeholder:ClearAllPoints() + local inset = iconSize * 0.2 + btn.placeholder:SetPoint("TOPLEFT", btn.icon, "TOPLEFT", inset, -inset) + btn.placeholder:SetPoint("BOTTOMRIGHT", btn.icon, "BOTTOMRIGHT", -inset, inset) + btn.placeholder:Show() + btn.textHost:Hide() + else + btn.placeholder:Hide() + btn.textHost:Show() + + local spellID + if slot.kind == "extra" then + -- Real Extra Spell: always its own actual icon, never + -- folded into the fake cycling pool. + spellID = slot.spellID + else -- "fake" + local entry = slot.entry + spellID = isBuff and entry or entry.id + dispel = (not isBuff) and entry.dispel or nil + end + + btn.icon:SetTexture(PreviewSpellIcon(spellID)) + local z = style.iconZoom or 0.055 + btn.icon:SetTexCoord(z, 1 - z, z, 1 - z) + end + + btn.border:SetAllPoints(btn.icon) + btn.border:SetFrameLevel(btn:GetFrameLevel() + 1) + local PP = EllesmereUI and EllesmereUI.PanelPP + if PP and style.border then + local br, bg, bb, ba = style.border[1], style.border[2], style.border[3], style.border[4] + if dispel and dcMap and dcMap[dispel] then + local c = dcMap[dispel] + br, bg, bb, ba = c.r, c.g, c.b, 1 + end + local size = style.border.size or 1 + -- PP.CreateBorder is create-ONCE-only -- a second call with a + -- different size/color on an already-created host is a + -- silent no-op (see EllesmereUI.lua's PP.CreateBorder: early- + -- returns the cached container without touching bd.borderSize + -- /borderColor). Live border-size/color changes on an + -- already-created host must go through PP.UpdateBorder + -- instead -- exactly the borderMade branch EllesmereUI_ + -- AuraKit.lua's own ApplyStyleToRegions uses for the real + -- bar's borders. Without this, the preview's border only + -- ever "moved" by toggling Hide()/Show() at size 0, never + -- actually re-sized above 0 (bug fixed 2026-08-02). + if btn.borderMade then + PP.UpdateBorder(btn.border, size, br, bg, bb, ba) + elseif PP.CreateBorder then + PP.CreateBorder(btn.border, br, bg, bb, ba, size, "OVERLAY", 7) + btn.borderMade = true + end + if PP.ShowBorder then PP.ShowBorder(btn.border) else btn.border:Show() end + else + if PP and PP.HideBorder then PP.HideBorder(btn.border) else btn.border:Hide() end + end + + if slot.kind ~= "placeholder" then + btn.textHost:SetAllPoints(btn) + btn.textHost:SetFrameLevel(btn:GetFrameLevel() + 2) + + btn.duration:ClearAllPoints() + btn.duration:SetFont(fontPath, style.durationFontSize or 11, "OUTLINE") + btn.duration:SetPoint(style.durationPoint or "TOP", btn, style.durationRelPoint or "BOTTOM", + style.durationX or 0, style.durationY or 0) + local dc = style.durationColor + btn.duration:SetTextColor(dc and dc.r or 1, dc and dc.g or 1, dc and dc.b or 1) + btn.duration:SetShown(not style.hideDurationText) + btn.duration:SetText(PREVIEW_DURATIONS[((i - 1) % #PREVIEW_DURATIONS) + 1]) + + btn.stack:ClearAllPoints() + btn.stack:SetFont(fontPath, style.stackFontSize or 11, "OUTLINE") + btn.stack:SetPoint(style.stackPoint or "TOP", btn, style.stackPoint or "TOP", + style.stackX or 0, style.stackY or 0) + local sc = style.stackColor + btn.stack:SetTextColor(sc and sc.r or 1, sc and sc.g or 1, sc and sc.b or 1) + local stackVal = PREVIEW_STACKS[((i - 1) % #PREVIEW_STACKS) + 1] + btn.stack:SetShown(style.showStacks ~= false and stackVal ~= nil) + if stackVal then btn.stack:SetText(stackVal) end + end + + -- Icon Effects Per-Filter preview: nil clears any fx left over on + -- a reused icon frame from a previous render (slot not claimed + -- this pass, or block removed/deactivated). + ApplyPreviewFx(btn, fxBySlot and fxBySlot[i]) + + btn:Show() + elseif icons[i] then + icons[i]:Hide() + end + end +end + +-- Public hook for the Options UI: builds this bar's embedded preview box +-- entirely OUTSIDE the scrollable settings area (2026-08-02, Joel: the +-- scrollbar must only scroll the settings fields below, never the preview +-- itself) -- box, "PREVIEW" label, darkened header band, and divider are +-- all children of `outerFrame` directly (the detail pane's own top-level, +-- non-scrolling frame that title/desc already live on), sized to the bar's +-- REAL configured grid (ComputeGrid, same as the live bar) and horizontally +-- centered (anchored TOP-to-TOP rather than TOPLEFT). +-- +-- The "PREVIEW" section-header label is wrapped in a small local +-- padDiff-compensated + clipped frame (same CONTENT_PAD-vs-20px trick as +-- EUI_PlayerAuraBars_ManagerPages.lua's WrapCompensatedBody, duplicated in +-- miniature here rather than shared -- W:SectionHeader assumes a 45px +-- CONTENT_PAD margin, but this detail pane only reserves 20px) -- the box +-- itself needs no such compensation since it's positioned via a plain +-- SetPoint, not a W: widget. +-- +-- Geometry (box size, header darken band, divider Y, and the caller's +-- scroll-area top offset via the onResize hook -- see PAB_MaybeRefreshPreview +-- below) is recomputed on every live-apply refresh too, not just here at +-- build time -- a row/column/icon-count change (Icons Per Row, Max Rows, Max +-- Total, Icon Size, Row Spacing, ...) changes the grid's real footprint, and +-- a box that stayed the old size would either clip the new icons (grid grew) +-- or leave a stale gap above the settings fields (grid shrank) until the +-- next structural rebuild (switching bars/tabs). 2026-08-02: Joel originally +-- had this fixed at build-time only ("box and divider should stay put") to +-- avoid the settings fields below jumping around on every slider tick -- +-- that still holds for icon CONTENT (see RenderPreviewIcons' block-centering +-- comment, unchanged), just not for the box's own footprint, which must +-- track the grid it's supposed to contain. +-- outerFrame: the detail pane's own top-level frame (title/desc's parent) +-- startY: outerFrame-local Y to start placing the PREVIEW label/box at +-- (the caller's fixed offset below title/desc, e.g. -50) +-- kind: "buff" or "debuff" +-- id: "default" | "extdef" | a custom bar's id +-- cfg: the same cfg table the caller already resolved for its own +-- ApplyBar/field builders +-- Returns the outerFrame-local Y where the preview area ends -- the caller +-- passes this straight to WrapCompensatedBody(outerFrame, returnedY) as the +-- scrollable settings area's own top offset. +function ns.PAB_BuildPreviewBox(outerFrame, fontPath, startY, kind, id, cfg) + local isBuff = kind == "buff" + local sy = startY + + do + local contentPad = EllesmereUI.CONTENT_PAD or 45 + local padDiff = contentPad - 20 + local visibleW = outerFrame:GetWidth() + -- 2026-08-03 (Joel: too much dead space between the title/desc and + -- the actual preview box): was W:SectionHeader, a shared widget + -- fixed at 40px tall with its label anchored 8px from the BOTTOM of + -- that block -- meant for spacing consistency among stacked option + -- rows elsewhere, not this floating title/desc/box context, and it + -- left ~20px of pure blank padding above the "PREVIEW" text with + -- nothing else needing that room here. Replaced with a lightweight, + -- purpose-built label + separator at a fraction of the height, + -- matching SectionHeader's own look (EllesmereUI.TEXT_SECTION/ + -- BORDER_COLOR, both already exposed on the shared EllesmereUI + -- table) without touching the shared widget file. + local hdrH = 18 + + -- Shift lives on the clipping frame itself (hdrClip), not on + -- hdrBody inside it -- mirrors WrapCompensatedBody's own fix in + -- EUI_PlayerAuraBars_ManagerPages.lua (see that file's doc comment: + -- a "shift the child instead of the clip frame" structure that's + -- mathematically equivalent measured a real ~30px extra gap + -- in-game vs RaidFrames' reference, which shifts the clip/scroll + -- frame itself). + local hdrClip = CreateFrame("Frame", nil, outerFrame) + hdrClip:SetPoint("TOPLEFT", outerFrame, "TOPLEFT", -padDiff, sy) + hdrClip:SetSize(math.max(visibleW, 1) + padDiff * 2, hdrH) + hdrClip:SetClipsChildren(true) + + local hdrBody = CreateFrame("Frame", nil, hdrClip) + hdrBody:SetSize(visibleW + padDiff * 2, hdrH) + + local TS = EllesmereUI.TEXT_SECTION or { r = 0.5, g = 0.5, b = 0.5, a = 1 } + local label = hdrBody:CreateFontString(nil, "OVERLAY") + label:SetFont(fontPath, 12, "") + label:SetTextColor(TS.r, TS.g, TS.b, TS.a or 1) + label:SetPoint("BOTTOMLEFT", hdrBody, "BOTTOMLEFT", contentPad, 0) + label:SetText(EllesmereUI.L("PREVIEW")) + + local BC = EllesmereUI.BORDER_COLOR or { r = 1, g = 1, b = 1 } + local sep = hdrBody:CreateTexture(nil, "ARTWORK") + sep:SetColorTexture(BC.r, BC.g, BC.b, 0.02) + sep:SetHeight(1) + sep:SetPoint("BOTTOMLEFT", hdrBody, "BOTTOMLEFT", contentPad, 0) + sep:SetPoint("BOTTOMRIGHT", hdrBody, "BOTTOMRIGHT", -contentPad, 0) + + sy = sy - hdrH + end + + -- Sized from the SCALED cfg (see ScaledPreviewCfg/PreviewScaleFactor's + -- own doc comment) so the box's footprint matches what + -- RenderPreviewIcons actually draws into it. + local grid = ComputeGrid(isBuff, ScaledPreviewCfg(cfg, isBuff)) + -- +30 (scaled) extra vertical room for duration/stack text rendering + -- above/below the icon grid itself -- ComputeGrid's own width/height + -- are the icon grid's bounding box only (same as the real bar), text + -- can render outside that box depending on Duration/Stacks Position. + local boxHeight = grid.height + 30 * PreviewScaleFactor() + local box = CreateFrame("Frame", nil, outerFrame) + box:SetPoint("TOP", outerFrame, "TOP", 0, sy) + box:SetSize(math.max(grid.width, 1), boxHeight) + + local headerBg = outerFrame._pabPreviewHeaderBg + if not headerBg then + headerBg = outerFrame:CreateTexture(nil, "BACKGROUND") + headerBg:SetColorTexture(0, 0, 0, 0.15) + outerFrame._pabPreviewHeaderBg = headerBg + end + headerBg:ClearAllPoints() + headerBg:SetPoint("TOPLEFT", outerFrame, "TOPLEFT", 0, 0) + headerBg:SetPoint("TOPRIGHT", outerFrame, "TOPRIGHT", 0, 0) + + local divider = outerFrame._pabPreviewDivider + if not divider then + divider = outerFrame:CreateTexture(nil, "OVERLAY") + divider:SetColorTexture(1, 1, 1, 0.10) + divider:SetHeight(1) + outerFrame._pabPreviewDivider = divider + end + + local bottomY = sy - boxHeight - 10 + headerBg:SetHeight(math.abs(bottomY)) + divider:ClearAllPoints() + divider:SetPoint("TOPLEFT", outerFrame, "TOPLEFT", 0, bottomY) + divider:SetPoint("TOPRIGHT", outerFrame, "TOPRIGHT", 0, bottomY) + + local icons = {} + -- Shuffled once per box build, not per refresh -- see BuildBuffPreviewPool's + -- own doc comment for why (icons shouldn't swap spell identity on every + -- slider tick, only their style/position/count). External Defensives + -- gets its own cross-class pool (EXTDEF_PREVIEW_SPELLS) instead of the + -- player's class buffs -- those auras come from OTHER players' classes. + -- Debuffs get BuildDebuffPreviewPool() (2026-08-03, same shuffle-once + -- treatment extended to the debuff side). + local pool + if isBuff then + pool = (id == "extdef") and ShuffleCopy(EXTDEF_PREVIEW_SPELLS) or BuildBuffPreviewPool() + else + pool = BuildDebuffPreviewPool() + end + activePreview = { + kind = kind, id = id, box = box, icons = icons, fontPath = fontPath, pool = pool, + outerFrame = outerFrame, boxTopY = sy, headerBg = headerBg, divider = divider, + } + RenderPreviewIcons(box, icons, isBuff, cfg, fontPath, pool) + + return bottomY +end + +-- Registers a callback the caller's WrapCompensatedBody (EUI_PlayerAuraBars_ +-- ManagerPages.lua) uses to reposition its scroll frame's top edge whenever +-- PAB_MaybeRefreshPreview resizes the box below -- see that function's doc +-- comment for why the box's footprint isn't fixed at build time anymore. +-- Set on activePreview (not a standalone module-level var) so a callback +-- from a since-torn-down detail pane can never fire against the wrong +-- pane's box after a tab switch rebuilds activePreview. +function ns.PAB_SetPreviewResizeHandler(fn) + if activePreview then activePreview.onResize = fn end +end + +-- Piggyback hook, called at the end of every live-apply path (ApplyLiveConfig, +-- ApplyExtDefLiveConfig, PAB_ReloadCustomBuffBar, PAB_ReloadCustomDebuffBar) +-- so a currently-open preview box stays in sync with slider drags/dropdown +-- changes without EUI_PlayerAuraBars_ManagerPages.lua needing to know the +-- preview exists or wrap its ApplyBar() closures. +PAB_MaybeRefreshPreview = function(kind, id) + if not (activePreview and activePreview.kind == kind and activePreview.id == id) then return end + local cfg, isBuff = ResolvePreviewCfg(kind, id) + if not cfg then return end + + -- Re-derive the box's footprint from the SAME scaled grid RenderPreviewIcons + -- is about to draw into, exactly mirroring PAB_BuildPreviewBox's own + -- boxHeight/bottomY math (kept in sync manually -- see that function's + -- doc comment for why this can't just be skipped/left build-time-only). + local grid = ComputeGrid(isBuff, ScaledPreviewCfg(cfg, isBuff)) + local boxHeight = grid.height + 30 * PreviewScaleFactor() + activePreview.box:SetSize(math.max(grid.width, 1), boxHeight) + + local bottomY = activePreview.boxTopY - boxHeight - 10 + if activePreview.headerBg then + activePreview.headerBg:SetHeight(math.abs(bottomY)) + end + if activePreview.divider and activePreview.outerFrame then + activePreview.divider:ClearAllPoints() + activePreview.divider:SetPoint("TOPLEFT", activePreview.outerFrame, "TOPLEFT", 0, bottomY) + activePreview.divider:SetPoint("TOPRIGHT", activePreview.outerFrame, "TOPRIGHT", 0, bottomY) + end + if activePreview.onResize then activePreview.onResize(bottomY) end + + RenderPreviewIcons(activePreview.box, activePreview.icons, isBuff, cfg, activePreview.fontPath, activePreview.pool) +end + +------------------------------------------------------------------------------- +-- Lifecycle +------------------------------------------------------------------------------- + +-- ns.db is set by EllesmereUIUnitFrames.lua's SetupOptionsPanel(), which +-- EnableBody() itself only schedules via C_Timer.After(0, SetupOptionsPanel) +-- -- i.e. one frame AFTER PLAYER_LOGIN's handlers finish running. A single +-- PLAYER_LOGIN listener here would run BEFORE ns.db exists (confirmed via +-- debug print: PAB() returned nil at that point). Rather than depend on the +-- exact relative timing between two independent C_Timer.After(0, ...) calls +-- in different files (not a stable guarantee), retry with a capped, gently +-- backing-off timer until ns.db is actually populated. +local RETRY_CAP = 40 -- ~ a few seconds worst case at the backed-off interval; then give up loudly +local retryCount = 0 + +local function TryCreateBars() + if PAB() then + CreateBars() + return + end + retryCount = retryCount + 1 + if retryCount > RETRY_CAP then + geterrorhandler()("EllesmereUIUnitFrames_PlayerAuraBars: ns.db never became " + .. "available after " .. RETRY_CAP .. " retries -- Player Aura Bars did not load.") + return + end + C_Timer.After(0, TryCreateBars) +end + +ns.PAB_CreateBars = CreateBars + +local initFrame = CreateFrame("Frame") +initFrame:RegisterEvent("PLAYER_LOGIN") +initFrame:SetScript("OnEvent", function(self, event) + if event == "PLAYER_LOGIN" then + self:UnregisterEvent("PLAYER_LOGIN") + TryCreateBars() + end +end) + diff --git a/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuras.lua b/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuras.lua deleted file mode 100644 index 29032305..00000000 --- a/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuras.lua +++ /dev/null @@ -1,815 +0,0 @@ -------------------------------------------------------------------------------- --- EllesmereUIUnitFrames_PlayerAuras.lua --- Simple reskin of Blizzard's standalone BuffFrame / DebuffFrame icons. --- No reparenting, no repositioning -- Blizzard controls layout via Edit Mode. -------------------------------------------------------------------------------- -local addon, ns = ... - -local GetFFD = EllesmereUI._GetFFD - -local ICON_ZOOM = 0.055 -- fallback crop (same as totem bar); user values in profile -local BLIZZARD_AURA_ICON_SIZE = 30 -- visible icon inside the native 32px aura button - -------------------------------------------------------------------------------- --- Settings helper -------------------------------------------------------------------------------- -local function PA() - local db = ns.db - return db and db.profile and db.profile.playerAuras -end - -------------------------------------------------------------------------------- --- Skin generation --- --- Blizzard fires AuraContainer:UpdateGridLayout continuously while buff timers --- tick, and every fire used to re-skin every visible button from scratch: the --- full border chain (ApplySecretSafeBorderStyle -> ApplyBorderStyle -> --- PP.UpdateBorder -> SnapBorderTextures) plus a font resolve and SetFont per --- button, every frame, even standing still doing nothing. SkinAuraButton --- already set an ffd._paSkinned flag for exactly this -- but nothing ever read --- it, so it never short-circuited anything. Profiling attributed the cost to --- the PARENT addon, because that is where the border and font code lives. --- --- A button stamped with the current generation is already styled correctly and --- is skipped. The settings that feed the skin are compared ONCE per refresh --- rather than per button, so a change from any source -- options, a profile --- switch, a font change -- bumps the generation and re-skins everything, --- without paying for the comparison on every button. -------------------------------------------------------------------------------- -local skinGen = 1 -local lastCfg = {} --- Active custom duration style, or nil when the user is on the Blizzard --- default. Maintained by NoteConfig. The UpdateDuration hook fires every --- render frame per visible aura button, so its body must read ONE upvalue -- --- never the settings chain -- and the hook itself only installs when a custom --- style is configured (zero cost on the default). -local _durFmt --- True when the aura LIST may have changed since the last sweep (player --- UNIT_AURA, Edit Mode preview, or an explicit settings refresh): aura --- buttons are only ever BORN from those, so the grid passes Blizzard fires --- from pure duration ticking find this false and skin nothing. -local _paAuraDirty = true - -local function NoteConfig(cfg) - local font = (EllesmereUI.GetFontPath and EllesmereUI.GetFontPath("unitFrames")) or "" - local outline = (EllesmereUI.GetFontOutlineFlag and EllesmereUI.GetFontOutlineFlag("unitFrames")) or "" - if lastCfg.borderSize == cfg.borderSize - and lastCfg.borderBehind == cfg.borderBehind - and lastCfg.noBorderDebuffs == cfg.noBorderDebuffs - and lastCfg.showText == cfg.showText - and lastCfg.textSize == cfg.textSize - and lastCfg.borderR == cfg.borderR - and lastCfg.borderG == cfg.borderG - and lastCfg.borderB == cfg.borderB - and lastCfg.borderA == cfg.borderA - and lastCfg.borderTexture == cfg.borderTexture - and lastCfg.offX == cfg.borderTextureOffset - and lastCfg.offY == cfg.borderTextureOffsetY - and lastCfg.shiftX == cfg.borderTextureShiftX - and lastCfg.shiftY == cfg.borderTextureShiftY - and lastCfg.buffZoom == cfg.buffIconZoom - and lastCfg.debuffZoom == cfg.debuffIconZoom - and lastCfg.durFmt == cfg.durationFormat - and lastCfg.font == font - and lastCfg.outline == outline then - return - end - lastCfg.borderSize, lastCfg.borderBehind = cfg.borderSize, cfg.borderBehind - lastCfg.noBorderDebuffs, lastCfg.showText = cfg.noBorderDebuffs, cfg.showText - lastCfg.textSize, lastCfg.borderTexture = cfg.textSize, cfg.borderTexture - lastCfg.borderR, lastCfg.borderG = cfg.borderR, cfg.borderG - lastCfg.borderB, lastCfg.borderA = cfg.borderB, cfg.borderA - lastCfg.offX, lastCfg.offY = cfg.borderTextureOffset, cfg.borderTextureOffsetY - lastCfg.shiftX, lastCfg.shiftY = cfg.borderTextureShiftX, cfg.borderTextureShiftY - lastCfg.buffZoom, lastCfg.debuffZoom = cfg.buffIconZoom, cfg.debuffIconZoom - lastCfg.durFmt = cfg.durationFormat - lastCfg.font, lastCfg.outline = font, outline - _durFmt = (cfg.durationFormat and cfg.durationFormat ~= "blizzard") - and cfg.durationFormat or nil - skinGen = skinGen + 1 -end - -local function FormatCompactDuration(timeLeft, style) - if timeLeft >= 86400 then - return string.format("%dd", math.floor(timeLeft / 86400 + 0.5)) - end - if style == "colon" then - if timeLeft >= 3600 then - return string.format("%d:%02d", - math.floor(timeLeft / 3600), - math.floor((timeLeft % 3600) / 60)) - end - if timeLeft >= 60 then - return string.format("%d:%02d", math.floor(timeLeft / 60), math.floor(timeLeft % 60)) - end - return string.format("%d", math.floor(timeLeft + 0.5)) - end - if timeLeft >= 3600 then - return string.format("%dh", math.floor(timeLeft / 3600 + 0.5)) - end - if style == "seconds" then - return string.format("%d", math.floor(timeLeft + 0.5)) - end - if timeLeft >= 60 then - return string.format("%dm", math.floor(timeLeft / 60 + 0.5)) - end - return string.format("%d", math.floor(timeLeft + 0.5)) -end - -------------------------------------------------------------------------------- --- Per-button skinning -------------------------------------------------------------------------------- -local function SkinAuraButton(btn, isDebuff, cfg) - cfg = cfg or PA() - if not cfg then return end - -- Skip layout anchors - if btn.isAuraAnchor then return end - - local ffd = GetFFD(btn) - if not ffd then return end - - -- Already styled at the current settings: nothing below would change a - -- pixel. This is the read that _paSkinned was always missing. - if ffd._paSkinned == skinGen and ffd._paSkinDebuff == isDebuff then return end - - -- Icon zoom crop (btn.Icon is a Frame in Midnight; find the Texture inside) - local iconFrame = btn.Icon - local iconTex - if iconFrame then - -- Try known child names first - iconTex = iconFrame.Texture or iconFrame.texture - -- Fallback: scan for the first Texture region - if not iconTex and iconFrame.GetRegions then - for i = 1, iconFrame:GetNumRegions() do - local r = select(i, iconFrame:GetRegions()) - if r and r:IsObjectType("Texture") and r.SetTexCoord then - iconTex = r - break - end - end - end - -- iconFrame itself might be a Texture (pre-Midnight) - if not iconTex and iconFrame.SetTexCoord then - iconTex = iconFrame - end - end - if iconTex and iconTex.SetTexCoord then - local z - if isDebuff then z = cfg.debuffIconZoom else z = cfg.buffIconZoom end - z = z or ICON_ZOOM - iconTex:SetTexCoord(z, 1 - z, z, 1 - z) - end - - -- Hide Blizzard border (alpha, not Hide, to avoid taint) - -- Keep it visible on debuffs when noBorderDebuffs is enabled (colored border) - if btn.DebuffBorder then - if isDebuff and cfg.noBorderDebuffs then - btn.DebuffBorder:SetAlpha(1) - else - btn.DebuffBorder:SetAlpha(0) - end - end - - -- Duration text styling (btn.Duration may be a Frame containing a FontString) - local durFS = btn.Duration - if durFS and not durFS.SetFont and durFS.GetRegions then - -- Duration is a Frame; find the FontString inside - for i = 1, durFS:GetNumRegions() do - local r = select(i, durFS:GetRegions()) - if r and r.SetFont then durFS = r; break end - end - end - -- Lazy install: only when a custom duration style is active. The settings - -- change that activates one bumps skinGen (NoteConfig tracks the key), so - -- this body re-runs and installs then. hooksecurefunc cannot uninstall, - -- so on a revert to "blizzard" the body bails on the single _durFmt read. - if durFS and durFS.SetFont and not ffd._paDurHooked and _durFmt - and type(btn.UpdateDuration) == "function" then - ffd._paDurHooked = true - local fs = durFS - hooksecurefunc(btn, "UpdateDuration", function(_, timeLeft) - if not _durFmt then return end - if type(timeLeft) ~= "number" then return end - if issecretvalue and issecretvalue(timeLeft) then return end - if timeLeft <= 0 then return end - fs:SetText(FormatCompactDuration(timeLeft, _durFmt)) - end) - end - - if durFS and durFS.SetFont then - if cfg.showText then - local fontPath = EllesmereUI.GetFontPath and EllesmereUI.GetFontPath("unitFrames") or STANDARD_TEXT_FONT - local outline = EllesmereUI.GetFontOutlineFlag and EllesmereUI.GetFontOutlineFlag("unitFrames") or "OUTLINE, SLUG" - if EllesmereUI and EllesmereUI.PrimeFontShadow then EllesmereUI.PrimeFontShadow(durFS, outline == "") end - durFS:SetFont(fontPath, cfg.textSize or 11, outline) - durFS:SetTextColor(1, 1, 1, 1) - else - durFS:SetTextColor(0, 0, 0, 0) - end - end - - -- Count text styling - local countFS = btn.Count - if countFS and not countFS.SetFont and countFS.GetRegions then - for i = 1, countFS:GetNumRegions() do - local r = select(i, countFS:GetRegions()) - if r and r.SetFont then countFS = r; break end - end - end - if countFS and countFS.SetFont then - local fontPath = EllesmereUI.GetFontPath and EllesmereUI.GetFontPath("unitFrames") or STANDARD_TEXT_FONT - -- Stack count always uses a forced OUTLINE, SLUG flag (keeps the digits - -- crisp regardless of the user's global font-outline setting). - EllesmereUI.ApplyIconTextFont(countFS, fontPath, cfg.textSize or 11, "unitFrames") - end - - -- The shared border-style engine requires a frame we own. Keep that frame - -- anchored to the icon instead of applying a backdrop to Blizzard's button. - local anchorFrame = iconFrame or btn - local bs = cfg.borderSize or 1 - local skipBorder = isDebuff and cfg.noBorderDebuffs - local border = ffd._paBorder - if not border then - border = CreateFrame("Frame", nil, btn) - border:EnableMouse(false) - ffd._paBorder = border - end - border:SetFrameLevel(cfg.borderBehind and math.max(0, btn:GetFrameLevel() - 1) or (btn:GetFrameLevel() + 10)) - -- Never derive this frame's dimensions through SetAllPoints(anchorFrame): - -- Blizzard aura dimensions are secret on Midnight, and BackdropTemplate's - -- SetBackdrop then attempts arithmetic on that secret width. AuraContainer - -- applies the user's icon size as scale over Blizzard's native 32px button. - -- Its visible icon is 30px, so this public constant avoids both the secret - -- geometry read and the one-pixel gap left by sizing to the whole button. - border:ClearAllPoints() - border:SetPoint("CENTER", anchorFrame, "CENTER", 0, 0) - border:SetSize(BLIZZARD_AURA_ICON_SIZE, BLIZZARD_AURA_ICON_SIZE) - EllesmereUI.ApplySecretSafeBorderStyle(border, ffd, - (bs > 0 and not skipBorder) and bs or 0, - cfg.borderR or 0, cfg.borderG or 0, cfg.borderB or 0, cfg.borderA or 1, - cfg.borderTexture or "solid", - cfg.borderTextureOffset, cfg.borderTextureOffsetY, - cfg.borderTextureShiftX, cfg.borderTextureShiftY, - "unitframes", bs) - - ffd._paSkinned = skinGen - ffd._paSkinDebuff = isDebuff -end - -------------------------------------------------------------------------------- --- Iterate and skin all visible aura buttons on a frame -------------------------------------------------------------------------------- -local function SkinAllButtons(frame, isDebuff, cfg) - if not frame or not frame.auraFrames then return end - for _, btn in pairs(frame.auraFrames) do - if btn and btn.Icon and not btn.isAuraAnchor then - SkinAuraButton(btn, isDebuff, cfg) - end - end -end - -------------------------------------------------------------------------------- --- Full refresh (called on setting change or UNIT_AURA) -------------------------------------------------------------------------------- -local function RefreshAll() - local cfg = PA() - if not (cfg and cfg.enabled) then return end - -- Once per refresh, not once per button. - NoteConfig(cfg) - _paAuraDirty = false - SkinAllButtons(BuffFrame, false, cfg) - SkinAllButtons(DebuffFrame, true, cfg) -end -ns.RefreshPlayerAuras = RefreshAll - --- UpdateGridLayout can fire several times within one frame; each fire used to --- queue its own full refresh. Coalesced to one pass per tick. Named rather than --- an inline closure so scheduling allocates nothing. -local refreshPending = false - -local function DoPendingRefresh() - refreshPending = false - if _paAuraDirty then - RefreshAll() - end -end - -local function RequestRefresh() - if refreshPending then return end - if _paAuraDirty then - refreshPending = true - C_Timer.After(0, DoPendingRefresh) - end -end - -------------------------------------------------------------------------------- --- Scale helper (applies iconSize via SetScale on AuraContainer) -------------------------------------------------------------------------------- -local _appliedBuffScale, _appliedDebuffScale - -local function ApplyExpandButtonSetting() - local cfg = PA() - local button = BuffFrame and BuffFrame.CollapseAndExpandButton - if not (cfg and button) then return end - local show = cfg.showExpandButton ~= false - -- Purely visual: hide the expand/collapse button when the user opts to. - -- We deliberately do NOT write BuffFrame.isExpanded or call BuffFrame:Update - -- / UpdateGridLayout / RefreshConsolidationFrameVisibility from addon code. - -- Driving Blizzard's aura machinery from addon context runs it under our - -- taint, so Blizzard's own UpdateExpirationTime compares the secret - -- expirationTime and errors on every aura update -- 2000+ errors and heavy - -- lag in raid combat -- and that tainted Update also throws before our - -- border re-skin hook runs, so the icon borders revert on reload. Auras keep - -- Blizzard's native expand state (default is expanded, so all auras show); - -- when hidden, the button also stays hidden via the deferred - -- RefreshConsolidationFrameVisibility hook installed at init. - if not show then - button:Hide() - end -end - -local function ApplyScale() - local cfg = PA() - if not cfg or not cfg.enabled then return end - local nativeSize = 32 - local scale = (cfg.iconSize or nativeSize) / nativeSize - - if BuffFrame and BuffFrame.AuraContainer then - if _appliedBuffScale ~= scale then - BuffFrame.AuraContainer:SetScale(scale) - _appliedBuffScale = scale - end - end - if DebuffFrame and DebuffFrame.AuraContainer then - if _appliedDebuffScale ~= scale then - DebuffFrame.AuraContainer:SetScale(scale) - _appliedDebuffScale = scale - end - end - ApplyExpandButtonSetting() -end -ns.ApplyPlayerAuraScale = ApplyScale - - -------------------------------------------------------------------------------- --- External Defensives Frame -- standalone EUI frame showing the external --- defensive buffs currently on the player (Pain Suppression, Ironbark, ...), --- matched by the engine's native EXTERNAL_DEFENSIVE aura filter. Cheap by --- construction: the C side filters the enumeration (almost always zero --- matches), the event is player-only UNIT_AURA, countdowns render through --- the engine's Cooldown widget (no ticker, no OnUpdate), and nothing -- --- frames, font object, event registration -- exists until first enabled. -------------------------------------------------------------------------------- -local EDF_FILTER = "HELPFUL|EXTERNAL_DEFENSIVE" -local EDF_SPACING = 4 -local C_UA = C_UnitAuras -local EDF_GetAuraDuration = C_UA and C_UA.GetAuraDuration -local EDF_GetAppCount = C_UA and C_UA.GetAuraApplicationDisplayCount --- Classification tokens are NOT slot-fetch filters on 12.0 -- membership is --- tested per aura instance, exactly like ns.EUIAuraFilter does for the unit --- frame elements (fetch broad HELPFUL, then IsAuraFilteredOutByInstanceID). -local EDF_IsFilteredOut = C_UA and C_UA.IsAuraFilteredOutByInstanceID - -local edfRoot -local edfButtons = {} -local edfEvt -local edfFont -local edfIDs = {} -- ordered shown auraInstanceIDs -local edfIcons = {} -- [auraInstanceID] = icon fileID - -local function ED() - local db = ns.db - return db and db.profile and db.profile.externalDefensives -end - --- Countdown formatters for the EDF cooldown widgets. SetCountdownFormatter --- takes an ENGINE formatter object (C_StringUtil.CreateNumericRuleFormatter), --- never a Lua function -- passing a closure throws "bad argument #2", which --- aborted EDF_StyleButton partway and left the button permanently unstyled and --- its count FontString font-less. Engine-side formatting is also what makes --- secret durations render at all, the same reason the Cooldown Manager's --- threshold text uses this API. --- --- Only the sub-hour range is styled per format; externals are all short, and --- the hour/day breakpoints exist purely as a tail. Thresholds sit just above --- each unit boundary so an UP-rounded value in (59, 60] routes into the next --- breakpoint instead of reading "60" for a tick. -local EDF_formatters = {} -local EDF_fmtUnsupported = false - -local function EDF_FormatterFor(style) - if EDF_fmtUnsupported or not style or style == "blizzard" then return nil end - local cached = EDF_formatters[style] - if cached ~= nil then return cached or nil end - if not (C_StringUtil and C_StringUtil.CreateNumericRuleFormatter - and Enum.NumericRuleFormatRounding) then - EDF_fmtUnsupported = true - return nil - end - local Up = Enum.NumericRuleFormatRounding.Up - local points = { { threshold = 0, format = "%d", rounding = Up, step = 1 } } - if style == "colon" then - points[#points + 1] = { - threshold = 59.0001, format = "%d:%02d", rounding = Up, step = 1, - components = { { div = 60 }, { mod = 60 } }, - } - elseif style ~= "seconds" then - -- "compact": minutes above a minute. "seconds" deliberately has no - -- minute breakpoint, so it keeps counting raw seconds ("152"). - points[#points + 1] = { - threshold = 59.0001, format = "%dm", rounding = Up, step = 1, - components = { { div = 60 } }, - } - end - points[#points + 1] = { - threshold = 3599.0001, format = "%dh", rounding = Up, step = 1, - components = { { div = 3600 } }, - } - points[#points + 1] = { - threshold = 86399.0001, format = "%dd", rounding = Up, step = 1, - components = { { div = 86400 } }, - } - local f = C_StringUtil.CreateNumericRuleFormatter() - if not pcall(f.SetBreakpoints, f, points) then - EDF_formatters[style] = false - return nil - end - EDF_formatters[style] = f - return f -end - -local function EDF_StyleButton(btn, cfg) - local size = cfg.iconSize or 32 - btn:SetSize(size, size) - btn:ClearAllPoints() - -- Growth direction: the first icon pins to one edge of the frame and - -- later icons extend toward the other. - if (cfg.growDirection or "right") == "left" then - btn:SetPoint("RIGHT", edfRoot, "RIGHT", -((btn._index - 1) * (size + EDF_SPACING)), 0) - else - btn:SetPoint("LEFT", edfRoot, "LEFT", (btn._index - 1) * (size + EDF_SPACING), 0) - end - - local z = cfg.iconZoom or ICON_ZOOM - btn._icon:SetTexCoord(z, 1 - z, z, 1 - z) - - local cd = btn._cd - if cd.SetHideCountdownNumbers then - cd:SetHideCountdownNumbers(cfg.showText == false) - end - -- SetCountdownFont takes the NAME of a named font object, not the object. - if edfFont and cd.SetCountdownFont then cd:SetCountdownFont("EUI_EDF_CountdownFont") end - -- Custom duration formats via the engine formatter (nil-guarded: on - -- clients without it the dropdown falls back to the native format). - if cd.SetCountdownFormatter then - cd:SetCountdownFormatter(EDF_FormatterFor(cfg.durationFormat)) - end - - if btn._count then - local fontPath = EllesmereUI.GetFontPath and EllesmereUI.GetFontPath("unitFrames") or STANDARD_TEXT_FONT - EllesmereUI.ApplyIconTextFont(btn._count, fontPath, cfg.textSize or 11, "unitFrames") - end - - local bs = cfg.borderSize or 1 - local host = btn._borderHost - host:SetFrameLevel(cfg.borderBehind and math.max(0, btn:GetFrameLevel() - 1) or (btn:GetFrameLevel() + 2)) - EllesmereUI.ApplyBorderStyle(host, bs, - cfg.borderR or 0, cfg.borderG or 0, cfg.borderB or 0, cfg.borderA or 1, - cfg.borderTexture or "solid", - cfg.borderTextureOffset, cfg.borderTextureOffsetY, - cfg.borderTextureShiftX, cfg.borderTextureShiftY, - "unitframes", bs) -end - -local function EDF_CreateButton(i) - local btn = CreateFrame("Frame", nil, edfRoot) - btn._index = i - btn:EnableMouse(false) - - local icon = btn:CreateTexture(nil, "ARTWORK") - icon:SetAllPoints() - btn._icon = icon - - local cd = CreateFrame("Cooldown", nil, btn, "CooldownFrameTemplate") - cd:SetAllPoints() - cd:SetReverse(true) - if cd.SetDrawEdge then cd:SetDrawEdge(false) end - btn._cd = cd - - local borderHost = CreateFrame("Frame", nil, btn) - borderHost:SetAllPoints(btn) - borderHost:EnableMouse(false) - btn._borderHost = borderHost - - -- Count + border live on a host above the cooldown, so the permanent-aura - -- alpha mask on the cd (see EDF_Update) never takes them down with it. - local txtHost = CreateFrame("Frame", nil, btn) - txtHost:SetAllPoints() - txtHost:SetFrameLevel(cd:GetFrameLevel() + 1) - local cnt = txtHost:CreateFontString(nil, "OVERLAY") - -- Baseline font at creation: EDF_StyleButton re-points this at the user's - -- configured font, but the button is already in edfButtons by then, so a - -- styling pass that fails partway would otherwise leave a font-less - -- FontString that throws "Font not set" on every later SetText. - cnt:SetFont(EllesmereUI.GetFontPath and EllesmereUI.GetFontPath("unitFrames") - or STANDARD_TEXT_FONT, 11, "") - cnt:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -1, 1) - btn._count = cnt - - edfButtons[i] = btn - local cfg = ED() - if cfg then EDF_StyleButton(btn, cfg) end - return btn -end - -local function EDF_IsExternal(iid) - return iid and EDF_IsFilteredOut - and not EDF_IsFilteredOut("player", iid, EDF_FILTER) -end - --- Arm one button's engine-rendered pieces (duration swipe/countdown + count). -local function EDF_ArmButton(btn, iid) - local cd = btn._cd - if cd and EDF_GetAuraDuration then - local durObj = EDF_GetAuraDuration("player", iid) - if durObj and cd.SetCooldownFromDurationObject then - cd:SetCooldownFromDurationObject(durObj) - -- Permanent/no-duration auras return a degenerate (0,0) duration - -- whose armed cooldown strobes; mask with alpha, never branch on - -- the (possibly secret) IsZero. - if durObj.IsZero and cd.SetAlphaFromBoolean then - cd:SetAlphaFromBoolean(durObj:IsZero(), 0, 1) - elseif cd.SetAlpha then - cd:SetAlpha(1) - end - else - cd:Clear() - end - end - if btn._count then - if EDF_GetAppCount then - btn._count:SetText(EDF_GetAppCount("player", iid, 2, 1000) or "") - else - btn._count:SetText("") - end - end -end - -local function EDF_Display() - local n = #edfIDs - for i = 1, n do - local iid = edfIDs[i] - local btn = edfButtons[i] or EDF_CreateButton(i) - btn._icon:SetTexture(edfIcons[iid]) - EDF_ArmButton(btn, iid) - btn:Show() - end - for i = n + 1, #edfButtons do edfButtons[i]:Hide() end -end - -local function EDF_FullScan() - wipe(edfIDs); wipe(edfIcons) - if C_UA and C_UA.GetAuraSlots and C_UA.GetAuraDataBySlot then - local slots = { C_UA.GetAuraSlots("player", "HELPFUL") } - for i = 2, #slots do - local aura = C_UA.GetAuraDataBySlot("player", slots[i]) - local iid = aura and aura.auraInstanceID - if iid and EDF_IsExternal(iid) then - edfIDs[#edfIDs + 1] = iid - edfIcons[iid] = aura.icon - end - end - end -end - --- Incremental UNIT_AURA processing: steady-state cost is proportional to the --- CHANGE (usually one added/removed aura tested with one C call), never to --- the player's full buff list. Full rescans only on login/full updates. -local function EDF_Update(_, _, _, updateInfo) - local cfg = ED() - if not (cfg and cfg.enabled and edfRoot) then return end - - if not updateInfo or updateInfo.isFullUpdate then - EDF_FullScan() - EDF_Display() - return - end - - local changed = false - if updateInfo.addedAuras then - for _, aura in ipairs(updateInfo.addedAuras) do - local iid = aura.auraInstanceID - if aura.isHelpful and iid and not edfIcons[iid] and EDF_IsExternal(iid) then - edfIDs[#edfIDs + 1] = iid - edfIcons[iid] = aura.icon - changed = true - end - end - end - if updateInfo.removedAuraInstanceIDs then - for _, iid in ipairs(updateInfo.removedAuraInstanceIDs) do - if edfIcons[iid] then - edfIcons[iid] = nil - for i = #edfIDs, 1, -1 do - if edfIDs[i] == iid then table.remove(edfIDs, i); break end - end - changed = true - end - end - end - if changed then - EDF_Display() - elseif updateInfo.updatedAuraInstanceIDs then - -- Refresh duration/stacks in place for tracked auras only. - for _, iid in ipairs(updateInfo.updatedAuraInstanceIDs) do - if edfIcons[iid] then - for i = 1, #edfIDs do - if edfIDs[i] == iid then - local btn = edfButtons[i] - if btn then EDF_ArmButton(btn, iid) end - break - end - end - end - end - end -end - -local function EDF_ApplyStyle() - local cfg = ED() - if not (cfg and edfRoot) then return end - if edfFont then - local fontPath = EllesmereUI.GetFontPath and EllesmereUI.GetFontPath("unitFrames") or STANDARD_TEXT_FONT - -- Icon-text convention: forced "OUTLINE, SLUG" like every other unit - -- frame icon text, with the global "Outline Icon Text" setting able - -- to route this to the user's font + font outline instead. - EllesmereUI.ApplyIconTextFont(edfFont, fontPath, cfg.textSize or 11, "unitFrames") - end - local size = cfg.iconSize or 32 - edfRoot:SetSize(4 * size + 3 * EDF_SPACING, size) - for _, btn in ipairs(edfButtons) do EDF_StyleButton(btn, cfg) end -end - -local function EDF_ApplyPosition() - if not edfRoot then return end - local cfg = ED() - local pos = cfg and cfg.unlockPos - edfRoot:ClearAllPoints() - if pos and pos.point then - edfRoot:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, pos.x or 0, pos.y or 0) - else - edfRoot:SetPoint("CENTER", UIParent, "CENTER", 0, -220) - end -end - -local function EDF_RegisterUnlock() - if not (EllesmereUI.RegisterUnlockElements and EllesmereUI.MakeUnlockElement) then return end - local MK = EllesmereUI.MakeUnlockElement - EllesmereUI:RegisterUnlockElements({ - MK({ - key = "EUF_ExternalDefensives", - label = "External Defensives", - group = "Unit Frames", - order = 450, - noResize = true, - getFrame = function() return edfRoot end, - getSize = function() - local cfg = ED() - local size = (cfg and cfg.iconSize) or 32 - return 4 * size + 3 * EDF_SPACING, size - end, - isHidden = function() - local cfg = ED() - return not (cfg and cfg.enabled) - end, - savePos = function(_, point, relPoint, x, y) - if not point then return end - local cfg = ED(); if not cfg then return end - cfg.unlockPos = { point = point, relPoint = relPoint or point, x = x, y = y } - if not EllesmereUI._unlockActive then EDF_ApplyPosition() end - end, - loadPos = function() - local cfg = ED() - local pos = cfg and cfg.unlockPos - if not pos then return nil end - return { point = pos.point, relPoint = pos.relPoint or pos.point, x = pos.x, y = pos.y } - end, - clearPos = function() - local cfg = ED() - if cfg then cfg.unlockPos = nil end - EDF_ApplyPosition() - end, - applyPos = EDF_ApplyPosition, - }), - }, "EllesmereUIUnitFrames") -end - --- Live enable/disable + full restyle. Zero footprint while never enabled: --- no frames, no font object, no event registration. -local function EDF_Setup() - -- 12.1 retires the External Defensives frame. Bail at the one chokepoint - -- that builds it, so on that client no frame, font object, event - -- registration or Unlock Mode element is ever created (the module is - -- already zero-footprint until first enabled, so this simply keeps it - -- there). Saved settings are left untouched, so a retail session on the - -- same profile still builds and positions the frame exactly as before. - if EllesmereUI.IS_121 then return end - local cfg = ED() - local enabled = cfg and cfg.enabled - if enabled and not edfRoot then - edfRoot = CreateFrame("Frame", "EUF_ExternalDefensives", UIParent) - edfRoot:EnableMouse(false) - edfFont = CreateFont("EUI_EDF_CountdownFont") - edfEvt = CreateFrame("Frame") - edfEvt:SetScript("OnEvent", EDF_Update) - EDF_RegisterUnlock() - end - if not edfRoot then return end - if enabled then - edfEvt:RegisterUnitEvent("UNIT_AURA", "player") - EDF_ApplyPosition() - EDF_ApplyStyle() - edfRoot:Show() - EDF_Update() - else - edfEvt:UnregisterEvent("UNIT_AURA") - edfRoot:Hide() - end -end -ns.RefreshExternalDefensives = EDF_Setup - -local edfInit = CreateFrame("Frame") -edfInit:RegisterEvent("PLAYER_LOGIN") -edfInit:SetScript("OnEvent", function(self) - self:UnregisterEvent("PLAYER_LOGIN") - -- Same UF-db-init delay the skin below uses. - C_Timer.After(1, EDF_Setup) -end) - -------------------------------------------------------------------------------- --- Initialization -------------------------------------------------------------------------------- -local initFrame = CreateFrame("Frame") -initFrame:RegisterEvent("PLAYER_LOGIN") -initFrame:SetScript("OnEvent", function(self, event, arg1) - if event == "PLAYER_LOGIN" then - self:UnregisterEvent("PLAYER_LOGIN") - - -- Delay to let UF db initialize - C_Timer.After(1, function() - local cfg = PA() - if not cfg or not cfg.enabled then return end - - -- Apply scale - ApplyScale() - - -- Initial skin pass - RefreshAll() - - -- Aura-set dirty signal: aura buttons are only born from - -- aura-list changes, so the grid hooks below sweep only after a - -- player UNIT_AURA. Edit Mode is the one birth path without an - -- aura event (its preview shows example buttons) -- hook its - -- open as a second dirty source. - local auraWatch = CreateFrame("Frame") - auraWatch:RegisterUnitEvent("UNIT_AURA", "player") - auraWatch:SetScript("OnEvent", function() - _paAuraDirty = true - end) - if EditModeManagerFrame then - EditModeManagerFrame:HookScript("OnShow", function() - _paAuraDirty = true - RequestRefresh() - end) - end - - -- Hook aura updates to catch new/changed buttons - if BuffFrame and BuffFrame.AuraContainer then - hooksecurefunc(BuffFrame.AuraContainer, "UpdateGridLayout", function() - RequestRefresh() - end) - if BuffFrame.RefreshConsolidationFrameVisibility then - hooksecurefunc(BuffFrame, "RefreshConsolidationFrameVisibility", function() - -- Deferred: this can fire inside Blizzard's secure - -- buff-system refresh (incl. Edit Mode's passes); - -- hiding inline there taints the rest of that - -- execution. - C_Timer.After(0, function() - local cfgNow = PA() - if cfgNow and cfgNow.showExpandButton == false - and BuffFrame.CollapseAndExpandButton then - BuffFrame.CollapseAndExpandButton:Hide() - end - end) - end) - end - end - if DebuffFrame and DebuffFrame.AuraContainer then - hooksecurefunc(DebuffFrame.AuraContainer, "UpdateGridLayout", function() - RequestRefresh() - end) - end - - end) - end -end) diff --git a/EllesmereUI_AuraKit.lua b/EllesmereUI_AuraKit.lua index 166d8ee2..1682fb7b 100644 --- a/EllesmereUI_AuraKit.lua +++ b/EllesmereUI_AuraKit.lua @@ -231,7 +231,7 @@ local function ApplyStyleToRegions(button, style) d.akStackAnchor = sKey end local c = style.stackColor - if c then d.stack:SetTextColor(c[1], c[2], c[3], c[4] or 1) end + if c then d.stack:SetTextColor(c.r, c.g, c.b, c.a or 1) end end if d.duration then @@ -599,23 +599,25 @@ function AK.MakeInitializer(styleKey, extra) -- Dispel-ring holder: its own frame between the border host and the -- text carrier so the engine-tinted ring ALWAYS WINS over every - -- border. +3, not +1: PP.CreateBorder parks its strips on a - -- CONTAINER child at borderHost+1, and the DM per-filter border - -- override's container lands at borderHost+2 -- the ring clears - -- both. Created UNCONDITIONALLY here -- this is the only - -- guaranteed-legal window for parenting a frame to the button, and - -- a style can gain dispelBorder later via a settings toggle (UF) - -- when the window is long closed. + -- border AND the DM per-filter glow. +4, not +3: PP.CreateBorder + -- parks its strips on a CONTAINER child at borderHost+1, the DM + -- per-filter border override's container lands at borderHost+2, and + -- the DM per-filter glow itself sits at borderHost+3 (ApplyDmFx / + -- PAB_ApplyDmFx) -- the ring clears all three. Created + -- UNCONDITIONALLY here -- this is the only guaranteed-legal window + -- for parenting a frame to the button, and a style can gain + -- dispelBorder later via a settings toggle (UF) when the window is + -- long closed. d.dispelHolder = CreateFrame("Frame", nil, button) d.dispelHolder:SetAllPoints(button) - d.dispelHolder:SetFrameLevel(d.borderHost:GetFrameLevel() + 3) + d.dispelHolder:SetFrameLevel(d.borderHost:GetFrameLevel() + 4) d.dispelHolder:EnableMouse(false) -- Stack and duration text ride a carrier frame above the cooldown, - -- borders and dispel ring so none of them can cover the text. + -- borders, DM glow, and dispel ring so none of them can cover the text. d.stackCarrier = CreateFrame("Frame", nil, button) d.stackCarrier:SetAllPoints(button) - d.stackCarrier:SetFrameLevel(d.borderHost:GetFrameLevel() + 4) + d.stackCarrier:SetFrameLevel(d.borderHost:GetFrameLevel() + 5) d.stackCarrier:EnableMouse(false) d.stack = d.stackCarrier:CreateFontString(nil, "OVERLAY") d.duration = d.stackCarrier:CreateFontString(nil, "OVERLAY") diff --git a/Locales/_keys.txt b/Locales/_keys.txt index 1a5ad812..3a4cf060 100644 --- a/Locales/_keys.txt +++ b/Locales/_keys.txt @@ -1,6 +1,6 @@ # Auto-generated by .tools/extract-locale-keys.sh -- do not edit by hand. # Canonical list of translatable English keys passed as string literals -# (666 unique). Regenerate after wrapping new strings. Keys passed as +# (659 unique). Regenerate after wrapping new strings. Keys passed as # variables are not listed here -- use the in-game /euiloc harvester for # the complete runtime set. (Raid) @@ -98,7 +98,6 @@ Anchor Anchored Apply Apply All Settings To -Apply the sharer's Blizz UI Enhanced Window Skins and Tooltips, Menus & Popups settings. These are account-wide and will overwrite yours across ALL profiles. Off = keep your own. Apply to Bar Apply to Bar (All Specs) Apply to This Spell @@ -318,17 +317,12 @@ Import Full Account Data Import Profile Import Selected Addons Import a profile from string. -Import the anchor & size-match relationships from this profile. Off = keep your own layout; only the selected modules' own positions/settings come in. -Import the sharer's complete override setup: spec and conditional override values, groups, their custom Unlock Mode layouts, and Buff Manager overrides. This replaces ALL of your own overrides. Off = keep yours untouched. Import will include %1$s of %2$s addons. Importing %1$s In-game countdown unavailable in combat; the boss mod pull timer still started. Include Include CDM Spell Layout? -Include Overrides Include This Spell -Include Window Skins -Include layout Include your Cooldown Manager spell layout (which spells sit on which bars) plus all per-spell settings for any specs you choose. Include: Independent @@ -421,6 +415,7 @@ Overrides Overwrite Existing Settings Overwrite Window & Tooltip Settings? PER-SPELL OPTIONS +PREVIEW Page Party Keystones Paste Interrupted @@ -585,8 +580,6 @@ This is where you can control the settings of Unlock Mode.\n\nElements can be re This option requires %1$s to be %2$s This option requires Subtitle Text to include the Guild Name This preset -This profile string does not carry any Window & Tooltip Skins settings. -This profile string does not carry any override data. This profile was made at %1$d%% UI scale; yours is %2$d%%. Change your UI scale to match the imported profile? This will show all profiles at this scale as UI Scale is not a per-profile setting, but can be changed at any time back to your original value. This setting's active Apply to Bar (All Specs) value will be replaced. This setting's active Apply to Bar value will be replaced.